From 1c0a4ceeb21b62e243776489ade2d6ee0e912887 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 23:47:13 +0800 Subject: [PATCH 01/38] Add portable LUI runtime primitives while preserving upstream identity --- LUI-FORK.md | 27 +++++ content.go | 12 +- content_menu_images_test.go | 59 ++++++++++ dialog.go | 31 +---- internal/darwin/access.go | 3 +- internal/darwin/dialog.go | 3 + internal/darwin/lui_menu_test.go | 50 ++++++++ internal/darwin/menu.go | 8 ++ internal/darwin/testhooks.go | 54 +++++++++ internal/linux/dialog.go | 3 + internal/linux/ffi.go | 4 + internal/linux/lui_menu_access.go | 46 ++++++++ internal/linux/lui_testhooks.go | 46 ++++++++ internal/linux/menu.go | 31 ++++- internal/linux/surface_access.go | 3 +- internal/linux/testhooks.go | 4 +- internal/platform/access.go | 2 + internal/platform/platform.go | 4 + internal/text/layout.go | 10 ++ internal/text/lui_line_height_test.go | 31 +++++ internal/windows/dialog.go | 4 + internal/windows/lui_input_test.go | 61 ++++++++++ internal/windows/lui_testhooks.go | 36 ++++++ internal/windows/menu.go | 22 +++- internal/windows/menudraw.go | 61 +++++++++- internal/windows/surface_access.go | 1 + internal/windows/surface_input.go | 5 +- internal/windows/testhooks_surface.go | 9 +- lui_dialog.go | 102 ++++++++++++++++ lui_dialog_test.go | 74 ++++++++++++ menu.go | 4 + testing/native/darwin.go | 26 +++++ testing/native/linux.go | 44 +++++++ testing/native/windows.go | 44 +++++++ ui/access.go | 4 +- ui/context.go | 2 + ui/element.go | 30 +++-- ui/headless.go | 33 +++++- ui/layout.go | 52 +++++++-- ui/layoutcache.go | 2 +- ui/layoutcache_test.go | 4 +- ui/list.go | 23 +++- ui/lui_extensions_test.go | 87 ++++++++++++++ ui/lui_layout.go | 152 ++++++++++++++++++++++++ ui/lui_link.go | 16 +++ ui/lui_menu.go | 74 ++++++++++++ ui/lui_menu_test.go | 126 ++++++++++++++++++++ ui/lui_overlay.go | 38 ++++++ ui/lui_overlay_test.go | 58 ++++++++++ ui/lui_style.go | 161 ++++++++++++++++++++++++++ ui/lui_style_test.go | 81 +++++++++++++ ui/menu.go | 3 + ui/paint.go | 4 +- ui/runtime.go | 11 ++ ui/scope.go | 4 + ui/widgets.go | 7 +- 56 files changed, 1814 insertions(+), 82 deletions(-) create mode 100644 LUI-FORK.md create mode 100644 content_menu_images_test.go create mode 100644 internal/darwin/lui_menu_test.go create mode 100644 internal/linux/lui_menu_access.go create mode 100644 internal/linux/lui_testhooks.go create mode 100644 internal/text/lui_line_height_test.go create mode 100644 internal/windows/lui_input_test.go create mode 100644 internal/windows/lui_testhooks.go create mode 100644 lui_dialog.go create mode 100644 lui_dialog_test.go create mode 100644 testing/native/darwin.go create mode 100644 testing/native/linux.go create mode 100644 testing/native/windows.go create mode 100644 ui/lui_extensions_test.go create mode 100644 ui/lui_layout.go create mode 100644 ui/lui_link.go create mode 100644 ui/lui_menu.go create mode 100644 ui/lui_menu_test.go create mode 100644 ui/lui_overlay.go create mode 100644 ui/lui_overlay_test.go create mode 100644 ui/lui_style.go create mode 100644 ui/lui_style_test.go diff --git a/LUI-FORK.md b/LUI-FORK.md new file mode 100644 index 000000000..83508b499 --- /dev/null +++ b/LUI-FORK.md @@ -0,0 +1,27 @@ +# LUI compatibility branch + +This branch starts at upstream MyGo v0.6.4, commit +`6255ea40139d06e0762a7c4a32626148ba410478`, and retains the complete upstream +repository, tests and licenses. Its Go module path and self-imports remain +`github.com/egoist/mygo`, preserving the upstream identity. Downstream applications +can pin an immutable fork version with their Go module replacement mechanism. + +No third-party source, module cache or generated build output needs to be +committed to downstream applications. + +Local extensions are deliberately limited to reusable native runtime primitives required by portable LUI behavior: + +- `Context.Measure` isolates intrinsic measurement; `Context.Fitting` chooses using final local layout constraints and constructs only the selected retained candidate. Fitting subtrees bypass cached-layout reuse. +- `Context.AfterFrame` runs once after the final committed layout, including late virtual rows and fitting children, before the next input/render pass. +- `ListState.SetOverscan` and `SetItemExtent` configure bounded virtualization using the actual local viewport. A scroll request fencepost is corrected and `ScrollState.Stop` cancels superseded animation. +- `Element.ZIndex` orders painting and pointer hits without reordering layout. `DisabledOpacity`, percentage minimums with pixel offsets, `ScrollAxes`, `WhiteSpace`, `Inert`, explicit italic and decoration overrides expose portable style behavior. `Tester.Accessibility` returns a copied committed accessibility tree for integration assertions. +- `Dialog.OpenContext` cancels the owned native picker on macOS, GTK and Windows and filters a result if cancellation wins the completion race. `Dialog.Open` preserves its existing pre-start diagnostic. +- `RoleTabPanel`, `OnMenuDismiss`, native cancellation notification, native menu `ImagePNG`/SVG rasterization, `LinkBase`, `CollisionPadding` and late-bound `AttachToHandle`/`MatchAnchorWidth` support accessible tabs and native menus/popovers, including targets constructed later in the same frame. + +The opt-in `testing/native` facade (`lui_native_smoke` build tag) exposes existing native host hooks for real Cocoa, GTK and Win32 qualification. Small host helpers read actual ATK/UIA focus and native popup image/name bindings. GTK image rows keep an explicit ATK name and update their retained label without removing the icon. These checks cover native interfaces and input-protocol boundaries; they do not claim audible screen-reader output or keyboard IME candidate UI. + +Focused `ui/lui_*_test.go` tests verify these extensions through the real layout, interaction, accessibility and rendering pipeline. Each extension source carries its API documentation; existing runtime files contain only the necessary integration hooks. + +Refresh this branch by merging an upstream release, reviewing these extensions, +and rerunning the layout, input, accessibility and native host checks. Preserve +upstream copyright notices and asset licenses. Never patch the Go module cache. diff --git a/content.go b/content.go index 7c4847f54..1a53c6bdb 100644 --- a/content.go +++ b/content.go @@ -88,13 +88,21 @@ func (w *Window) attachContent() { PlaceWebViews: w.placeWebViews, PopupMenu: func(m *platform.Menu, x, y float64, chosen func(int)) { pos := &platform.Point{X: int(math.Round(x)), Y: int(math.Round(y))} - menu := NewMenu(contentMenu(m, chosen)) + completed := false + notify := func(id int) { + if !completed { + completed = true + chosen(id) + } + } + menu := NewMenu(contentMenu(m, notify)) // The menu waits for the user in a loop of its own: not in the // event that asked for it, which the surface is handling. postMain(func() { if w.native != nil { menu.popup(w, pos, m.RightToLeft) } + notify(0) }) }, } @@ -106,7 +114,7 @@ func (w *Window) attachContent() { func contentMenu(m *platform.Menu, chosen func(int)) []*MenuItem { items := make([]*MenuItem, 0, len(m.Items)) for _, p := range m.Items { - it := &MenuItem{Label: p.Label, Accelerator: p.Accelerator, Disabled: !p.Enabled, Hidden: !p.Visible, Checked: p.Checked} + it := &MenuItem{Label: p.Label, Accelerator: p.Accelerator, Disabled: !p.Enabled, Hidden: !p.Visible, Checked: p.Checked, imagePNG: append([]byte(nil), p.ImagePNG...)} switch p.Type { case platform.MenuItemSeparator: it.Type = MenuItemSeparator diff --git a/content_menu_images_test.go b/content_menu_images_test.go new file mode 100644 index 000000000..6326e161b --- /dev/null +++ b/content_menu_images_test.go @@ -0,0 +1,59 @@ +package mygo + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" + + "github.com/egoist/mygo/internal/platform" +) + +func TestContentNativeMenuPreservesOwnedImagesThroughHostSnapshots(t *testing.T) { + pixels := image.NewRGBA(image.Rect(0, 0, 2, 2)) + pixels.SetRGBA(1, 1, color.RGBA{R: 255, A: 255}) + var encoded bytes.Buffer + if err := png.Encode(&encoded, pixels); err != nil { + t.Fatal(err) + } + icon := encoded.Bytes() + makeItem := func(id int, label string) *platform.MenuItem { + return &platform.MenuItem{ID: id, Label: label, Enabled: true, Visible: true, ImagePNG: append([]byte(nil), icon...)} + } + plain, checked, nested := makeItem(1, "Plain icon"), makeItem(2, "Checked icon"), makeItem(4, "Nested icon") + checked.Type, checked.Checked = platform.MenuItemCheckbox, true + submenu := makeItem(3, "Submenu icon") + submenu.Type, submenu.Submenu = platform.MenuItemSubmenu, &platform.Menu{Items: []*platform.MenuItem{nested}} + chosen := 0 + items := contentMenu(&platform.Menu{Items: []*platform.MenuItem{plain, checked, submenu}}, func(id int) { chosen = id }) + // The host bridge owns an opening snapshot after Content returns; callers + // may publish a replacement or release their original PNG buffers. + for _, item := range []*platform.MenuItem{plain, checked, submenu, nested} { + clear(item.ImagePNG) + } + menu := NewMenu(items) + snapshot := menu.snapshot() + all := []*platform.MenuItem{snapshot.Items[0], snapshot.Items[1], snapshot.Items[2], snapshot.Items[2].Submenu.Items[0]} + for _, item := range all { + decoded, err := png.Decode(bytes.NewReader(item.ImagePNG)) + if err != nil { + t.Errorf("native host lost %q decoded image: %v", item.Label, err) + continue + } + if got := color.RGBAModel.Convert(decoded.At(1, 1)).(color.RGBA); got != (color.RGBA{R: 255, A: 255}) { + t.Errorf("native host changed %q image pixels: %v", item.Label, got) + } + } + if !snapshot.Items[1].Checked || snapshot.Items[1].Type != platform.MenuItemCheckbox { + t.Fatal("image forwarding changed checked menu behavior") + } + items[2].Submenu[0].Click(nil, nil) + if chosen != 4 { + t.Fatalf("nested image item selected %d, want 4", chosen) + } + clear(snapshot.Items[0].ImagePNG) + if again := menu.snapshot().Items[0].ImagePNG; !bytes.Equal(again, icon) { + t.Fatal("native snapshot shares mutable image bytes with retained menu") + } +} diff --git a/dialog.go b/dialog.go index 7d5ff957f..33d7254de 100644 --- a/dialog.go +++ b/dialog.go @@ -1,6 +1,7 @@ package mygo import ( + "context" "strings" "github.com/egoist/mygo/internal/platform" @@ -115,35 +116,9 @@ func toFilters(fs []FileFilter) []platform.FileFilter { // Open shows a dialog to pick files or directories and returns the chosen // paths, or nil when canceled. -func (DialogModule) Open(opts OpenDialogOptions) ([]string, error) { +func (d DialogModule) Open(opts OpenDialogOptions) ([]string, error) { needsApp("Dialog.Open") - type result struct { - paths []string - err error - } - ch := make(chan result, 1) - popts := &platform.OpenDialogOptions{ - Title: opts.Title, - DefaultPath: opts.DefaultPath, - ButtonLabel: opts.ButtonLabel, - Message: opts.Message, - Filters: toFilters(opts.Filters), - OpenFiles: !opts.Directory, - OpenDirectories: opts.Directory, - Multiple: opts.Multiple, - ShowHiddenFiles: opts.ShowHiddenFiles, - CreateDirectories: opts.CreateDirectories, - TreatPackagesAsDirectories: opts.TreatPackagesAsDirectories, - } - if !postMain(func() { - backend().Dialogs().ShowOpenDialog(opts.Parent.nativeOrNil(), popts, func(paths []string, err error) { - deliver(ch, result{paths, err}) - }) - }) { - return nil, errLoopStopped - } - r := await(ch) - return r.paths, r.err + return d.OpenContext(context.Background(), opts) } // Save shows a dialog to choose where to save a file and returns the path, diff --git a/internal/darwin/access.go b/internal/darwin/access.go index 2a1b72caf..f4205c5d6 100644 --- a/internal/darwin/access.go +++ b/internal/darwin/access.go @@ -123,7 +123,8 @@ var accessRoles = map[platform.AccessRole][2]string{ platform.RoleMenuItemCheckBox: {"AXMenuItem", ""}, platform.RoleMenuItemRadio: {"AXMenuItem", ""}, // WebKit's headings, whose value is their level. - platform.RoleHeading: {"AXHeading", ""}, + platform.RoleHeading: {"AXHeading", ""}, + platform.RoleTabPanel: {"AXGroup", ""}, } // menuItem reports whether nodes of a role are items of a menu. diff --git a/internal/darwin/dialog.go b/internal/darwin/dialog.go index f3fe988d4..2d536e910 100644 --- a/internal/darwin/dialog.go +++ b/internal/darwin/dialog.go @@ -101,6 +101,9 @@ func (d dialogs) ShowOpenDialog(parent platform.Window, o *platform.OpenDialogOp send(panel, "setAllowsMultipleSelection:", boolArg(o.Multiple)) setupSavePanel(panel, o.Title, o.Message, o.ButtonLabel, o.DefaultPath, o.Filters, o.ShowHiddenFiles, o.CreateDirectories, o.TreatPackagesAsDirectories) + if o.RegisterCancel != nil { + o.RegisterCancel(func() { withPool(func() { send(panel, "cancel:", 0) }) }) + } runPanel(panel, asWindow(parent), "beginSheetModalForWindow:completionHandler:", func(resp int) { if resp != nsModalResponseOK { cb(nil, nil) diff --git a/internal/darwin/lui_menu_test.go b/internal/darwin/lui_menu_test.go new file mode 100644 index 000000000..8a143ef36 --- /dev/null +++ b/internal/darwin/lui_menu_test.go @@ -0,0 +1,50 @@ +//go:build darwin + +package darwin + +import ( + "bytes" + "image" + "image/color" + "image/png" + "runtime" + "testing" + + "github.com/egoist/mygo/internal/platform" +) + +func TestLUINativeMenuImagePixelsAndRemoval(t *testing.T) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + load() + source := image.NewRGBA(image.Rect(0, 0, 16, 16)) + for y := range 16 { + for x := range 16 { + source.SetRGBA(x, y, color.RGBA{R: 255, A: 255}) + } + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, source); err != nil { + t.Fatal(err) + } + withPool(func() { + item := autorelease(send(send(class("NSMenuItem"), "alloc"), "initWithTitle:action:keyEquivalent:", uintptr(nsString("Inspect")), 0, uintptr(nsString("")))) + applyItemState(item, &platform.MenuItem{Label: "Inspect", Visible: true, Enabled: true, ImagePNG: encoded.Bytes()}) + native := send(item, "image") + if native == 0 { + t.Fatal("NSMenuItem did not retain its native image") + } + result, err := png.Decode(bytes.NewReader(pngOfImage(native, 16))) + if err != nil { + t.Fatal(err) + } + r, g, b, a := result.At(8, 8).RGBA() + if r < 60000 || g > 500 || b > 500 || a < 60000 { + t.Fatalf("native menu image pixels: %d %d %d %d", r, g, b, a) + } + applyItemState(item, &platform.MenuItem{Label: "Inspect", Visible: true, Enabled: true}) + if send(item, "image") != 0 { + t.Fatal("native image survived removal") + } + }) +} diff --git a/internal/darwin/menu.go b/internal/darwin/menu.go index 18e2f9f56..8e3293ac2 100644 --- a/internal/darwin/menu.go +++ b/internal/darwin/menu.go @@ -153,6 +153,14 @@ func (b *Backend) buildItem(it *platform.MenuItem, owner int) id { } func applyItemState(item id, it *platform.MenuItem) { + var image id + if len(it.ImagePNG) > 0 { + image = autorelease(send(send(class("NSImage"), "alloc"), "initWithData:", uintptr(nsData(it.ImagePNG)))) + if image != 0 { + msgSetSize(image, sel("setSize:"), NSSize{16, 16}) + } + } + send(item, "setImage:", uintptr(image)) send(item, "setEnabled:", boolArg(it.Enabled)) send(item, "setHidden:", boolArg(!it.Visible)) state := uintptr(0) diff --git a/internal/darwin/testhooks.go b/internal/darwin/testhooks.go index 98516ce5c..e84cdd74a 100644 --- a/internal/darwin/testhooks.go +++ b/internal/darwin/testhooks.go @@ -69,6 +69,56 @@ func TestOnScreen(handle uintptr) bool { return sendInt(id(handle), "occlusionState")&occlusionStateVisible != 0 } +// TestWindowNumber returns the WindowServer window ID for capture and recording. +func TestWindowNumber(handle uintptr) int { return sendInt(id(handle), "windowNumber") } + +var testScroll struct { + once sync.Once + create func(uintptr, uint32, uint32, int32, int32, int32) uintptr + getLocation func(uintptr) NSPoint + setLocation func(uintptr, NSPoint) +} + +// TestScroll delivers a native pixel wheel event at window content coordinates. +// Positive dx/dy scroll right/down, matching the public headless tester. +func TestScroll(handle uintptr, x, y, dx, dy float64) bool { + w := theBackend.byNSWindow[id(handle)] + if w == nil || w.surface == nil { + return false + } + testScroll.once.Do(func() { + purego.RegisterLibFunc(&testScroll.create, libCG, "CGEventCreateScrollWheelEvent2") + purego.RegisterLibFunc(&testScroll.getLocation, libCG, "CGEventGetLocation") + purego.RegisterLibFunc(&testScroll.setLocation, libCG, "CGEventSetLocation") + }) + ok := false + withPool(func() { + event := testScroll.create(0, 0, 2, -int32(math.Round(dy)), -int32(math.Round(dx)), 0) + if event == 0 { + return + } + defer cfRelease(event) + bounds := w.ContentBounds() + testScroll.setLocation(event, NSPoint{float64(bounds.X) + x, float64(bounds.Y) + y}) + native := send(class("NSEvent"), "eventWithCGEvent:", event) + if native == 0 { + return + } + content := msgRect(send(w.win, "contentView"), sel("frame")) + want := NSPoint{x, content.Size.Height - y} + at := msgPoint(native, sel("locationInWindow")) + location := testScroll.getLocation(event) + testScroll.setLocation(event, NSPoint{location.X + want.X - at.X, location.Y - (want.Y - at.Y)}) + native = send(class("NSEvent"), "eventWithCGEvent:", event) + if native == 0 { + return + } + send(w.surface.view, "scrollWheel:", uintptr(native)) + ok = true + }) + return ok +} + // TestEndSheet ends the sheet attached to a window as if its first button // was clicked, and reports whether there was one. func TestEndSheet(handle uintptr) bool { @@ -100,6 +150,10 @@ func TestAppPress(handle uintptr, x, y float64, down bool) { testMouse(handle, x, y, true, typ) } +// TestDragMove advances a held native gesture without pressing or releasing. +// Tests may commit a real frame between moves, as a user's gesture does. +func TestDragMove(handle uintptr, x, y float64) { testMouse(handle, x, y, false, 6) } + func testClick(handle uintptr, x, y float64, viaApp bool) { testMouse(handle, x, y, viaApp, 1, 2) // NSEventTypeLeftMouseDown, LeftMouseUp } diff --git a/internal/linux/dialog.go b/internal/linux/dialog.go index 4101cf569..16502740b 100644 --- a/internal/linux/dialog.go +++ b/internal/linux/dialog.go @@ -84,6 +84,9 @@ func (d dialogs) ShowOpenDialog(parent platform.Window, o *platform.OpenDialogOp fc := gtkFileChooserNativeNew(cs(title), parentWindow(parent), action, cs(accept), cs("_"+tx.Cancel)) gtkFileChooserSetSelectMultiple(fc, o.Multiple) setupChooser(fc, o.DefaultPath, o.Filters, o.ShowHiddenFiles, false) + if o.RegisterCancel != nil { + o.RegisterCancel(func() { gtkNativeDialogHide(fc) }) + } var paths []string if d.b.runDialog(fc, true) == gtkResponseAccept { paths = slistStrings(gtkFileChooserGetFilenames(fc)) diff --git a/internal/linux/ffi.go b/internal/linux/ffi.go index e5e084197..f86beda40 100644 --- a/internal/linux/ffi.go +++ b/internal/linux/ffi.go @@ -298,6 +298,8 @@ var ( gtkWidgetSetNoShowAll func(w ptr, v bool) gtkWidgetSetVisible func(w ptr, v bool) gtkWidgetMnemonicActivate func(w ptr, groupCycling bool) bool + gtkImageNewFromPixbuf func(pixbuf ptr) ptr + gtkLabelNew func(text *byte) ptr gtkBoxNew func(orientation int32, spacing int32) ptr gtkBoxPackStart func(box, child ptr, expand, fill bool, padding uint32) gtkBoxReorderChild func(box, child ptr, pos int32) @@ -725,6 +727,8 @@ func load() error { mustBind(t, >kWidgetSetNoShowAll, "gtk_widget_set_no_show_all") mustBind(t, >kWidgetSetVisible, "gtk_widget_set_visible") mustBind(t, >kWidgetMnemonicActivate, "gtk_widget_mnemonic_activate") + mustBind(t, >kImageNewFromPixbuf, "gtk_image_new_from_pixbuf") + mustBind(t, >kLabelNew, "gtk_label_new") mustBind(t, >kBoxNew, "gtk_box_new") mustBind(t, >kBoxPackStart, "gtk_box_pack_start") mustBind(t, >kBoxReorderChild, "gtk_box_reorder_child") diff --git a/internal/linux/lui_menu_access.go b/internal/linux/lui_menu_access.go new file mode 100644 index 000000000..f71217945 --- /dev/null +++ b/internal/linux/lui_menu_access.go @@ -0,0 +1,46 @@ +//go:build linux && (amd64 || arm64) + +package linux + +import "sync" + +var menuAccessOnce sync.Once +var menuWidgetAccessible func(ptr) ptr +var menuAccessibleName func(ptr) ptr +var menuSetAccessibleName func(ptr, *byte) +var menuImageGetType func() uintptr +var menuBinGetChild func(ptr) ptr +var menuContainerGetType func() uintptr +var menuLabelSetText func(ptr, *byte) + +func loadMenuAccess() { + menuAccessOnce.Do(func() { + atk, err := open("libatk-1.0.so.0") + if err != nil { + panic(err) + } + mustBind(libGTK, &menuWidgetAccessible, "gtk_widget_get_accessible") + mustBind(atk, &menuAccessibleName, "atk_object_get_name") + mustBind(atk, &menuSetAccessibleName, "atk_object_set_name") + mustBind(libGTK, &menuImageGetType, "gtk_image_get_type") + mustBind(libGTK, &menuBinGetChild, "gtk_bin_get_child") + mustBind(libGTK, &menuContainerGetType, "gtk_container_get_type") + mustBind(libGTK, &menuLabelSetText, "gtk_label_set_text") + }) +} + +// Explicit ATK names remain stable when a GtkMenuItem contains an image row +// rather than GtkLabel, which GTK's default accessible-name lookup requires. +func nameMenuItem(item ptr, label string) { + loadMenuAccess() + if obj := menuWidgetAccessible(item); obj != 0 { + menuSetAccessibleName(obj, cs(label)) + } +} +func menuItemText(item ptr) string { + if text := goStr(gtkMenuItemGetLabel(item)); text != "" { + return text + } + loadMenuAccess() + return goStr(menuAccessibleName(menuWidgetAccessible(item))) +} diff --git a/internal/linux/lui_testhooks.go b/internal/linux/lui_testhooks.go new file mode 100644 index 000000000..a79571b9b --- /dev/null +++ b/internal/linux/lui_testhooks.go @@ -0,0 +1,46 @@ +//go:build linux && (amd64 || arm64) + +package linux + +// TestAccessibilityFocused reads the real ATK focused state of a snapshot node. +func TestAccessibilityFocused(n TestAccessNode) bool { + accessTestsOnce.Do(loadAccessTests) + states := atkObjectRefStateSet(n.obj) + if states == 0 { + return false + } + defer gObjectUnref(states) + return atkStateSetContainsState(states, atkStates.focused) +} + +// TestPopupImageLabels reads actual GTK image children and their ATK names. +func TestPopupImageLabels() []string { + loadMenuAccess() + var names []string + for _, menu := range theBackend.popups { + items := gtkContainerGetChildren(menu) + for at := items; at != 0; at = field[ptr](at, 8) { + item := field[ptr](at, 0) + row := menuBinGetChild(item) + if row == 0 { + continue + } + // Rows added for image menu items are containers; GtkLabel is not. + if !gTypeCheckInstanceIsA(row, menuContainerGetType()) { + continue + } + children := gtkContainerGetChildren(row) + for child := children; child != 0; child = field[ptr](child, 8) { + if gTypeCheckInstanceIsA(field[ptr](child, 0), menuImageGetType()) { + if label := goStr(menuAccessibleName(menuWidgetAccessible(item))); label != "" { + names = append(names, label) + } + break + } + } + gListFree(children) + } + gListFree(items) + } + return names +} diff --git a/internal/linux/menu.go b/internal/linux/menu.go index ba4b9558d..8c06c7d2f 100644 --- a/internal/linux/menu.go +++ b/internal/linux/menu.go @@ -22,6 +22,7 @@ import ( type nativeItem struct { owner int item ptr + label ptr check bool windowID int // menu bar or popup's window; 0 for a tray } @@ -201,7 +202,7 @@ func buildMenu(shell ptr, m *platform.Menu, accel ptr, owner, windowID int) { } func buildItem(it *platform.MenuItem, accel ptr, owner, windowID int) ptr { - var item ptr + var item, customLabel ptr check := false switch it.Type { case platform.MenuItemSeparator: @@ -213,6 +214,22 @@ func buildItem(it *platform.MenuItem, accel ptr, owner, windowID int) ptr { default: item = gtkMenuItemNewWithLabel(cs(it.Label)) } + if len(it.ImagePNG) > 0 && it.Type != platform.MenuItemSeparator { + if pix, err := pixbufFromPNG(it.ImagePNG); err == nil { + row := gtkBoxNew(0, 8) + children := gtkContainerGetChildren(item) + for link := children; link != 0; link = field[ptr](link, 8) { + gtkContainerRemove(item, field[ptr](link, 0)) + } + gListFree(children) + gtkBoxPackStart(row, gtkImageNewFromPixbuf(pix), false, false, 0) + customLabel = gtkLabelNew(cs(it.Label)) + gtkBoxPackStart(row, customLabel, true, true, 0) + gtkContainerAdd(item, row) + nameMenuItem(item, it.Label) + gObjectUnref(pix) + } + } if it.Submenu != nil { sub := gtkMenuNew() buildMenu(sub, it.Submenu, accel, owner, windowID) @@ -233,7 +250,7 @@ func buildItem(it *platform.MenuItem, accel ptr, owner, windowID int) ptr { } } applyState(item, it, check) - itemsByID[it.ID] = append(itemsByID[it.ID], nativeItem{owner: owner, item: item, check: check, windowID: windowID}) + itemsByID[it.ID] = append(itemsByID[it.ID], nativeItem{owner: owner, item: item, label: customLabel, check: check, windowID: windowID}) return item } @@ -396,7 +413,12 @@ func firstMenu(bar ptr) ptr { func (b *Backend) UpdateMenuItem(it *platform.MenuItem) { for _, n := range itemsByID[it.ID] { if it.Type != platform.MenuItemSeparator { - gtkMenuItemSetLabel(n.item, cs(it.Label)) + if n.label != 0 { + menuLabelSetText(n.label, cs(it.Label)) + nameMenuItem(n.item, it.Label) + } else { + gtkMenuItemSetLabel(n.item, cs(it.Label)) + } } applyState(n.item, it, n.check) } @@ -458,8 +480,9 @@ func (b *Backend) PopupMenu(m *platform.Menu, pw platform.Window, pos *platform. gMainLoopRun(loop) b.quitLoops = b.quitLoops[:len(b.quitLoops)-1] b.popups = b.popups[:len(b.popups)-1] - gMainLoopUnref(loop) + // Destroy can emit deactivate again; its callback still owns this loop. gtkWidgetDestroy(menu) + gMainLoopUnref(loop) } // Tray icons use AppIndicator (StatusNotifierItem), which shows a menu on diff --git a/internal/linux/surface_access.go b/internal/linux/surface_access.go index f50786360..33d8a24b0 100644 --- a/internal/linux/surface_access.go +++ b/internal/linux/surface_access.go @@ -314,7 +314,8 @@ func loadAccessNames() { platform.RoleColorWell: {"push button", "button"}, platform.RoleAlertDialog: {"alert", "dialog"}, platform.RoleMenu: {"menu"}, platform.RoleMenuBar: {"menu bar"}, platform.RoleMenuItem: {"menu item"}, platform.RoleMenuItemCheckBox: {"check menu item", "menu item"}, platform.RoleMenuItemRadio: {"radio menu item", "menu item"}, - platform.RoleHeading: {"heading", "label"}, + platform.RoleHeading: {"heading", "label"}, + platform.RoleTabPanel: {"panel"}, } { atkRoles[r] = role(names...) } diff --git a/internal/linux/testhooks.go b/internal/linux/testhooks.go index da7868bd6..8b81d5780 100644 --- a/internal/linux/testhooks.go +++ b/internal/linux/testhooks.go @@ -48,7 +48,7 @@ func findMenuItem(shell ptr, label string) ptr { defer gListFree(list) for node := list; node != 0; node = field[ptr](node, 8) { item := field[ptr](node, 0) - if goStr(gtkMenuItemGetLabel(item)) == label { + if menuItemText(item) == label { return item } } @@ -226,7 +226,7 @@ func TestPopups() [][]string { labels := []string{} list := gtkContainerGetChildren(m) for node := list; node != 0; node = field[ptr](node, 8) { - label := goStr(gtkMenuItemGetLabel(field[ptr](node, 0))) + label := menuItemText(field[ptr](node, 0)) if label == "" { label = "-" } diff --git a/internal/platform/access.go b/internal/platform/access.go index d9d56fc1a..853c5c828 100644 --- a/internal/platform/access.go +++ b/internal/platform/access.go @@ -147,6 +147,8 @@ const ( // RoleHeading is the title of a section, Level its rank: 1 for the // highest. RoleHeading + // RoleTabPanel holds the content associated with a tab. + RoleTabPanel ) // Ranged reports whether nodes of a role have a value in a range (Min, diff --git a/internal/platform/platform.go b/internal/platform/platform.go index c3e5f5244..0441b72d1 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -739,6 +739,8 @@ type Menu struct { // MenuItem is a snapshot of a menu item. ID identifies the public item the // native item was built from. type MenuItem struct { + // ImagePNG holds an optional small icon, owned by this menu snapshot. + ImagePNG []byte ID int Type MenuItemType Label string @@ -759,6 +761,8 @@ type FileFilter struct { // OpenDialogOptions configures an open panel. type OpenDialogOptions struct { + // RegisterCancel installs a dialog-specific close operation on the UI thread. + RegisterCancel func(func()) Title string DefaultPath string ButtonLabel string diff --git a/internal/text/layout.go b/internal/text/layout.go index be912343f..3ccaf9cc6 100644 --- a/internal/text/layout.go +++ b/internal/text/layout.go @@ -990,6 +990,7 @@ func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetr } } size := p.Style.FontSize() + var objectAscent float32 x0 := float32(math.MaxFloat32) starts := *buf for _, run := range sl.runs { @@ -1027,6 +1028,7 @@ func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetr // it is, whatever font its run took. g.Object, g.Advance, g.ID = true, o.ObjectWidth, 0 line.Ascent = max(line.Ascent, o.ObjectHeight) + objectAscent = max(objectAscent, o.ObjectHeight) } g.Cluster += offset } @@ -1044,6 +1046,14 @@ func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetr } line.Width = max(line.Width, 0) line.Height = max(m.lineHeight, line.Ascent+line.Descent) + if p.Style.LineHeight > 0 { + // Explicit line boxes may overlap font ink. Inline objects still + // contribute layout height rather than merely overflowing glyphs. + line.Height = m.lineHeight + if objectAscent > 0 { + line.Height = max(line.Height, objectAscent+line.Descent) + } + } line.Y = *y line.Baseline = *y + (line.Height-line.Ascent-line.Descent)/2 + line.Ascent for i := range line.Glyphs { diff --git a/internal/text/lui_line_height_test.go b/internal/text/lui_line_height_test.go new file mode 100644 index 000000000..3b37748a8 --- /dev/null +++ b/internal/text/lui_line_height_test.go @@ -0,0 +1,31 @@ +package text + +import "testing" + +func TestLUIExplicitLineBoxesPreserveInkAndInlineObjectMetrics(t *testing.T) { + s := Shared() + for _, family := range []string{"system-ui", "serif", "monospace"} { + t.Run(family, func(t *testing.T) { + style := Style{Size: 40, Family: family, LineHeight: .25} + layout := s.Layout(Params{Text: "first\nsecond", Style: style}) + if len(layout.Lines) != 2 || layout.Height != 20 { + t.Errorf("explicit line boxes: lines=%d height=%v, want 2 lines of 10", len(layout.Lines), layout.Height) + } + if layout.Lines[0].Ascent+layout.Lines[0].Descent <= 10 { + t.Error("font ink metrics were truncated to the explicit line height") + } + style.LineHeight = 0 + natural := s.Layout(Params{Text: "first\nsecond", Style: style}) + if natural.Height <= 20 { + t.Errorf("default line spacing lost natural font metrics: %v", natural.Height) + } + style.LineHeight = .25 + object := s.Layout(Params{Text: "a\ufffcb", Style: style, Spans: EncodeSpans([]Span{ + {End: 1}, {End: 2, ObjectWidth: 30, ObjectHeight: 70}, {End: 3}, + })}) + if object.Height < 70 { + t.Errorf("inline object no longer contributes its layout height: %v", object.Height) + } + }) + } +} diff --git a/internal/windows/dialog.go b/internal/windows/dialog.go index 3a98c7708..dfa509b22 100644 --- a/internal/windows/dialog.go +++ b/internal/windows/dialog.go @@ -37,6 +37,7 @@ const ( fdSetOkButtonLabel = 18 fdSetFileNameLabel = 19 fdGetResult = 20 + fdClose = 23 fodGetResults = 27 siGetDisplayName = 5 @@ -81,6 +82,9 @@ func (d dialogs) ShowOpenDialog(parent platform.Window, o *platform.OpenDialogOp opts |= fosForceShowHidden } setupFileDialog(dlg, opts, o.Title, o.ButtonLabel, "", o.DefaultPath, o.Filters) + if o.RegisterCancel != nil { + o.RegisterCancel(func() { comCall(dlg, fdClose, uintptr(0x800704c7)) }) + } if failed(comCall(dlg, fdShow, ownerOf(parent))) { cb(nil, nil) // canceled return diff --git a/internal/windows/lui_input_test.go b/internal/windows/lui_input_test.go new file mode 100644 index 000000000..ae04cb68a --- /dev/null +++ b/internal/windows/lui_input_test.go @@ -0,0 +1,61 @@ +//go:build windows && (amd64 || arm64) + +package windows + +import ( + "reflect" + "runtime" + "testing" + "unicode/utf16" + "unsafe" + + "github.com/egoist/mygo/internal/platform" +) + +func TestLUIIMERequestDocumentFeedBufferBoundaries(t *testing.T) { + for _, request := range []uintptr{imrDocumentFeed, imrReconvertString} { + for _, text := range []string{"", "A🙂中"} { + t.Run(text, func(t *testing.T) { + input := platform.TextInputState{Active: true, Text: text} + if text != "" { + input.Start, input.End = 1, 2 + } + s := surface{input: input} + head := uint32(unsafe.Sizeof(reconvertString{})) + units := utf16.Encode([]rune(text)) + size := uintptr(head) + uintptr(2*len(units)) + if got := s.imeRequest(request, 0); got != size { + t.Fatalf("request %d: buffer size %d, want %d", request, got, size) + } + buf := make([]uint32, (size+3)/4) + rs := (*reconvertString)(unsafe.Pointer(&buf[0])) + rs.Size = uint32(size) + if got := s.imeRequest(request, uintptr(unsafe.Pointer(rs))); got != size { + t.Fatalf("request %d: wrote %d bytes, want %d", request, got, size) + } + wantStart, wantLength := uint32(0), uint32(0) + if text != "" { + wantStart, wantLength = 2, 2 + } + want := reconvertString{Size: uint32(size), StrLen: uint32(len(units)), StrOffset: head, + CompStrOffset: wantStart, CompStrLen: wantLength, TargetStrOffset: wantStart, TargetStrLen: wantLength} + if *rs != want { + t.Fatalf("request %d: header %+v, want %+v", request, *rs, want) + } + if len(units) != 0 { + got := unsafe.Slice((*uint16)(unsafe.Add(unsafe.Pointer(rs), head)), len(units)) + if !reflect.DeepEqual(got, units) { + t.Fatalf("request %d: UTF-16 %v, want %v", request, got, units) + } + } + runtime.KeepAlive(buf) + rs.Size = uint32(size - 1) + before := *rs + if got := s.imeRequest(request, uintptr(unsafe.Pointer(rs))); got != 0 || *rs != before { + t.Fatalf("request %d: undersized buffer was accepted or modified", request) + } + runtime.KeepAlive(buf) + }) + } + } +} diff --git a/internal/windows/lui_testhooks.go b/internal/windows/lui_testhooks.go new file mode 100644 index 000000000..291fa8ade --- /dev/null +++ b/internal/windows/lui_testhooks.go @@ -0,0 +1,36 @@ +//go:build windows && (amd64 || arm64) + +package windows + +import "unsafe" + +// TestAccessibilityFocused reads the actual UIA provider focus property. +func TestAccessibilityFocused(n TestAccessNode) bool { + return uiaProperty(n.simple, uiaHasKeyboardFocusProperty).Val != 0 +} + +// TestPopupImageLabels checks that real HMENU item data refers to a decoded +// native owner-draw image, retaining the OS item's own visible label. +func TestPopupImageLabels() []string { + getInfo := user32.NewProc("GetMenuItemInfoW") + var labels []string + for _, popup := range threadPopups() { + menu, _, _ := procSendMessageW.Call(popup, mnGetHMenu, 0, 0) + if menu == 0 { + continue + } + count, _, _ := procGetMenuItemCount.Call(menu) + for index := range int(int32(count)) { + info := menuItemInfo{Mask: miimData} + info.Size = uint32(unsafe.Sizeof(info)) + ok, _, _ := getInfo.Call(menu, uintptr(index), 1, uintptr(unsafe.Pointer(&info))) + if ok == 0 { + continue + } + if look := theBackend.menus.looks[info.ItemData]; look != nil && look.icon != nil { + labels = append(labels, look.label) + } + } + } + return labels +} diff --git a/internal/windows/menu.go b/internal/windows/menu.go index 0537bfde1..8e066f81e 100644 --- a/internal/windows/menu.go +++ b/internal/windows/menu.go @@ -3,7 +3,9 @@ package windows import ( + "bytes" "encoding/json" + "image/png" "strings" "unsafe" @@ -80,7 +82,11 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac } // MyGo draws the items of popup menus (menudraw.go), which leave room // for checks when any item has one. - drawn := !bar && fluentMenus() + images := false + for _, it := range m.Items { + images = images || it.Visible && len(it.ImagePNG) > 0 + } + drawn := !bar && (fluentMenus() || images) column := false for _, it := range m.Items { column = column || it.Visible && (it.Type == platform.MenuItemCheckbox || it.Type == platform.MenuItemRadio || it.Checked) @@ -92,7 +98,7 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac if !drawn { return nil } - l.owner, l.column = owner, column + l.owner, l.column, l.iconColumn = owner, column, images looks = append(looks, l) var ftype uint32 switch { @@ -120,7 +126,11 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac if it.Submenu != nil { sub := b.buildMenu(it.Submenu, false, owner, accels) procAppendMenuW.Call(h, flags|mfPopup, sub, uintptr(unsafe.Pointer(u16(it.Label)))) - look(&menuLook{label: it.Label, submenu: true}) + l := &menuLook{label: it.Label, submenu: true} + if len(it.ImagePNG) > 0 { + l.icon, _ = png.Decode(bytes.NewReader(it.ImagePNG)) + } + look(l) continue } if it.Checked { @@ -137,7 +147,11 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac } } procAppendMenuW.Call(h, flags, uintptr(cmd), uintptr(unsafe.Pointer(u16(label)))) - e.look = look(&menuLook{label: it.Label, accel: e.accel, radio: e.radio}) + l := &menuLook{label: it.Label, accel: e.accel, radio: e.radio} + if len(it.ImagePNG) > 0 { + l.icon, _ = png.Decode(bytes.NewReader(it.ImagePNG)) + } + e.look = look(l) if e.radio && e.look == nil { setItemInfo(h, cmd, label, it.Enabled, it.Checked, true, false) } diff --git a/internal/windows/menudraw.go b/internal/windows/menudraw.go index 4bc1e53aa..0eaf87eb0 100644 --- a/internal/windows/menudraw.go +++ b/internal/windows/menudraw.go @@ -3,6 +3,7 @@ package windows import ( + "image" "math" "sync" "syscall" @@ -76,12 +77,14 @@ func fluentMenus() bool { return windows11() } // menuLook is what an item MyGo draws shows. type menuLook struct { - owner int - label string // with its mnemonic's & - accel string - separator bool - submenu bool - radio bool + icon image.Image + iconColumn bool + owner int + label string // with its mnemonic's & + accel string + separator bool + submenu bool + radio bool // column is whether the menu has a column for the marks of checkable // items, which all its items leave room for. column bool @@ -363,6 +366,9 @@ func (b *Backend) measureItem(hwnd uintptr, l *menuLook, mis *measureItemStruct) if l.column { w += toPx(menuMarkColumn, dpi) } + if l.iconColumn { + w += toPx(menuMarkColumn, dpi) + } if l.accel != "" { w += toPx(menuAccelGap, dpi) + textWidth(dc, f.text, l.accel) } @@ -426,6 +432,18 @@ func (b *Backend) drawItem(hwnd uintptr, l *menuLook, d *drawItemStruct) { } x += toPx(menuMarkColumn, dpi) } + if l.iconColumn { + if l.icon != nil { + side := toPx(16, dpi) + y := (r.Top + r.Bottom - side) / 2 + back := p.back + if selected { + back = p.plate + } + drawMenuIcon(dc, l.icon, x, y, side, back) + } + x += toPx(menuMarkColumn, dpi) + } if l.submenu { // A menu laid out right to left has a mirrored DC, which // places the glyphs from the right but draws them unmirrored: @@ -581,3 +599,34 @@ func roundMenu(hwnd uintptr, border uint32) { procSetClassLongPtrW.Call(hwnd, gclStyle, style&^csDropShadow) } } + +// drawMenuIcon scales an RGBA icon into a temporary DIB and composites its +// alpha over the native item's actual plate. The OS still owns menu input. +func drawMenuIcon(dc uintptr, source image.Image, x, y, side int32, background uint32) { + if side <= 0 { + return + } + target := newBitmap(dc, side, side) + if target == nil { + return + } + defer target.free() + bounds := source.Bounds() + if bounds.Empty() { + return + } + bg := dibColor(background) + for row := int32(0); row < side; row++ { + for col := int32(0); col < side; col++ { + r, g, b, a := source.At(bounds.Min.X+int(col)*bounds.Dx()/int(side), bounds.Min.Y+int(row)*bounds.Dy()/int(side)).RGBA() + // Color.RGBA is premultiplied; add the uncovered background directly. + inv := uint32(65535 - a) + red := (r + ((bg>>16)&255)*257*inv/65535) / 257 + green := (g + ((bg>>8)&255)*257*inv/65535) / 257 + blue := (b + (bg&255)*257*inv/65535) / 257 + target.px[row*side+col] = red<<16 | green<<8 | blue + } + } + procGdiFlush.Call() + procBitBlt.Call(dc, uintptr(x), uintptr(y), uintptr(side), uintptr(side), target.dc, 0, 0, srcCopy) +} diff --git a/internal/windows/surface_access.go b/internal/windows/surface_access.go index a7486c558..85f87a410 100644 --- a/internal/windows/surface_access.go +++ b/internal/windows/surface_access.go @@ -169,6 +169,7 @@ var uiaControlTypes = map[platform.AccessRole]int32{ platform.RoleMeter: 50012, platform.RoleStepper: 50016, platform.RoleColorWell: 50000, platform.RoleAlertDialog: 50033, platform.RoleMenu: 50009, platform.RoleMenuBar: 50010, platform.RoleMenuItem: 50011, platform.RoleMenuItemCheckBox: 50011, platform.RoleMenuItemRadio: 50011, platform.RoleHeading: 50020, + platform.RoleTabPanel: 50033, } // variant is VARIANT, with the value of the types used here. diff --git a/internal/windows/surface_input.go b/internal/windows/surface_input.go index c3c3f0d53..0b0cf2f3f 100644 --- a/internal/windows/surface_input.go +++ b/internal/windows/surface_input.go @@ -54,7 +54,10 @@ func (s *surface) imeRequest(wp, lp uintptr) uintptr { *rs = reconvertString{Size: size, StrLen: uint32(len(units)), StrOffset: head, CompStrLen: uint32(end - start), CompStrOffset: uint32(start) * 2, TargetStrLen: uint32(end - start), TargetStrOffset: uint32(start) * 2} - copy(unsafe.Slice((*uint16)(unsafe.Add(native(lp), head)), len(units)), units) + // An empty document has no payload allocation beyond its header. + if len(units) != 0 { + copy(unsafe.Slice((*uint16)(unsafe.Add(native(lp), head)), len(units)), units) + } return uintptr(size) case imrConfirmReconvertString: rs := (*reconvertString)(native(lp)) diff --git a/internal/windows/testhooks_surface.go b/internal/windows/testhooks_surface.go index 147ae19c5..aebe622d3 100644 --- a/internal/windows/testhooks_surface.go +++ b/internal/windows/testhooks_surface.go @@ -41,7 +41,10 @@ func TestDocumentFeed(hwnd uintptr) (text string, start, length int, ok bool) { rs := (*reconvertString)(unsafe.Pointer(&buf[0])) rs.Size = uint32(size) procSendMessageW.Call(s.hwnd, wmImeRequest, imrDocumentFeed, uintptr(unsafe.Pointer(rs))) - units := unsafe.Slice((*uint16)(unsafe.Add(unsafe.Pointer(rs), rs.StrOffset)), rs.StrLen) + var units []uint16 + if rs.StrLen != 0 { + units = unsafe.Slice((*uint16)(unsafe.Add(unsafe.Pointer(rs), rs.StrOffset)), rs.StrLen) + } from := int(rs.TargetStrOffset / 2) to := from + int(rs.TargetStrLen) return string(utf16.Decode(units)), runeOffset(units, from), runeOffset(units, to) - runeOffset(units, from), true @@ -65,7 +68,9 @@ func TestComposeOver(hwnd uintptr, text string, caret int, commit bool, from, le a, b := unitOffset(doc, from), unitOffset(doc, from+length) *rs = reconvertString{Size: head + uint32(len(units))*2, StrLen: uint32(len(units)), StrOffset: head, CompStrLen: uint32(b - a), CompStrOffset: uint32(a) * 2, TargetStrLen: uint32(b - a), TargetStrOffset: uint32(a) * 2} - copy(unsafe.Slice((*uint16)(unsafe.Add(unsafe.Pointer(rs), head)), len(units)), units) + if len(units) != 0 { + copy(unsafe.Slice((*uint16)(unsafe.Add(unsafe.Pointer(rs), head)), len(units)), units) + } if r, _, _ := procSendMessageW.Call(s.hwnd, wmImeRequest, imrConfirmReconvertString, uintptr(unsafe.Pointer(rs))); r == 0 { return false } diff --git a/lui_dialog.go b/lui_dialog.go new file mode 100644 index 000000000..f6d917125 --- /dev/null +++ b/lui_dialog.go @@ -0,0 +1,102 @@ +package mygo + +import ( + "context" + "sync" + + "github.com/egoist/mygo/internal/platform" +) + +type contextDialogResult struct { + paths []string + err error +} + +// OpenContext shows the native file dialog and closes that presentation when +// ctx is cancelled. Native handles remain owned until their close callback; +// cancellation never calls a released handle. Like Open, it is safe from any +// goroutine, including the main thread, where native events keep being pumped. +func (DialogModule) OpenContext(ctx context.Context, opts OpenDialogOptions) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + needsApp("Dialog.OpenContext") + native := &platform.OpenDialogOptions{ + Title: opts.Title, DefaultPath: opts.DefaultPath, ButtonLabel: opts.ButtonLabel, Message: opts.Message, Filters: toFilters(opts.Filters), + OpenFiles: !opts.Directory, OpenDirectories: opts.Directory, Multiple: opts.Multiple, ShowHiddenFiles: opts.ShowHiddenFiles, + CreateDirectories: opts.CreateDirectories, TreatPackagesAsDirectories: opts.TreatPackagesAsDirectories, + } + result := startContextOpenDialog(ctx, native, func(options *platform.OpenDialogOptions, done func([]string, error)) { + backend().Dialogs().ShowOpenDialog(opts.Parent.nativeOrNil(), options, done) + }, postMain, func() { backend().Wake() }) + reply := await(result) + return reply.paths, reply.err +} + +// startContextOpenDialog keeps OS-specific handles on their owning UI queue. +// The injected functions are the native boundary, shared by desktop hosts and +// exercised without replacing the public file-picker UI or its state machine. +func startContextOpenDialog(ctx context.Context, opts *platform.OpenDialogOptions, show func(*platform.OpenDialogOptions, func([]string, error)), post func(func()) bool, wake func()) <-chan contextDialogResult { + ch := make(chan contextDialogResult, 1) + var mu sync.Mutex + finished := false + var nativeCancel func() + var stop func() bool + finish := func(paths []string, err error) { + mu.Lock() + if finished { + mu.Unlock() + return + } + finished = true + nativeCancel = nil + stopWatcher := stop + mu.Unlock() + if stopWatcher != nil { + stopWatcher() + } + if ctx.Err() != nil { + paths = nil + err = ctx.Err() + } + ch <- contextDialogResult{paths: paths, err: err} + wake() + } + opts.RegisterCancel = func(cancel func()) { + mu.Lock() + defer mu.Unlock() + if !finished { + nativeCancel = cancel + } + } + stopWatcher := context.AfterFunc(ctx, func() { + if !post(func() { + mu.Lock() + cancel := nativeCancel + active := !finished + mu.Unlock() + if active && cancel != nil { + cancel() + } + }) { + finish(nil, errLoopStopped) + } + }) + mu.Lock() + stop = stopWatcher + alreadyFinished := finished + mu.Unlock() + if alreadyFinished { + stopWatcher() + } + if !post(func() { + if err := ctx.Err(); err != nil { + finish(nil, err) + return + } + show(opts, finish) + }) { + finish(nil, errLoopStopped) + } + return ch +} diff --git a/lui_dialog_test.go b/lui_dialog_test.go new file mode 100644 index 000000000..ed2c4548c --- /dev/null +++ b/lui_dialog_test.go @@ -0,0 +1,74 @@ +package mygo + +import ( + "context" + "errors" + "github.com/egoist/mygo/internal/platform" + "testing" + "time" +) + +func TestLUIOpenContextCancelledBeforeApplication(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Dialog.OpenContext(ctx, OpenDialogOptions{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled request must not start application or native dialog: %v", err) + } +} +func TestLUIOpenContextClosesTheNativeDialogOnItsUIQueue(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + queue := make(chan func(), 8) + closed := 0 + result := startContextOpenDialog(ctx, &platform.OpenDialogOptions{}, func(opts *platform.OpenDialogOptions, done func([]string, error)) { + if opts.RegisterCancel != nil { + opts.RegisterCancel(func() { closed++; done(nil, nil) }) + } + }, func(fn func()) bool { queue <- fn; return true }, func() {}) + (<-queue)() + cancel() + select { + case closeNative := <-queue: + closeNative() + case <-time.After(time.Second): + t.Fatal("cancellation did not queue native presentation dismissal") + } + if closed != 1 { + t.Fatalf("native close count: %d", closed) + } + select { + case reply := <-result: + if !errors.Is(reply.err, context.Canceled) { + t.Fatalf("cancel result: %v", reply.err) + } + case <-time.After(time.Second): + t.Fatal("native dismissal did not release waiter") + } +} +func TestLUIOpenContextDoesNotCloseAnAlreadyDisposedNativeHandle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + queue := make(chan func(), 8) + closed := 0 + result := startContextOpenDialog(ctx, &platform.OpenDialogOptions{}, func(opts *platform.OpenDialogOptions, done func([]string, error)) { + if opts.RegisterCancel != nil { + opts.RegisterCancel(func() { closed++ }) + } + done([]string{"/picked"}, nil) + }, func(fn func()) bool { queue <- fn; return true }, func() {}) + (<-queue)() + reply := <-result + if len(reply.paths) != 1 { + t.Fatal("native selection was lost") + } + cancel() + select { + case fn := <-queue: + fn() + case <-time.After(10 * time.Millisecond): + } + if closed != 0 { + t.Fatal("late cancellation touched released native dialog") + } +} diff --git a/menu.go b/menu.go index 8c5308dc2..43ae26128 100644 --- a/menu.go +++ b/menu.go @@ -108,6 +108,9 @@ type MenuItem struct { // chosen. Other menus use the focused window, which may be nil. Click func(item *MenuItem, win *Window) + // imagePNG owns the Content menu icon across deferred native presentation. + imagePNG []byte + uid int parent *Menu sub *Menu @@ -423,6 +426,7 @@ func (it *MenuItem) snapshotLocked() *platform.MenuItem { Visible: !it.Hidden, Checked: it.Checked, ToolTip: it.ToolTip, + ImagePNG: append([]byte(nil), it.imagePNG...), } switch it.Type { case MenuItemSeparator: diff --git a/testing/native/darwin.go b/testing/native/darwin.go new file mode 100644 index 000000000..09cd005d7 --- /dev/null +++ b/testing/native/darwin.go @@ -0,0 +1,26 @@ +//go:build darwin && lui_native_smoke + +// Package native exposes the native backend's existing test hooks only for +// explicitly opted-in desktop smoke tools. Every call must run on the UI thread. +package native + +import "github.com/egoist/mygo/internal/darwin" + +type AccessNode = darwin.TestAccessNode + +var ( + OnScreen = darwin.TestOnScreen + WindowNumber = darwin.TestWindowNumber + Click = darwin.TestClick + AppClick = darwin.TestAppClick + AppPress = darwin.TestAppPress + DragMove = darwin.TestDragMove + Key = darwin.TestKey + KeyShift = darwin.TestKeyShift + Scroll = darwin.TestScroll + Drag = darwin.TestDrag + Compose = darwin.TestCompose + InputClient = darwin.TestInputClient + Accessibility = darwin.TestAccessibility + Perform = darwin.TestAccessibilityPerform +) diff --git a/testing/native/linux.go b/testing/native/linux.go new file mode 100644 index 000000000..31d5454c6 --- /dev/null +++ b/testing/native/linux.go @@ -0,0 +1,44 @@ +//go:build linux && lui_native_smoke + +package native + +import "github.com/egoist/mygo/internal/linux" + +type AccessNode struct { + Role, Label, Value string + Focused bool +} + +func Accessibility(handle uintptr) []AccessNode { + nodes, _ := linux.TestAccessibility(handle) + out := make([]AccessNode, 0, len(nodes)) + for _, n := range nodes { + role := n.Role + switch role { + case "entry", "Edit": + role = "textbox" + case "Button": + role = "button" + case "check box", "CheckBox": + role = "checkbox" + } + out = append(out, AccessNode{Role: role, Label: n.Label, Value: n.Value, Focused: linux.TestAccessibilityFocused(n)}) + } + return out +} +func Compose(handle uintptr, text string, caret int, commit bool) bool { + return linux.TestComposeOver(handle, text, caret, commit, -1, 0) +} +func WindowNumber(handle uintptr) int { return int(handle) } + +var Perform = linux.TestAccessibilityPerform +var RightClick = linux.TestRightClickSurface +var Popups = linux.TestPopups +var ChoosePopup = linux.TestChoosePopupItem +var PopupImages = linux.TestPopupImageLabels + +func OnScreen(handle uintptr) bool { return len(linux.TestSurfaceOnScreen(handle)) > 0 } +func InputClient(handle uintptr) ([2]int, string) { + text, caret, _ := linux.TestSurrounding(handle) + return [2]int{caret, 0}, text +} diff --git a/testing/native/windows.go b/testing/native/windows.go new file mode 100644 index 000000000..b2e188920 --- /dev/null +++ b/testing/native/windows.go @@ -0,0 +1,44 @@ +//go:build windows && lui_native_smoke + +package native + +import "github.com/egoist/mygo/internal/windows" + +type AccessNode struct { + Role, Label, Value string + Focused bool +} + +func Accessibility(handle uintptr) []AccessNode { + nodes, _ := windows.TestAccessibility(handle) + out := make([]AccessNode, 0, len(nodes)) + for _, n := range nodes { + role := n.Role + switch role { + case "entry", "Edit": + role = "textbox" + case "Button": + role = "button" + case "check box", "CheckBox": + role = "checkbox" + } + out = append(out, AccessNode{Role: role, Label: n.Label, Value: n.Value, Focused: windows.TestAccessibilityFocused(n)}) + } + return out +} +func Compose(handle uintptr, text string, caret int, commit bool) bool { + return windows.TestComposeOver(handle, text, caret, commit, -1, 0) +} +func WindowNumber(handle uintptr) int { return int(handle) } + +var Perform = windows.TestAccessibilityPerform +var RightClick = windows.TestRightClickSurface +var Popups = windows.TestPopups +var ChoosePopup = windows.TestChoosePopupItem +var PopupImages = windows.TestPopupImageLabels +var OnScreen = windows.TestForeground + +func InputClient(handle uintptr) ([2]int, string) { + text, start, length, _ := windows.TestDocumentFeed(handle) + return [2]int{start, length}, text +} diff --git a/ui/access.go b/ui/access.go index db094ac77..2e117c4b6 100644 --- a/ui/access.go +++ b/ui/access.go @@ -88,6 +88,8 @@ const ( // RoleHeading is the title of a section, named by its text, its rank // set with Level. RoleHeading + // RoleTabPanel holds the content of the selected tab. + RoleTabPanel ) // Role sets what the element is to assistive technology, for an element @@ -185,7 +187,7 @@ func (e *node) accessRole() (platform.AccessRole, bool) { // as their name, rather than showing it as elements of its own. func leafRole(r platform.AccessRole) bool { switch r { - case platform.RoleGroup, platform.RoleList, platform.RoleScroll, platform.RoleDialog, platform.RoleAlertDialog, platform.RolePopup, platform.RoleStatus, + case platform.RoleTabPanel, platform.RoleGroup, platform.RoleList, platform.RoleScroll, platform.RoleDialog, platform.RoleAlertDialog, platform.RolePopup, platform.RoleStatus, platform.RoleTabList, platform.RoleTable, platform.RoleRow, platform.RoleCell, platform.RoleTree, platform.RoleListItem, platform.RoleToolbar, platform.RoleRadioGroup, platform.RoleMenu, platform.RoleMenuBar: return false diff --git a/ui/context.go b/ui/context.go index 9061f922c..a67a60942 100644 --- a/ui/context.go +++ b/ui/context.go @@ -92,6 +92,8 @@ func (c *context) alloc() *node { } func (c *context) reset(now time.Time, w, h float32) { + clear(c.rt.afterFrame) + c.rt.afterFrame = c.rt.afterFrame[:0] for _, f := range c.rt.focusFields { clear(f.members) f.members = f.members[:0] diff --git a/ui/element.go b/ui/element.go index 721c8bc0a..808bcdafb 100644 --- a/ui/element.go +++ b/ui/element.go @@ -45,8 +45,9 @@ const ( // length is a size in DIPs, a percentage of the parent's or automatic. type length struct { - v float32 - u unit + v float32 + u unit + offset float32 } // Auto is an automatic margin, which takes the free space on its side, as @@ -56,8 +57,8 @@ var Auto = float32(math.Inf(1)) func isAuto(v float32) bool { return math.IsInf(float64(v), 1) } -func px(v float32) length { return length{v, unitPx} } -func percent(v float32) length { return length{v, unitPercent} } +func px(v float32) length { return length{v: v, u: unitPx} } +func percent(v float32) length { return length{v: v, u: unitPercent} } // resolve returns the length in DIPs against base, and false for auto or a // percentage of an unknown base. @@ -67,7 +68,7 @@ func (l length) resolve(base float32) (float32, bool) { return l.v, true case unitPercent: if base >= 0 { - return l.v / 100 * base, true + return l.v/100*base + l.offset, true } } return 0, false @@ -256,6 +257,12 @@ type layoutStyle struct { // Common input queries return false or zero for nil or cleared elements; // this does not make references to reused storage safe. type node struct { + zIndex int + zIndexSet bool + disabledOpacity *float32 + + fitting *fittingBuilder + epoch uint64 key any valueInput func(*node) @@ -405,11 +412,14 @@ type node struct { // rowsOf is the list whose rows the element holds for assistive // technology, and takes the keys for: the list, or its Table. // popover is the anchor of the panel of a popover. - popover *node - list *listFrame - listRow bool - rowIndex int - rowsOf *listFrame + popover *node + popoverHandle *Handle + collisionPadding *float32 + matchAnchorWidth bool + list *listFrame + listRow bool + rowIndex int + rowsOf *listFrame // focusGroup makes the element a focus group (FocusGroup), whose // arrows click the element they move to with groupSelects. focusGroup Orientation diff --git a/ui/headless.go b/ui/headless.go index ec83fef81..8efec4251 100644 --- a/ui/headless.go +++ b/ui/headless.go @@ -1,8 +1,10 @@ package ui import ( + "bytes" "fmt" "image" + "image/png" "slices" "strings" "sync/atomic" @@ -603,7 +605,14 @@ func (t *Tester) ChooseMenuItem(path ...string) error { } // CloseMenu closes the context menu shown without choosing an item. -func (t *Tester) CloseMenu() { t.h.menu, t.h.chosen = nil, nil } +func (t *Tester) CloseMenu() { + chosen := t.h.chosen + t.h.menu, t.h.chosen = nil, nil + if chosen != nil { + chosen(0) + t.settle() + } +} // Press presses (x, y) without releasing; Release releases it. func (t *Tester) Press(x, y float32) { @@ -819,3 +828,25 @@ func (t *Tester) Focused(s string) bool { } return false } + +// MenuImage returns the decoded image sent to the native popup service for an +// item of the currently open menu, or nil. It does not render substitute UI. +func (t *Tester) MenuImage(label string) image.Image { + var find func(*platform.Menu) image.Image + find = func(menu *platform.Menu) image.Image { + if menu == nil { + return nil + } + for _, it := range menu.Items { + if it.Label == label { + im, _ := png.Decode(bytes.NewReader(it.ImagePNG)) + return im + } + if im := find(it.Submenu); im != nil { + return im + } + } + return nil + } + return find(t.h.menu) +} diff --git a/ui/layout.go b/ui/layout.go index e6d05fa3a..ca7ef7d25 100644 --- a/ui/layout.go +++ b/ui/layout.go @@ -351,6 +351,9 @@ func (e *node) forgetIntrinsic() { // measureIntrinsic measures the max-content or min-content width of an // element sized by its content. func measureIntrinsic(e *node, maxContent bool) float32 { + if e.fitting != nil { + return e.fitting.ideal(-1).W + e.padX() + } var w float32 switch { case e.kind != kindBox: @@ -483,6 +486,9 @@ func naturalHeight(e *node, w, cbH float32) float32 { // contentHeight returns the height the content takes in a content box cw // wide. func contentHeight(e *node, cw float32) float32 { + if e.fitting != nil { + return e.fitting.ideal(cw).H + } switch e.kind { case kindText: return e.textLayout(max(cw, 1)).Height @@ -562,6 +568,9 @@ func computeBox(e *node, w, h float32) { case kindImage, kindIcon: return } + if e.fitting != nil { + e.fitting.build(cw, ch) + } if e.list != nil { e.layoutList(w, h) return @@ -1177,6 +1186,17 @@ func layoutAbsolute(e *node) { // Copies of elements leaving are placed by their transitions. continue } + if c.popoverHandle != nil { + c.popover = c.c.rt.public.Resolve(*c.popoverHandle).node() + if c.popover == nil { + c.flags |= flagInvisible + c.w, c.h = 0, 0 + continue + } + if c.dir&dirSet == 0 { + c.setDir(c.popover.rtl(), false) + } + } if c.attach != 0 { if c.popover != nil { attachTo(c, e, pw, ph) @@ -1247,9 +1267,12 @@ func layoutAbsolute(e *node) { // attachTo places c, a child of e whose padding box is pw×ph, attached to // a point of the box of the element it is a popover of (AttachTo). func attachTo(c, e *node, pw, ph float32) { + t := laidOutVisualBox(c.popover) var w float32 if v, ok := c.width.resolve(pw); ok { w = c.clampW(v, pw) + } else if c.matchAnchorWidth { + w = c.clampW(t.W, pw) } else { w = fitWidth(c, pw, pw) } @@ -1259,16 +1282,21 @@ func attachTo(c, e *node, pw, ph float32) { } else { h = heightAt(c, w, ph) } - t := laidOutVisualBox(c.popover) + margin := float32(windowMargin) + if c.collisionPadding != nil { + margin = *c.collisionPadding + w = min(w, max(0, c.c.w-2*margin)) + h = min(h, max(0, c.c.h-2*margin)) + } at, self := c.attach.anchors() at, self = at.physical(c.rtl()), self.physical(c.rtl()) ax, ay := at.fractions() sx, sy := self.fractions() if c.flags&flagScrollY != 0 { - h = min(h, roomAlong(t.Y, t.H, beside(ay, sy), c.m(0)+c.m(2), c.c.h)) + h = min(h, roomAlongMargin(t.Y, t.H, beside(ay, sy), c.m(0)+c.m(2), c.c.h, margin)) } - x := alongTarget(t.X, t.W, ax, sx, w, c.m(3)-c.m(1), c.c.w) - y := alongTarget(t.Y, t.H, ay, sy, h, c.m(0)-c.m(2), c.c.h) + x := alongTargetMargin(t.X, t.W, ax, sx, w, c.m(3)-c.m(1), c.c.w, margin) + y := alongTargetMargin(t.Y, t.H, ay, sy, h, c.m(0)-c.m(2), c.c.h, margin) dx, dy := c.relative(pw, ph) ox, oy := laidOutOrigin(e) c.x, c.y = x+dx-ox, y+dy-oy @@ -1283,9 +1311,12 @@ const windowMargin = 4 // target for one placed beside it, else the window. One that scrolls, as a // select's long list of options, is no longer. func roomAlong(t0, tsize float32, beside bool, margins, limit float32) float32 { - room := limit - 2*windowMargin - margins + return roomAlongMargin(t0, tsize, beside, margins, limit, windowMargin) +} +func roomAlongMargin(t0, tsize float32, beside bool, margins, limit, margin float32) float32 { + room := limit - 2*margin - margins if beside { - room = max(t0, limit-(t0+tsize)) - windowMargin - margins + room = max(t0, limit-(t0+tsize)) - margin - margins } return max(room, 0) } @@ -1300,9 +1331,12 @@ func beside(at, self float32) bool { return at == 1 && self == 0 || at == 0 && s // along it, where that overflows the window less, then moved into the // window along target. func alongTarget(t0, tsize, at, self, size, off, limit float32) float32 { + return alongTargetMargin(t0, tsize, at, self, size, off, limit, windowMargin) +} +func alongTargetMargin(t0, tsize, at, self, size, off, limit, margin float32) float32 { pos := func(at, self, off float32) float32 { return t0 + at*tsize - self*size + off } over := func(p float32) float32 { - return max(0, windowMargin-p) + max(0, p+size-(limit-windowMargin)) + return max(0, margin-p) + max(0, p+size-(limit-margin)) } p := pos(at, self, off) if over(p) > 0 { @@ -1310,8 +1344,8 @@ func alongTarget(t0, tsize, at, self, size, off, limit float32) float32 { p = q } } - if !beside(at, self) && over(p) > 0 { - p = max(windowMargin, min(p, limit-windowMargin-size)) + if over(p) > 0 { + p = max(margin, min(p, limit-margin-size)) } return p } diff --git a/ui/layoutcache.go b/ui/layoutcache.go index 2aa8701a7..234981294 100644 --- a/ui/layoutcache.go +++ b/ui/layoutcache.go @@ -172,7 +172,7 @@ func sameLayout(e *node, parent uint64, index int, changed bool) bool { // keep nothing: the next layout finds their states not seen by this // one. self := e.kind == kindInput || e.attach != 0 || e.ghosts - tree := e.list != nil || e.form != nil || e.colFit != nil || e.overflow != nil || + tree := e.fitting != nil || e.list != nil || e.form != nil || e.colFit != nil || e.overflow != nil || e.gridFit != nil || e.leaving != 0 || e.kind == kindText && e.first != nil if tree { keepNoLayout(e) diff --git a/ui/layoutcache_test.go b/ui/layoutcache_test.go index fc819cb7a..4520c1789 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -446,7 +446,7 @@ func TestLayoutInputs(t *testing.T) { // or made volatile by sameLayout, and those it writes. read := "flags ts cols rows spans image svg placeOn list form colFit overflow gridFit leaving ghosts attach popover " + "first last next nchild parent st c measured tl lay laid x y w h contentW contentH frags runes paragraph " + - "ownText merged spansKey collapsed rowMinW field" + "ownText merged spansKey collapsed rowMinW field fitting popoverHandle collisionPadding matchAnchorWidth" // Fields the layout never reads. other := "epoch key valueInput id depth track transform originX originY originSet world content contentSet " + "pose poseAnim poseSet poseM poseFade reposed ink noCull backface bg fill grad stripes borderC borderStyle radius " + @@ -455,7 +455,7 @@ func TestLayoutInputs(t *testing.T) { "checked expanded vertical tip highlighted accValue accRange hasRange accFill accStep barInset scrollBase " + "listRow rowIndex rowsOf focusGroup groupSelects segment activeDescendant nameFrom nameJoin " + "setPos setSize choosesItems search description invalid followX sort level expandable " + - "chooseMany serial" + "chooseMany serial zIndex zIndexSet disabledOpacity" known := map[string]bool{} for _, f := range strings.Fields(read + " " + other) { known[f] = true diff --git a/ui/list.go b/ui/list.go index 049cb81ea..924b0108b 100644 --- a/ui/list.go +++ b/ui/list.go @@ -36,6 +36,9 @@ const listSearch = 1000 // knows of the heights of its rows, kept in the app's state. Give each // List a ListState of its own; set its fields before building the List. type ListState struct { + overscan *int + itemExtent float32 + // Handle names this list independently of its per-pass node. Handle @@ -528,6 +531,9 @@ func (s *ListState) sync(e *node, n int) { s.header.row = -1 } s.heights.def = float64(e.c.theme.Space(8)) + if s.itemExtent > 0 { + s.heights.def = float64(s.itemExtent) + } if s.Key != nil && n > 0 { if k := s.anchorKey; k != nil { if j, ok := s.find(k, s.anchor, n); ok && j != s.anchor { @@ -626,10 +632,18 @@ func (s *ListState) find(k any, near, n int) (int, bool) { // plan returns the row the place starts from and the rows to build, as far // as the heights known tell where the place puts them. func (s *ListState) plan(e *node, n int) (a, first, last int) { + // Tuned lists defer their first rows until actual local constraints are + // known; constructor-time window height is not their viewport height. + if !s.laidOut && (s.overscan != nil || s.itemExtent > 0) { + return 0, 0, 0 + } if n == 0 { return 0, 0, 0 } view := float64(e.st.h) + if proposed, ok := e.height.resolve(e.c.h); ok { + view = float64(proposed) + } if view <= 0 { view = float64(e.c.h) } @@ -643,7 +657,8 @@ func (s *ListState) plan(e *node, n int) (a, first, last int) { } case r.set && !r.end: // Rows on both sides cover any alignment. - a, y, above = max(0, min(r.row, n-1)), 0, -view + a, y = max(0, min(r.row, n-1)), 0 + above = -view + hs.height(a) case r.end || s.following: a = n - 1 y = view - hs.height(a) @@ -657,7 +672,7 @@ func (s *ListState) plan(e *node, n int) (a, first, last int) { first-- yy -= hs.height(first) + gap } - return a, max(0, first-listOverscan), min(n, last+listOverscan) + return a, max(0, first-s.overscanRows()), min(n, last+s.overscanRows()) } // build builds row i in the list. @@ -1456,11 +1471,11 @@ func (e *node) layoutList(w, h float32) { } } glide := s.glideToEnd(e, n, lo, hi, H, padTop+padBottom+hs.top(n, gap)-gap, func(d float64) { shift(d); cover() }) - for k := 0; k < listOverscan && hi < n-1 && ensure(hi+1); k++ { + for k := 0; k < s.overscanRows() && hi < n-1 && ensure(hi+1); k++ { yHi += ht(hi) + gap hi++ } - for k := 0; k < listOverscan && lo > 0 && ensure(lo-1); k++ { + for k := 0; k < s.overscanRows() && lo > 0 && ensure(lo-1); k++ { lo-- yLo -= ht(lo) + gap } diff --git a/ui/lui_extensions_test.go b/ui/lui_extensions_test.go new file mode 100644 index 000000000..ab450554f --- /dev/null +++ b/ui/lui_extensions_test.go @@ -0,0 +1,87 @@ +package ui + +import "testing" + +type measurementContext interface { + Measure(func(*Context), float32) Rect + Fitting(bool, []func(*Context), func(*Context, int)) Element + AfterFrame(func()) +} + +func TestLUIFittingMeasuresFinalLocalRoomWithoutActivatingProbes(t *testing.T) { + measured, selected, committed := 0, -1, 0 + var failure string + tester := NewTester(func(c *Context) { + api, ok := any(c).(measurementContext) + if !ok { + failure = "missing isolated measure/fitting/after-frame API" + return + } + Column(c).Width(100).Children(func() { + fit := api.Fitting(false, []func(*Context){ + func(p *Context) { measured++; TextInput(p, new(string)).Width(300).Focus() }, + func(p *Context) { measured++; Text(p, "compact").Width(80) }, + }, func(p *Context, index int) { selected = index; Text(p, "chosen").Width(80) }) + fit.Width(100).Height(40) + }) + api.AfterFrame(func() { committed++ }) + }, 600, 200) + if failure != "" { + t.Fatal(failure) + } + if measured == 0 || selected != 1 || !tester.HasText("chosen") || tester.HasText("compact") { + t.Fatalf("fitting: measured=%d selected=%d visible=%v", measured, selected, tester.Texts()) + } + if committed != 1 { + t.Fatalf("after-frame ran %d times", committed) + } +} + +func TestLUIMeasureUsesActualTypographyAndDoesNotCommitFocus(t *testing.T) { + var wide, narrow Rect + var failure string + tester := NewTester(func(c *Context) { + api, ok := any(c).(measurementContext) + if !ok { + failure = "missing measure API" + return + } + Column(c).FontSize(28).Children(func() { + wide = api.Measure(func(p *Context) { Text(p, "MMMM") }, -1) + narrow = api.Measure(func(p *Context) { Text(p, "MMMM").FontSize(14) }, -1) + api.Measure(func(p *Context) { TextInput(p, new(string)).Label("probe").Focus() }, 100) + TextInput(c, new(string)).Label("actual").Focus() + }) + }, 300, 200) + if failure != "" { + t.Fatal(failure) + } + if wide.W < narrow.W*1.8 || wide.H < narrow.H*1.8 { + t.Fatalf("typography not inherited: wide=%+v narrow=%+v", wide, narrow) + } + if !tester.Focused("actual") || tester.HasText("probe") { + t.Fatal("measurement committed probe focus/content") + } +} + +func TestLUIListTuningControlsActualRowsBuilt(t *testing.T) { + for _, overscan := range []int{0, 8} { + var state ListState + tuning, ok := any(&state).(interface { + SetOverscan(int) + SetItemExtent(float32) + }) + if !ok { + t.Fatal("ListState lacks viewport tuning") + } + tuning.SetOverscan(overscan) + tuning.SetItemExtent(20) + built := map[int]bool{} + NewTester(func(c *Context) { + List(c, &state, 10000, func(row int) { built[row] = true; Text(c, "row").Height(20) }).Width(200).Height(100) + }, 300, 200) + if len(built) != 5+overscan { + t.Fatalf("overscan %d built %d rows", overscan, len(built)) + } + } +} diff --git a/ui/lui_layout.go b/ui/lui_layout.go new file mode 100644 index 000000000..adc091791 --- /dev/null +++ b/ui/lui_layout.go @@ -0,0 +1,152 @@ +package ui + +import "math" + +// AfterFrame runs fn once after this frame's final layout and presentation. +// Callbacks from discarded build passes are discarded too. The callback may +// update retained application state; Element values have expired at this point. +func (c *Context) AfterFrame(fn func()) { + if raw := c.build(); raw != nil && fn != nil { + raw.rt.afterFrame = append(raw.rt.afterFrame, fn) + } +} + +// Measure builds an isolated subtree and returns its ideal border-box size. +// A negative maxWidth asks for its unconstrained ideal width; otherwise text +// and wrapping use maxWidth. The real window receives no input, focus, +// accessibility, lifecycle or painting effects from the isolated context. +// Application builders must remain declarative: native services and external +// model mutations belong outside measurement callbacks. +func (c *Context) Measure(view func(*Context), maxWidth float32) Rect { + raw := c.build() + if raw == nil || view == nil { + return Rect{} + } + width := maxWidth + if width < 0 || math.IsInf(float64(width), 0) { + width = raw.w + } + host := &headless{w: width, h: raw.h, scale: 1, lang: raw.rt.lang, rtl: raw.rt.direction(), prefs: raw.rt.host.preferences()} + rt := newRuntime(nil, host) + rt.inFrame = true + rt.frame = 1 + rt.epoch = 1 + rt.dark, rt.darkKnown = raw.theme.Dark, true + rt.prefsKnown, rt.themeOK = true, true + rt.theme = *raw.theme + rt.c.reset(raw.now, width, raw.h) + rt.c.theme = raw.theme + rt.c.root.ts = raw.parent.resolvedText() + view(rt.public) + root := rt.c.root + root.width, root.height = length{}, length{} + measuredWidth := fitWidth(root, inf, inf) + if maxWidth >= 0 { + measuredWidth = fitWidth(root, maxWidth, maxWidth) + } + measuredHeight := heightAt(root, measuredWidth, inf) + rt.inFrame = false + rt.closed = true + return Rect{W: measuredWidth, H: measuredHeight} +} + +// Fitting builds only the first candidate whose ideal size fits the local +// proposed axis; if none fits it builds the last. Probes run through Measure +// and build receives the chosen index during layout, with the current Context. +func (c *Context) Fitting(vertical bool, candidates []func(*Context), build func(*Context, int)) Element { + raw := c.build() + if raw == nil { + return Element{} + } + e := coreBox(raw).MinWidth(0) + e.fitting = &fittingBuilder{c: c, e: e, vertical: vertical, candidates: candidates, chosen: build, index: -1, theme: *raw.theme} + return wrapElement(e) +} + +type fittingBuilder struct { + c *Context + e *node + vertical bool + candidates []func(*Context) + chosen func(*Context, int) + index int + built bool + theme Theme +} + +func (f *fittingBuilder) sizes(width float32) []Rect { + raw := f.c.build() + parent := raw.parent + raw.parent = f.e + defer func() { raw.parent = parent }() + sizes := make([]Rect, len(f.candidates)) + for i, candidate := range f.candidates { + sizes[i] = f.c.Measure(candidate, width) + } + return sizes +} +func (f *fittingBuilder) ideal(width float32) Rect { + if len(f.candidates) == 0 { + return Rect{} + } + if !f.vertical { + width = -1 + } + sizes := f.sizes(width) + if width >= 0 { + for _, size := range sizes { + if size.W <= width { + return size + } + } + } + return sizes[0] +} +func (f *fittingBuilder) build(width, height float32) { + if f.built || len(f.candidates) == 0 || f.chosen == nil { + return + } + f.built = true + measureWidth := float32(-1) + if f.vertical { + measureWidth = width + } + sizes := f.sizes(measureWidth) + selected := len(sizes) - 1 + for i, size := range sizes { + if !f.vertical && size.W <= width+0.01 || f.vertical && size.H <= height+0.01 { + selected = i + break + } + } + f.index = selected + raw := f.e.c + parent, theme := raw.parent, raw.theme + raw.parent, raw.theme = f.e, &f.theme + f.chosen(f.c, selected) + raw.parent, raw.theme = parent, theme + raw.rt.late = true +} + +// SetOverscan sets rows constructed beyond each viewport edge. Zero disables +// overscan; unconfigured lists retain the upstream default of two rows. +func (s *ListState) SetOverscan(rows int) { value := max(0, rows); s.overscan = &value } +func (s *ListState) overscanRows() int { + if s.overscan == nil { + return listOverscan + } + return *s.overscan +} + +// SetItemExtent supplies the initial row-height estimate before real rows are +// measured. Actual measured heights always replace the estimate. +func (s *ListState) SetItemExtent(extent float32) { + if extent > 0 && !math.IsInf(float64(extent), 0) && !math.IsNaN(float64(extent)) { + s.itemExtent = extent + } else { + s.itemExtent = 0 + } +} + +// Stop cancels an explicit ScrollTo animation at its current position. +func (s *ScrollState) Stop() { s.glide = nil } diff --git a/ui/lui_link.go b/ui/lui_link.go new file mode 100644 index 000000000..6cc6cd460 --- /dev/null +++ b/ui/lui_link.go @@ -0,0 +1,16 @@ +package ui + +// LinkBase creates a link with arbitrary child content. It uses Link's native +// routing, URL activation, focus and keyboard behavior; the caller supplies +// its label, icon and other presentation. +func LinkBase(c *Context, url string) Element { + ctx := c.build() + if ctx == nil { + return Element{} + } + ctx.rt.beginBuild() + e := coreButtonBase(ctx).TextColor(ctx.theme.Accent).Cursor(CursorPointer) + result := linkBehavior(ctx, e, url) + ctx.rt.endBuild() + return wrapElement(result) +} diff --git a/ui/lui_menu.go b/ui/lui_menu.go new file mode 100644 index 000000000..b010cd401 --- /dev/null +++ b/ui/lui_menu.go @@ -0,0 +1,74 @@ +package ui + +import ( + "image" + "image/color" +) + +// OnMenuDismiss observes a native menu closing, after its chosen item has +// been delivered to ContextMenu or Menu. Cancellation also closes a menu. +// The latest observer runs after the final frame, only while its host still +// exists; rebuilding a control never replays an already delivered close. +func (e Element) OnMenuDismiss(fn func()) Element { + n := e.node() + if n == nil || fn == nil { + return e + } + rt, id := n.c.rt, n.id + if rt.menu.dismissed == id { + rt.public.AfterFrame(func() { + if rt.menu.dismissed != id { + return + } + rt.menu.dismissed = 0 + fn() + }) + } + return e +} + +// ImagePNG sets the native menu item's icon. Its bytes are copied into this +// opening's snapshot; native hosts retain their decoded image until dismissal. +func (it *MenuItem) ImagePNG(data []byte) *MenuItem { + if it.p != nil { + it.p.ImagePNG = append([]byte(nil), data...) + } + return it +} + +// RasterizeIcon draws an SVG's alpha mask in the supplied color into an owned +// image. Native menus and other OS services can share the same icon resources +// as in-window Icon elements without constructing a second UI runtime. +func (s *SVG) RasterizeIcon(width, height int, ink Color) *image.RGBA { + if s == nil || width <= 0 || height <= 0 || width > 4096 || height > 4096 { + return nil + } + dst := image.NewRGBA(image.Rect(0, 0, width, height)) + s.doc.Draw(dst, color.NRGBA{R: ink.R, G: ink.G, B: ink.B, A: 255}, false) + for i := 0; i < len(dst.Pix); i += 4 { + a := uint32(dst.Pix[i+3]) * uint32(ink.A) / 255 + dst.Pix[i] = uint8(uint32(ink.R) * a / 255) + dst.Pix[i+1] = uint8(uint32(ink.G) * a / 255) + dst.Pix[i+2] = uint8(uint32(ink.B) * a / 255) + dst.Pix[i+3] = uint8(a) + } + return dst +} + +// ContextMenuBase consumes the toolkit's ordinary secondary-click and keyboard +// context request without asking the OS to present a menu. Its caller supplies +// retained content at the requested window position; MyGo still owns targeting, +// disabled checks and Shift+F10 routing, including text-selection semantics. +func (e Element) ContextMenuBase(present func(x, y float32)) Element { + n := e.node() + if n == nil || present == nil { + return e + } + n.flags |= flagContextMenu + request := &n.c.rt.menu + if request.asked == n.id && !request.button { + request.asked = 0 + present(request.x, request.y) + } + return e +} diff --git a/ui/lui_menu_test.go b/ui/lui_menu_test.go new file mode 100644 index 000000000..46523bf20 --- /dev/null +++ b/ui/lui_menu_test.go @@ -0,0 +1,126 @@ +package ui + +import ( + "bytes" + "image" + "image/color" + "image/png" + "reflect" + "testing" + + "github.com/egoist/mygo/internal/platform" +) + +func TestLUINativeMenuReportsSelectionThenDismissal(t *testing.T) { + var events []string + tt := NewTester(func(c *Context) { + e := Button(c, "Document").ContextMenu(func(m *Menu) { + if m.Item("Inspect").Chosen() { + events = append(events, "inspect") + } + }) + e.OnMenuDismiss(func() { events = append(events, "dismiss") }) + }, 400, 200) + if err := tt.RightClick("Document"); err != nil { + t.Fatal(err) + } + if err := tt.ChooseMenuItem("Inspect"); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(events, []string{"inspect", "dismiss"}) { + t.Fatalf("native menu close order: %v", events) + } + tt.CloseMenu() + tt.Frame() + if len(events) != 2 { + t.Fatalf("menu dismissed twice: %v", events) + } +} + +func TestLUINativeMenuCancellationAndRetiredHost(t *testing.T) { + visible := true + dismissals := 0 + tt := NewTester(func(c *Context) { + if !visible { + return + } + e := Button(c, "Document").ContextMenu(func(m *Menu) { m.Item("Inspect") }) + e.OnMenuDismiss(func() { dismissals++ }) + }, 400, 200) + if err := tt.RightClick("Document"); err != nil { + t.Fatal(err) + } + tt.CloseMenu() + if dismissals != 1 { + t.Fatalf("cancel must notify once: %d", dismissals) + } + if err := tt.RightClick("Document"); err != nil { + t.Fatal(err) + } + visible = false + tt.Frame() + tt.CloseMenu() + tt.Frame() + if dismissals != 1 { + t.Fatal("retired host delivered stale menu callback") + } +} + +func TestLUITabPanelRetainsAccessibleChildren(t *testing.T) { + // The appended role preserves the numerical ABI of existing toolkit roles. + panelRole := RoleTabPanel + tt := NewTester(func(c *Context) { + Column(c).Role(panelRole).Label("Home page").Children(func() { Text(c, "Page content") }) + }, 400, 200) + tt.send(platform.SurfaceEvent{Kind: platform.AccessibilityOn}) + if tt.h.access == nil { + t.Fatal("missing accessible tree") + } + found := false + for _, n := range tt.h.access.Nodes { + if n.Label == "Page content" { + found = true + } + } + if !found { + t.Fatalf("tab panel hid accessible child: %+v", tt.h.access.Nodes) + } +} + +func TestLUINativeMenuImageIsDeliveredAndRemoved(t *testing.T) { + picture := image.NewRGBA(image.Rect(0, 0, 16, 16)) + for y := range 16 { + for x := range 16 { + picture.SetRGBA(x, y, color.RGBA{R: 255, A: 255}) + } + } + var data bytes.Buffer + png.Encode(&data, picture) + showImage := true + tt := NewTester(func(c *Context) { + Button(c, "Open image menu").ContextMenu(func(menu *Menu) { + item := menu.Item("Inspect") + if showImage { + if api, ok := any(item).(interface{ ImagePNG([]byte) *MenuItem }); ok { + api.ImagePNG(data.Bytes()) + } + } + }) + }, 300, 200) + tt.RightClick("Open image menu") + api, ok := any(tt).(interface{ MenuImage(string) image.Image }) + if !ok || api.MenuImage("Inspect") == nil { + t.Fatal("native popup service did not receive an image") + } + r, g, b, a := api.MenuImage("Inspect").At(8, 8).RGBA() + if r < 60000 || g != 0 || b != 0 || a < 60000 { + t.Fatal("native menu pixels changed") + } + tt.CloseMenu() + showImage = false + tt.Frame() + tt.RightClick("Open image menu") + if api.MenuImage("Inspect") != nil { + t.Fatal("image leaked into subsequent native menu opening") + } +} diff --git a/ui/lui_overlay.go b/ui/lui_overlay.go new file mode 100644 index 000000000..06ce589a2 --- /dev/null +++ b/ui/lui_overlay.go @@ -0,0 +1,38 @@ +package ui + +import "math" + +// CollisionPadding keeps an attached overlay this far inside its window. +// The toolkit's default is four DIPs; a zero padding permits touching the edge. +func (e Element) CollisionPadding(padding float32) Element { + if n := e.node(); n != nil && padding >= 0 && !math.IsNaN(float64(padding)) && !math.IsInf(float64(padding), 0) { + value := padding + n.collisionPadding = &value + } + return e +} + +// AttachToHandle attaches an overlay to a stable handle, including a target +// built later in this pass. Geometry, focus order and outside presses use the +// actual target. An absent target hides the overlay for that pass. +func (e Element) AttachToHandle(target *Handle, at, self Anchor) Element { + n := e.node() + if n == nil || target == nil { + return e + } + target.init() + n.flags |= flagAbsolute + n.attach = attachment(1 + min(at, AnchorBottomEnd)*15 + min(self, AnchorBottomEnd)) + n.popoverHandle = target + n.popover = n.c.rt.public.Resolve(*target).node() + return e +} + +// MatchAnchorWidth uses the committed attachment target's width when this +// overlay has no explicit width. It also resolves targets built later. +func (e Element) MatchAnchorWidth() Element { + if n := e.node(); n != nil { + n.matchAnchorWidth = true + } + return e +} diff --git a/ui/lui_overlay_test.go b/ui/lui_overlay_test.go new file mode 100644 index 000000000..705cd00c3 --- /dev/null +++ b/ui/lui_overlay_test.go @@ -0,0 +1,58 @@ +package ui + +import "testing" + +func TestLUIOverlayCollisionPadding(t *testing.T) { + open := true + tt := NewTester(func(c *Context) { + var anchor Element + Overlay(c, func() { anchor = Box(c).Absolute().Left(699).Top(499).Size(0, 0) }) + PopoverBase(c, anchor, &open, func(panel Element) { + panel.Width(100).Height(50).Label("Popup") + if styled, ok := any(panel).(interface{ CollisionPadding(float32) Element }); ok { + styled.CollisionPadding(8) + } + }) + }, 700, 500) + r, ok := tt.Find("Popup") + if !ok { + t.Fatal("missing popup") + } + if r.X < 8 || r.Y < 8 || r.X+r.W > 692 || r.Y+r.H > 492 { + t.Fatalf("explicit collision padding ignored: %+v", r) + } +} + +func TestLUIForwardHandleAnchorPreservesGeometryAndAnchorPress(t *testing.T) { + open := true + var target Handle + tt := NewTester(func(c *Context) { + PopoverBase(c, Element{}, &open, func(panel Element) { + panel.Width(100).Height(50).Label("Popup") + if attached, ok := any(panel).(interface { + AttachToHandle(*Handle, Anchor, Anchor) Element + }); ok { + attached.AttachToHandle(&target, AnchorBottomLeft, AnchorTopLeft) + } + panel.Children(func() { Button(c, "Popup action") }) + }) + Column(c).Padding(100, 80).Children(func() { Button(c, "Anchor").Width(120).Height(40).Bind(&target) }) + }, 700, 500) + r, ok := tt.Find("Popup") + if !ok { + t.Fatal("missing popup") + } + if r.X != 80 || r.Y != 140 { + t.Fatalf("future target did not position popup: %+v", r) + } + if err := tt.Click("Anchor"); err != nil { + t.Fatal(err) + } + if !open { + t.Fatal("pressing attached handle anchor dismissed popup") + } + tt.Key(0, KeyTab) + if !tt.Focused("Popup action") { + t.Fatal("popup did not follow actual anchor in focus order") + } +} diff --git a/ui/lui_style.go b/ui/lui_style.go new file mode 100644 index 000000000..263605d2e --- /dev/null +++ b/ui/lui_style.go @@ -0,0 +1,161 @@ +package ui + +import ( + "github.com/egoist/mygo/internal/platform" + "slices" + "strings" +) + +// ZIndex orders drawing and pointer hits among siblings without changing flow. +func (e Element) ZIndex(index int) Element { + if n := e.node(); n != nil { + n.zIndex, n.zIndexSet = index, true + } + return e +} +func (e *node) layeredChildren() []*node { + active := false + for child := e.first; child != nil; child = child.next { + active = active || child.zIndexSet + } + if !active { + return nil + } + children := make([]*node, 0, e.nchild) + for child := e.first; child != nil; child = child.next { + children = append(children, child) + } + slices.SortStableFunc(children, func(a, b *node) int { + if a.zIndex < b.zIndex { + return -1 + } + if a.zIndex > b.zIndex { + return 1 + } + return int(b2i(a.flags&flagAbsolute != 0)) - int(b2i(b.flags&flagAbsolute != 0)) + }) + return children +} + +// DisabledOpacity replaces the toolkit's default disabled alpha. Element +// Opacity still applies independently to its complete composited subtree. +func (e Element) DisabledOpacity(alpha float32) Element { + if n := e.node(); n != nil { + value := max(0, min(1, alpha)) + n.disabledOpacity = &value + } + return e +} + +// MinWidthPercentOffset is a percentage minimum plus a logical-pixel offset. +func (e Element) MinWidthPercentOffset(percent, offset float32) Element { + if n := e.node(); n != nil { + n.minW = length{v: percent, u: unitPercent, offset: offset} + } + return e +} +func (e Element) MinHeightPercentOffset(percent, offset float32) Element { + if n := e.node(); n != nil { + n.minH = length{v: percent, u: unitPercent, offset: offset} + } + return e +} + +// ScrollAxes makes an existing container scroll on the chosen axes. +func (e Element) ScrollAxes(horizontal, vertical bool) Element { + if n := e.node(); n != nil { + n.flags &^= flagScrollX | flagScrollY + if horizontal || vertical { + n.flags |= flagHover + } + if horizontal { + n.flags |= flagScrollX + } + if vertical { + n.flags |= flagScrollY + } + } + return e +} + +// WhiteSpace controls collapsing and wrapping for the inline text in e. +func (e Element) WhiteSpace(mode string) Element { + var apply func(*node) + apply = func(n *node) { + if n.kind == kindText { + switch mode { + case "normal", "nowrap": + n.text = strings.Join(strings.Fields(n.text), " ") + case "pre-line": + lines := strings.Split(n.text, "\n") + for i, line := range lines { + lines[i] = strings.Join(strings.Fields(line), " ") + } + n.text = strings.Join(lines, "\n") + } + if mode == "nowrap" { + n.NoWrap() + } + } + for child := n.first; child != nil; child = child.next { + apply(child) + } + } + if n := e.node(); n != nil { + apply(n) + } + return e +} + +// AccessibleNode is a copied view of a committed assistive-technology node. +type AccessibleNode struct { + ID uint64 `json:"id"` + Role Role `json:"role"` + Label string `json:"label"` + Value string `json:"value"` + Level int `json:"level"` + Bounds Rect `json:"bounds"` + Disabled bool `json:"disabled"` + Selected bool `json:"selected"` + Expanded bool `json:"expanded"` +} + +// Accessibility returns the actual committed accessibility tree for testing. +// Returned values own no frame handles and may be kept across later frames. +func (t *Tester) Accessibility() []AccessibleNode { + tree := t.rt.accessTree() + out := make([]AccessibleNode, 0, len(tree.Nodes)) + for _, n := range tree.Nodes { + out = append(out, AccessibleNode{ID: n.ID, Role: Role(n.Role) + RoleGroup, Label: n.Label, Value: n.Value, Level: n.Level, Bounds: Rect{X: float32(n.Bounds.X), Y: float32(n.Bounds.Y), W: float32(n.Bounds.W), H: float32(n.Bounds.H)}, Disabled: n.States&platform.AccessDisabled != 0, Selected: n.States&platform.AccessSelected != 0, Expanded: n.States&platform.AccessExpanded != 0}) + } + return out +} + +// Inert keeps the subtree visible while removing it from interaction and access. +func (e Element) Inert(enabled bool) Element { + if n := e.node(); n != nil { + n.flags &^= flagInert + if enabled { + n.flags |= flagInert + } + } + return e +} + +// FontItalic sets an explicit inherited italic value, including normal text. +func (e Element) FontItalic(enabled bool) Element { + if n := e.node(); n != nil { + n.ts.italic = enabled + n.ts.set |= setItalic + } + return e +} + +// TextDecorations explicitly overrides inherited underlines and strike-through. +func (e Element) TextDecorations(underline, strike bool) Element { + if n := e.node(); n != nil { + n.ts.underline, n.ts.wavy, n.ts.strike = underline, false, strike + n.ts.set |= setUnderline | setStrike + } + return e +} diff --git a/ui/lui_style_test.go b/ui/lui_style_test.go new file mode 100644 index 000000000..33d9fe3fa --- /dev/null +++ b/ui/lui_style_test.go @@ -0,0 +1,81 @@ +package ui + +import ( + "reflect" + "testing" +) + +func TestLUIZIndexPreservesFlowAndOrdersPaintAndHitTesting(t *testing.T) { + red, blue := 0, 0 + var failure string + tester := NewTester(func(c *Context) { + Grid(c).ColumnTracks(Fixed(80)).RowTracks(Fixed(40)).Children(func() { + e := Box(c).ColumnStart(1).RowStart(1).Background(RGB(255, 0, 0)).OnClick(func() { red++ }) + index, ok := any(e).(interface{ ZIndex(int) Element }) + if !ok { + failure = "missing paint-only z-index" + return + } + index.ZIndex(10) + Box(c).ColumnStart(1).RowStart(1).Background(RGB(0, 0, 255)).OnClick(func() { blue++ }) + }) + }, 200, 100) + if failure != "" { + t.Fatal(failure) + } + pixel := tester.Image().RGBAAt(40, 20) + if pixel.R < 250 || pixel.B > 5 { + t.Fatalf("z-order did not affect painting: %#v", pixel) + } + tester.ClickAt(40, 20) + if red != 1 || blue != 0 { + t.Fatalf("paint and hit order diverged: red=%d blue=%d", red, blue) + } +} + +func TestLUITesterExposesCommittedAccessibilitySnapshot(t *testing.T) { + tester := NewTester(func(c *Context) { Text(c, "Heading").Role(RoleHeading).Level(3) }, 200, 100) + method := reflect.ValueOf(tester).MethodByName("Accessibility") + if !method.IsValid() { + t.Fatal("missing accessibility snapshot") + } + if method.Call(nil)[0].Len() == 0 { + t.Fatal("committed accessibility snapshot is empty") + } +} + +func TestLUINegativePercentageOffsetsRemainSigned(t *testing.T) { + var relative, absolute Handle + var relativeX, absoluteX float32 + NewTester(func(c *Context) { + Box(c).Width(200).Height(100).Children(func() { + Box(c).Width(20).Height(20).StartPercent(-25).Bind(&relative) + Box(c).Absolute().Width(20).Height(20).LeftPercent(-10).Bind(&absolute) + }) + c.AfterFrame(func() { + relativeX, absoluteX = relative.Bounds(c).X, absolute.Bounds(c).X + }) + }, 300, 200) + if relativeX != -50 || absoluteX != -20 { + t.Fatalf("negative offsets clamped: relative=%v, absolute=%v", relativeX, absoluteX) + } +} + +func TestLUIExplicitLineHeightMayBeSmallerThanFontMetrics(t *testing.T) { + for _, fixed := range []bool{true, false} { + t.Run(map[bool]string{true: "logical-pixels", false: "font-multiplier"}[fixed], func(t *testing.T) { + tester := NewTester(func(c *Context) { + e := Text(c, "first\nsecond").FontSize(40).Label("two lines") + if fixed { + e.FixedLineHeight(10) + } else { + e.LineHeight(.25) + } + }, 300, 200) + r, ok := tester.Find("two lines") + if !ok || r.H != 20 { + t.Fatalf("explicit line boxes must preserve requested height despite larger font ink: found=%v rect=%v", ok, r) + } + }) + } +} diff --git a/ui/menu.go b/ui/menu.go index 41b88a6f3..800890ec0 100644 --- a/ui/menu.go +++ b/ui/menu.go @@ -247,6 +247,7 @@ type menuState struct { release, releaseText uint64 pending *shownMenu chosen uint64 + dismissed uint64 choice int label string } @@ -457,6 +458,8 @@ func (rt *engine) showMenu() { // reports it to ContextMenu's function, or the text input does the // command. func (rt *engine) menuChosen(p *shownMenu, id int) { + rt.menu.dismissed = p.id + rt.requestFrame() if id < 1 || id > len(p.labels) { return } diff --git a/ui/paint.go b/ui/paint.go index 28fcfcfa4..e416ea8d6 100644 --- a/ui/paint.go +++ b/ui/paint.go @@ -208,7 +208,7 @@ func (p *Painter) element(e *node) { } saved := p.opacity if e.flags&flagDisabled != 0 { - p.opacity *= 0.5 + if e.disabledOpacity!=nil {p.opacity*=*e.disabledOpacity}else{p.opacity *= 0.5} } if e.opacitySet { p.opacity *= e.opacity @@ -301,6 +301,7 @@ func (p *Painter) element(e *node) { } } } + if ordered:=e.layeredChildren();ordered!=nil {for _,child:=range ordered {if child.leaving==0 && p.paintable(child){p.element(child)}}}else{ for c := e.first; c != nil; c = c.next { if c.flags&flagAbsolute == 0 && p.paintable(c) { p.element(c) @@ -314,6 +315,7 @@ func (p *Painter) element(e *node) { p.element(c) } } + } } if clips { p.popClip() diff --git a/ui/runtime.go b/ui/runtime.go index dda28ff86..484a9a8fc 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -85,6 +85,8 @@ type host interface { // the view function, lays them out, paints them and routes input to the // elements of the last frame. Main thread only, except where noted. type engine struct { + afterFrame []func() + // inlineRects is placeInline's scratch, kept from frame to frame. inlineRects []text.Rect // byPosition holds the keys whose press stood for its physical key @@ -947,6 +949,13 @@ func (rt *engine) close() { // during it. func (rt *engine) endFrame() { rt.inFrame = false + callbacks := rt.afterFrame + rt.afterFrame = nil + if !rt.closing { + for _, callback := range callbacks { + callback() + } + } if rt.closing { rt.close() } @@ -1101,6 +1110,7 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { r, _ := e.clipRect() clip = intersect(clip, e.visualRect(r)) } + if ordered:=e.layeredChildren();ordered!=nil {for _,ch:=range ordered {rt.commitElement(ch,clip,invisible)}}else{ // Children in flow first, absolute ones above them: the paint order. for ch := e.first; ch != nil; ch = ch.next { if ch.flags&flagAbsolute == 0 { @@ -1112,6 +1122,7 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { rt.commitElement(ch, clip, invisible) } } + } e.setInk(vis) } diff --git a/ui/scope.go b/ui/scope.go index 83bb193da..4ea687144 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -225,6 +225,10 @@ func (rt *engine) pressedWithin(id uint64, e *node) bool { var anchor uint64 if e.popover != nil { anchor = e.popover.id + } else if e.popoverHandle != nil { + if binding := e.popoverHandle.binding(rt, false); binding != nil && !binding.closed { + anchor = binding.id + } } s := rt.states[id] for range 1000 { // against cycles of anchors diff --git a/ui/widgets.go b/ui/widgets.go index 6e3971dfc..51b21d61e 100644 --- a/ui/widgets.go +++ b/ui/widgets.go @@ -186,8 +186,11 @@ func styleButton(c *context, b *node, primary bool) { // Inside a RichText it is a link within the paragraph; give it an empty // label and Children to style parts of its text. func coreLink(c *context, label, url string) *node { - t := c.theme - e := coreText(c, label).TextColor(t.Accent).Cursor(CursorPointer).Focusable() + e := coreText(c, label).TextColor(c.theme.Accent).Cursor(CursorPointer).Focusable() + return linkBehavior(c, e, url) +} + +func linkBehavior(c *context, e *node, url string) *node { e.widget, e.role = "Link", RoleLink router := c.router e.afterInput(func() { From 4d51d7aa50eaba68e81a20531c18bca26c79a32e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 01:12:23 +0800 Subject: [PATCH 02/38] Release abandoned native UI handle bindings after collection --- ui/context.go | 2 + ui/lui_handle_lifetime_test.go | 135 +++++++++++++++++++++++++++++++++ ui/ref.go | 20 ++++- ui/runtime.go | 13 +++- 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 ui/lui_handle_lifetime_test.go diff --git a/ui/context.go b/ui/context.go index a67a60942..348361e36 100644 --- a/ui/context.go +++ b/ui/context.go @@ -92,6 +92,8 @@ func (c *context) alloc() *node { } func (c *context) reset(now time.Time, w, h float32) { + clear(c.rt.boundRefs) + c.rt.boundRefs = c.rt.boundRefs[:0] clear(c.rt.afterFrame) c.rt.afterFrame = c.rt.afterFrame[:0] for _, f := range c.rt.focusFields { diff --git a/ui/lui_handle_lifetime_test.go b/ui/lui_handle_lifetime_test.go new file mode 100644 index 000000000..9598ddf3b --- /dev/null +++ b/ui/lui_handle_lifetime_test.go @@ -0,0 +1,135 @@ +package ui + +import ( + "runtime" + "testing" + "weak" +) + +func TestLUIEphemeralHandleBindingsFollowTheirOwners(t *testing.T) { + shown := true + var bindings []weak.Pointer[refState] + tt := NewTester(func(c *Context) { + if !shown { + Text(c, "Cleared") + return + } + for i := range 16 { + var handle Handle + Button(c.Key(i), "Row").Bind(&handle) + bindings = append(bindings, weak.Make(handle.state.bindings[0])) + } + }, 160, 100) + defer tt.rt.close() + for range 15 { + tt.Frame() + } + shown = false + tt.Frame() + runtime.GC() + tt.Frame() + retained := 0 + for _, binding := range bindings { + if binding.Value() != nil { + retained++ + } + } + if retained != 0 { + t.Errorf("clearing ephemeral controls retained %d of %d abandoned handle bindings", retained, len(bindings)) + } + if len(tt.rt.refs) != 0 { + t.Errorf("focus processing still scans %d abandoned handle bindings", len(tt.rt.refs)) + } + runtime.KeepAlive(tt) +} + +func TestLUIHiddenLiveHandleKeepsItsPendingFocus(t *testing.T) { + var handle Handle + shown := true + tt := NewTester(func(c *Context) { + Column(c).Children(func() { + if shown { + Button(c.Key("target"), "Target").Bind(&handle) + } + Button(c.Key("other"), "Other") + }) + }, 160, 100) + defer tt.rt.close() + shown = false + tt.Frame() + runtime.GC() + handle.Focus() + runtime.GC() + tt.Frame() + if tt.Focused("Target") || !handle.state.bindings[0].requested { + t.Fatal("hidden control lost its pending focus request or took focus") + } + shown = true + tt.Frame() + if !tt.Focused("Target") || handle.state.bindings[0].requested { + t.Fatal("returning live handle did not receive pending focus") + } + shown = false + tt.Frame() + if err := tt.Click("Other"); err != nil { + t.Fatal(err) + } + handle.Focus() + handle.CancelFocus() + runtime.GC() + shown = true + tt.Frame() + if tt.Focused("Target") { + t.Fatal("cancelled hidden focus request was delivered") + } + runtime.KeepAlive(handle) +} + +func TestLUITransientHandleFocusSurvivesCollectionDuringBuild(t *testing.T) { + tt := NewTester(func(c *Context) { + var handle Handle + Button(c.Key("target"), "Target").Bind(&handle) + handle.Focus() + runtime.GC() + }, 160, 100) + defer tt.rt.close() + if !tt.Focused("Target") { + t.Fatal("a focus request made during construction was collected before delivery") + } +} + +func TestLUILiveHandleSeparatesWindowBindingsAcrossCollectionAndClose(t *testing.T) { + var handle Handle + var firstContext, secondContext *Context + first := NewTester(func(c *Context) { + firstContext = c + Button(c, "First").Bind(&handle) + }, 160, 100) + second := NewTester(func(c *Context) { + secondContext = c + Button(c, "Second").Bind(&handle) + }, 160, 100) + defer second.rt.close() + runtime.GC() + handle.Focus(firstContext) + first.Frame() + if !handle.Focused(firstContext) || handle.Focused(secondContext) { + t.Fatal("focus crossed window bindings after collection") + } + firstOwner := weak.Make(first.rt) + first.rt.close() + first = nil + runtime.GC() + if firstOwner.Value() != nil { + t.Fatal("a live handle retained its closed window") + } + if !handle.state.bindings[0].closed || handle.state.bindings[0].requested { + t.Fatal("closing a window did not retire its live handle binding") + } + handle.Focus() + second.Frame() + if !handle.Focused(secondContext) || handle.Focused(firstContext) { + t.Fatal("closing one window damaged the remaining binding") + } + runtime.KeepAlive(handle) +} diff --git a/ui/ref.go b/ui/ref.go index c81ef343f..148ca1f25 100644 --- a/ui/ref.go +++ b/ui/ref.go @@ -1,5 +1,7 @@ package ui +import "weak" + // Handle identifies a control across builds. Bind it to an Element on each // pass. One handle may have separate bindings in several windows. Queries // take a Context and read committed identity, regardless of build order. @@ -50,7 +52,7 @@ func (r *Handle) binding(rt *engine, create bool) *refState { b := &refState{window: rt.public, requested: s.pending} s.pending = false s.bindings = append(s.bindings, b) - rt.refs = append(rt.refs, b) + rt.refs = append(rt.refs, weak.Make(b)) return b } @@ -124,7 +126,12 @@ func (e Element) Bind(r *Handle) Element { if b.element.Valid() && b.element != e { panic("ui: Handle bound to two controls in one build") } - b.element = e + if b.element != e { + b.element = e + // A local handle may be discarded after requesting focus. Keep its + // binding through this build, then let the handle own its lifetime. + n.c.rt.boundRefs = append(n.c.rt.boundRefs, b) + } return e } @@ -178,10 +185,13 @@ func (r *Handle) OnShortcut(c *Context, mods Modifiers, key Key, fn func()) { } func (rt *engine) applyFocusRequests() { - for _, b := range rt.refs { - if b.closed { + refs := rt.refs[:0] + for _, ref := range rt.refs { + b := ref.Value() + if b == nil || b.closed { continue } + refs = append(refs, ref) if n := b.element.lookup(); n != nil { b.id = n.id } @@ -199,6 +209,8 @@ func (rt *engine) applyFocusRequests() { rt.consumed = true } } + clear(rt.refs[len(refs):]) + rt.refs = refs rt.applyFocusBindings() } diff --git a/ui/runtime.go b/ui/runtime.go index 484a9a8fc..33274f0cc 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -106,7 +106,8 @@ type engine struct { notices []*state typedInputs []*state focusFields map[any]*focusField - refs []*refState + refs []weak.Pointer[refState] + boundRefs []*refState actions []action textInputClosed bool view func(*context) @@ -911,12 +912,16 @@ func (rt *engine) close() { rt.closed, rt.closing = true, false clear(rt.focusFields) rt.focusFields = nil - for _, r := range rt.refs { - r.closed = true - r.requested = false + for _, ref := range rt.refs { + if r := ref.Value(); r != nil { + r.closed = true + r.requested = false + } } clear(rt.refs) rt.refs = nil + clear(rt.boundRefs) + rt.boundRefs = nil clear(rt.parts) rt.parts = nil clear(rt.typedInputs) From 2949164d2beec7ad8ca9a2d0a26b2b1bba97ef01 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 02:26:10 +0800 Subject: [PATCH 03/38] Expose bounded list windows and preserve active row owners --- ui/list.go | 130 +++++++++++++--- ui/lui_list_window_test.go | 306 +++++++++++++++++++++++++++++++++++++ ui/menu.go | 5 + 3 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 ui/lui_list_window_test.go diff --git a/ui/list.go b/ui/list.go index 924b0108b..0d55d3930 100644 --- a/ui/list.go +++ b/ui/list.go @@ -38,6 +38,7 @@ const listSearch = 1000 type ListState struct { overscan *int itemExtent float32 + keyIndex func(any) (int, bool) // Handle names this list independently of its per-pass node. Handle @@ -204,12 +205,14 @@ type ListState struct { // width, gap and padTop are those the rows were placed with: the width // of the rows, the gap between them, and where the first starts below // the list's top. border is its top border. - width float32 - gap float64 - padTop float64 - border float64 - first, last int - atEnd bool + width float32 + gap float64 + padTop float64 + border float64 + first, last int + requestedFirst, requestedLast int + requestedPins, buildPins []int + atEnd bool // read is set when the view read where the list is, which a frame // that moves it then builds another for. read bool @@ -327,6 +330,27 @@ func (s *ListState) Visible() (first, last int) { return s.first, s.last } +// SetKeyIndex supplies an authoritative lookup for Key, allowing a retained +// item to move arbitrarily far without searching the collection. Pass nil to +// restore the default nearby-key search. Missing keys must report false. +func (s *ListState) SetKeyIndex(lookup func(any) (int, bool)) { s.keyIndex = lookup } + +// Requested returns the committed half-open overscan window and the rows +// outside it retained for active input or an open popup. Pins never enlarge +// the contiguous window. The returned slice belongs to the caller. +func (s *ListState) Requested() (first, last int, pins []int) { + s.read = true + first, last = s.requestedFirst, s.requestedLast + if s.frame.e != nil { + for _, i := range s.interactionRows(s.frame.e, s.frame.n) { + if i < first || i >= last { + pins = append(pins, i) + } + } + } + return first, last, pins +} + // AtEnd reports whether the list showed its end in the last frame, as // Visible does its rows. func (s *ListState) AtEnd() bool { @@ -495,24 +519,19 @@ func buildList(c *context, e, owner *node, s *ListState, n int, row func(i int), e.Focusable() } a, first, last := s.plan(e, n) + s.buildPins = s.interactionRows(e, n) e.Children(func() { for i := first; i < last; i++ { f.build(i) } - // The row holding the focus stays out of view as well, with the - // text being edited in it; and the header of the section at the - // top is built where the others are. - focus, ok := s.focusedRow(e, n) - if ok && (focus < first || focus >= last) { - f.build(focus) - } - // So does the row a drag choosing rows began on, which holds the - // press as the list scrolls it away. - if d := &s.dragSel; d.on && d.from < n && (d.from < first || d.from >= last) && (!ok || d.from != focus) { - f.build(d.from) + for _, i := range s.buildPins { + if i < first || i >= last { + f.build(i) + } } + if s.Header != nil && n > 0 { - if h := s.headerAbove(a, n); h >= 0 && (h < first || h >= last) && (!ok || h != focus) { + if h := s.headerAbove(a, n); h >= 0 && (h < first || h >= last) && !slices.Contains(s.buildPins, h) { f.build(h) } } @@ -614,6 +633,10 @@ func (s *ListState) anchorAt(y float64) (int, float64) { // find returns the row whose key is k, looking around row near. func (s *ListState) find(k any, near, n int) (int, bool) { + if s.keyIndex != nil { + i, ok := s.keyIndex(k) + return i, ok && i >= 0 && i < n && s.Key(i) == k + } for d := 0; d <= listSearch; d++ { lo, hi := near-d, near+d if lo < 0 && hi >= n { @@ -1307,6 +1330,75 @@ func (s *ListState) focusedRow(e *node, n int) (int, bool) { return 0, false } +// interactionRows follows real input and popup ownership back to this +// list's previous row roots. A portal's anchor may be outside its parent chain. +func (s *ListState) interactionRows(e *node, n int) []int { + rt := e.c.rt + rows := []int{} + add := func(id uint64) { + visited := map[uint64]bool{} + for id != 0 && id != e.id && !visited[id] { + visited[id] = true + st := rt.states[id] + if st == nil { + return + } + if r, ok := s.rows[id]; ok { + i, valid := r.row, true + if s.Key != nil && (i >= n || s.Key(i) != r.key) { + i, valid = s.find(r.key, i, n) + } + if valid && i >= 0 && i < n && !slices.Contains(rows, i) { + rows = append(rows, i) + } + return + } + if st.anchor != 0 { + id = st.anchor + } else { + id = st.parent + } + } + } + add(rt.focused) + if rt.pressed != nil { + add(rt.pressed.id) + } + add(rt.pressDragger) + if rt.drag != nil && !rt.drag.canceled { + add(rt.drag.src) + } + if s.dragSel.on { + add(s.dragSel.id) + } + add(rt.menu.asked) + add(rt.menu.release) + add(rt.menu.chosen) + if rt.menu.active != nil { + add(rt.menu.active.id) + } + for _, st := range rt.states { + if st.anchor != 0 && st.seen+1 >= rt.frame { + add(st.anchor) + } + } + slices.Sort(rows) + return rows +} + +func (s *ListState) rememberRequested(e *node, first, last int) { + pins := s.buildPins[:0:0] + for _, i := range s.buildPins { + if i < first || i >= last { + pins = append(pins, i) + } + } + if s.read && (first != s.requestedFirst || last != s.requestedLast || !slices.Equal(pins, s.requestedPins)) { + e.c.rt.animating = true + } + s.requestedFirst, s.requestedLast, s.requestedPins = first, last, pins +} + // layoutList measures and places the rows of a List w×h from its place, // building those missing, pins the header of the section at the top, and // sets the size of the content and the offset. Without rows, what else was @@ -1344,6 +1436,7 @@ func (e *node) layoutList(w, h float32) { e.contentW, e.contentH = float64(cw+e.padX()), float64(max(uh, max(h-e.padY(), 0))+e.padY()) layoutAbsolute(e) e.scrollBase = 0 + s.rememberRequested(e, 0, 0) s.remember(e, 0, -1, true, st.scrollY) return } @@ -1555,6 +1648,7 @@ func (e *node) layoutList(w, h float32) { e.scrollBase = scroll st.scrollTo(st.scrollX, scroll) s.inset = inset + s.rememberRequested(e, lo, hi+1) s.remember(e, first, last, hi == n-1 && end()-glide <= H+0.5, scroll) } diff --git a/ui/lui_list_window_test.go b/ui/lui_list_window_test.go new file mode 100644 index 000000000..6cebb2f56 --- /dev/null +++ b/ui/lui_list_window_test.go @@ -0,0 +1,306 @@ +package ui + +import ( + "fmt" + "slices" + "testing" +) + +func requestedListWindow(t *testing.T, s *ListState) (int, int, []int) { + t.Helper() + api, ok := any(s).(interface{ Requested() (int, int, []int) }) + if !ok { + t.Fatal("ListState does not expose its committed contiguous window and active row pins") + } + return api.Requested() +} + +func TestListRequestedWindowUsesLogicalExtentAndOverscan(t *testing.T) { + var s ListState + s.SetItemExtent(20) + s.SetOverscan(2) + tt := coreNewTester(func(c *context) { + coreList(c, &s, 100000, func(i int) { coreBox(c).Height(20).Children(func() { coreTextf(c, "Row %d", i) }) }).Height(100) + }, 300, 100) + first, last, pins := requestedListWindow(t, &s) + if first != 0 || last != 7 || len(pins) != 0 { + t.Fatalf("initial half-open overscan window: %d..%d pins%v", first, last, pins) + } + s.ScrollTo(99999, End) + tt.Frame() + first, last, pins = requestedListWindow(t, &s) + if first != 99993 || last != 100000 || len(pins) != 0 { + t.Fatalf("bottom jump changed logical extent: %d..%d pins%v", first, last, pins) + } + if len(s.rows) > 16 { + t.Fatalf("built %d rows for distant jump", len(s.rows)) + } +} + +func TestListRequestedWindowPinsFocusedCompositionAndReleasesRemovedRows(t *testing.T) { + var s ListState + texts := make([]string, 1000) + count := 1000 + s.SetItemExtent(30) + s.SetOverscan(1) + tt := coreNewTester(func(c *context) { + coreList(c, &s, count, func(i int) { + coreRow(c).Height(30).Children(func() { coreTextInput(c, &texts[i]).Label(fmt.Sprintf("Field %d", i)) }) + }).Height(150) + }, 300, 150) + if err := tt.Click("Field 2"); err != nil { + t.Fatal(err) + } + tt.Compose("ä½ ", 1) + focus := tt.rt.focused + s.ScrollToEnd() + tt.Frame() + first, last, pins := requestedListWindow(t, &s) + if first < 990 || last != 1000 || !slices.Equal(pins, []int{2}) { + t.Fatalf("composition owner incorrectly expands window: %d..%d pins%v", first, last, pins) + } + if tt.rt.focused != focus { + t.Fatal("composition lost focus") + } + tt.Type("ä½ ") + if texts[2] != "ä½ " { + t.Fatalf("composition committed to wrong row: %q", texts[2]) + } + count = 0 + tt.Frame() + first, last, pins = requestedListWindow(t, &s) + if first != 0 || last != 0 || len(pins) != 0 { + t.Fatalf("removed source retains requested rows: %d..%d %v", first, last, pins) + } +} + +func TestListRequestedWindowPinsPointerHoldAndDrag(t *testing.T) { + for _, drag := range []bool{false, true} { + t.Run(fmt.Sprint(drag), func(t *testing.T) { + var s ListState + s.SetItemExtent(30) + s.SetOverscan(1) + tt := coreNewTester(func(c *context) { + coreList(c, &s, 1000, func(i int) { + e := coreBox(c).Height(30).Label(fmt.Sprintf("Hold %d", i)) + e.flags |= flagClickable + if drag { + e.Drag(i) + } + }).Height(150) + }, 300, 150) + r, ok := tt.Find("Hold 2") + if !ok { + t.Fatal("missing press target") + } + tt.Press(r.X+10, r.Y+10) + if drag { + tt.Move(r.X+30, r.Y+10) + if tt.rt.drag == nil { + t.Fatal("drag did not start") + } + } + pressed := tt.rt.pressed + s.ScrollToEnd() + tt.Frame() + if !drag && (pressed == nil || tt.rt.pressed != pressed) { + t.Fatal("offscreen pointer owner was disposed before release") + } + first, last, pins := requestedListWindow(t, &s) + if first < 990 || last != 1000 || !slices.Equal(pins, []int{2}) { + t.Fatalf("hold owner incorrectly expands window: %d..%d pins%v", first, last, pins) + } + tt.Release(20, 20) + tt.Frame() + _, _, pins = requestedListWindow(t, &s) + if len(pins) != 0 { + t.Fatalf("released interaction retains rows: %v", pins) + } + }) + } +} + +func TestListRequestedWindowPinsPopoverOwner(t *testing.T) { + var s ListState + s.SetItemExtent(30) + s.SetOverscan(1) + open := false + tt := coreNewTester(func(c *context) { + coreList(c, &s, 1000, func(i int) { + b := coreBox(c).Height(30).Label(fmt.Sprintf("Owner %d", i)) + b.flags |= flagClickable + if b.Clicked() && i == 2 { + open = true + } + if i == 2 { + corePopoverBase(c, b, &open, func(panel *node) { panel.Size(100, 40); coreText(c, "Owned popup") }) + } + }).Height(150) + }, 300, 150) + if err := tt.Click("Owner 2"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Owned popup") { + t.Fatal("popup did not open") + } + s.ScrollToEnd() + tt.Frame() + if !tt.HasText("Owned popup") { + t.Fatal("scroll disposed live popup owner") + } + first, last, pins := requestedListWindow(t, &s) + if first < 990 || last != 1000 || !slices.Equal(pins, []int{2}) { + t.Fatalf("popup owner incorrectly expands window: %d..%d pins%v", first, last, pins) + } + open = false + tt.Frame() + _, _, pins = requestedListWindow(t, &s) + if len(pins) != 0 { + t.Fatalf("closed popup retains owner: %v", pins) + } +} + +func TestListRequestedWindowPinsNativeMenuOwnerUntilChoice(t *testing.T) { + var s ListState + s.SetItemExtent(30) + s.SetOverscan(1) + chosen := false + tt := coreNewTester(func(c *context) { + coreList(c, &s, 1000, func(i int) { + coreBox(c).Height(30).Label(fmt.Sprintf("Menu owner %d", i)).ContextMenu(func(m *Menu) { + if m.Item("Choose").Chosen() { + chosen = true + } + }) + }).Height(150) + }, 300, 150) + if err := tt.RightClick("Menu owner 2"); err != nil { + t.Fatal(err) + } + if len(tt.Menu()) == 0 { + t.Fatal("menu did not open") + } + s.ScrollToEnd() + tt.Frame() + first, last, pins := requestedListWindow(t, &s) + if first < 990 || last != 1000 || !slices.Equal(pins, []int{2}) { + t.Fatalf("native menu owner incorrectly expands window: %d..%d pins%v", first, last, pins) + } + if err := tt.ChooseMenuItem("Choose"); err != nil { + t.Fatal(err) + } + if !chosen { + t.Fatal("menu choice lost after owner scrolled offscreen") + } + tt.Frame() + _, _, pins = requestedListWindow(t, &s) + if len(pins) != 0 { + t.Fatalf("chosen menu retains owner: %v", pins) + } +} + +func TestListRequestedWindowFollowsPinnedKeyAcrossDistantReorder(t *testing.T) { + var s ListState + s.SetItemExtent(30) + s.SetOverscan(1) + moved := false + keyAt := func(i int) int { + if moved { + if i == 2 { + return 99999 + } + if i == 99999 { + return 2 + } + } + return i + } + s.Key = func(i int) any { return keyAt(i) } + if lookup, ok := any(&s).(interface{ SetKeyIndex(func(any) (int, bool)) }); ok { + lookup.SetKeyIndex(func(key any) (int, bool) { + i, ok := key.(int) + if !ok { + return 0, false + } + return keyAt(i), true + }) + } + text := "" + tt := coreNewTester(func(c *context) { + coreList(c, &s, 100000, func(i int) { + coreBox(c).Height(30).Children(func() { + if keyAt(i) == 2 { + coreTextInput(c, &text).Label("moving editor") + } else { + coreTextf(c, "Row %d", keyAt(i)) + } + }) + }).Height(150) + }, 300, 150) + if err := tt.Click("moving editor"); err != nil { + t.Fatal(err) + } + moved = true + tt.Frame() + if !tt.Focused("moving editor") { + t.Fatal("distant source reorder retired the pinned editor key") + } + _, _, pins := requestedListWindow(t, &s) + if !slices.Equal(pins, []int{99999}) { + t.Fatalf("pin followed old numeric index instead of key: %v", pins) + } + tt.Type("kept") + if text != "kept" { + t.Fatal("reordered editor lost native input state") + } +} + +// Native focus transfer ends preedit through the existing text-input client +// boundary. A finished composition must not pin an otherwise unused row. +func TestListRequestedWindowCommitsNativeCompositionBeforeBlurEviction(t *testing.T) { + var s ListState + s.SetItemExtent(30) + s.SetOverscan(1) + text := "" + tt := coreNewTester(func(c *context) { + coreColumn(c).Children(func() { + coreList(c, &s, 1000, func(i int) { + coreBox(c).Height(30).Children(func() { + if i == 2 { + coreTextInput(c, &text).Label("composing owner") + } else { + coreTextf(c, "Row %d", i) + } + }) + }).Height(150) + coreButton(c, "Other focus") + }) + }, 300, 220) + if err := tt.Click("composing owner"); err != nil { + t.Fatal(err) + } + client := tt.rt.states[tt.rt.focused].editor.client + client.SetMarkedText(nil, "ä½ ", TextInputRange{Start: 1, End: 1}) + tt.Frame() + if _, marked := client.MarkedRange(); !marked { + t.Fatal("native preedit did not begin") + } + if err := tt.Click("Other focus"); err != nil { + t.Fatal(err) + } + if _, marked := client.MarkedRange(); marked { + t.Fatal("focus transfer left native preedit unresolved") + } + if text != "ä½ " { + t.Fatalf("native composition was not committed before blur: %q", text) + } + s.ScrollToEnd() + tt.Frame() + _, _, pins := requestedListWindow(t, &s) + if len(pins) != 0 { + t.Fatalf("finished composition retained unused row: %v", pins) + } + if _, ok := tt.Find("composing owner"); ok { + t.Fatal("blurred row stayed materialized") + } +} diff --git a/ui/menu.go b/ui/menu.go index 800890ec0..12ce85a32 100644 --- a/ui/menu.go +++ b/ui/menu.go @@ -246,6 +246,7 @@ type menuState struct { // goes up, as on Windows, on the text releaseText. release, releaseText uint64 pending *shownMenu + active *shownMenu chosen uint64 dismissed uint64 choice int @@ -451,6 +452,7 @@ func (rt *engine) showMenu() { return } rt.menu.pending = nil + rt.menu.active = p rt.host.popupMenu(p.menu, p.x, p.y, func(id int) { rt.menuChosen(p, id) }) } @@ -458,6 +460,9 @@ func (rt *engine) showMenu() { // reports it to ContextMenu's function, or the text input does the // command. func (rt *engine) menuChosen(p *shownMenu, id int) { + if rt.menu.active == p { + rt.menu.active = nil + } rt.menu.dismissed = p.id rt.requestFrame() if id < 1 || id > len(p.labels) { From 7875529a3c4d630733b3996ada9f0b1fc746ba78 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 20:38:21 +0000 Subject: [PATCH 04/38] feat(ui): expose committed window input capture --- docs/input-capture.md | 37 +++++++++++++++++++++ ui/handler.go | 3 ++ ui/input.go | 42 ++++++++++++++++++++++++ ui/input_capture.go | 71 ++++++++++++++++++++++++++++++++++++++++ ui/input_capture_test.go | 49 +++++++++++++++++++++++++++ ui/runtime.go | 27 ++++++++++----- 6 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 docs/input-capture.md create mode 100644 ui/input_capture.go create mode 100644 ui/input_capture_test.go diff --git a/docs/input-capture.md b/docs/input-capture.md new file mode 100644 index 000000000..fb796c642 --- /dev/null +++ b/docs/input-capture.md @@ -0,0 +1,37 @@ +# Window input capture + +Install `Context.CaptureInput(func(InputEvent, InputTarget) bool)` once per view +build. It observes input before focused text clients, element handlers, shortcuts, +menus and native editing consume it. Returning true prevents native handling; +return false for observation. A build that omits registration removes it. The +last registration wins. Callbacks run on the UI thread, must not build UI or +enter a nested event loop, and must remain short. + +Keyboard, text, composition and native menu commands identify the committed +focused element. Pointer and scroll events identify the deepest committed hit +element, including non-interactive text children. `InputTarget.Distance(handle)` +reports zero for an exact binding, the number of ancestors for a containing +binding, and -1 otherwise. An input target expires on callback return; retain +application identity instead, and validate its incarnation before delayed use. +No geometry guessing or private engine access is needed. Pointer positions and +scroll deltas in capture use window DIPs, unlike local `HandleInput` coordinates. + +Capture reports raw pointer press/release/movement and `InputClick` when MyGo's +native hit/press/drag rules qualify a primary release as a click. The latter is +capture-only and includes the native click count. Click +recognition, focus, IME, editor commands and menus remain MyGo's responsibility +unless explicitly prevented. A pointer release prevented by capture cancels the +pending native press/drag so it cannot produce a click later. + +Native menu commands arrive as `InputCommand` (for example `copy`, `paste`, +`selectAll`, `undo`). Do not re-inject fabricated physical keys into the runtime. +A host may normalize a menu command to its existing document accelerator +vocabulary, but must return false unless its document explicitly prevents the +command's default action. The focused native text control then handles the +original command exactly once. Embedded editors may handle the command through +their own focused input handler. + +Capture does not synthesize key events on platforms where an input method +reports only committed/composing text, nor intercept OS-reserved shortcuts. +Applications forwarding document events must suppress duplicate forwarding from +their element-local handlers, not disable those handlers' native editing logic. diff --git a/ui/handler.go b/ui/handler.go index 12a6eb597..15d42ae66 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -52,6 +52,9 @@ const ( // with Ctrl held comes as InputZoom too, and a mouse wheel's notches // as InputScroll with Ctrl in Mods. InputZoom + // InputClick is emitted only to CaptureInput when a primary pointer release + // qualifies as a native click. Returning true suppresses native activation. + InputClick ) // InputEvent is input an element takes as it comes (HandleInput). diff --git a/ui/input.go b/ui/input.go index 08228f998..e5f4a670c 100644 --- a/ui/input.go +++ b/ui/input.go @@ -80,6 +80,9 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { key = physical } delete(rt.byPosition, physical) + if rt.captureInput(InputEvent{Kind: InputKeyUp, Mods: Modifiers(ev.Mods), Key: key, PhysicalKey: physical}, rt.focused) { + break + } if h := rt.focusHandler(); h != nil { rt.deliver(h, InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Mods: Modifiers(ev.Mods)}) } @@ -220,6 +223,10 @@ func (rt *engine) rehover() { } func (rt *engine) pointerMove(x, y float32) { + if (x != rt.pointerX || y != rt.pointerY || !rt.pointerIn) && rt.capturePointer(InputEvent{Kind: InputPointerMove, Button: -1, Mods: rt.mods}, x, y) { + rt.pointerX, rt.pointerY, rt.pointerIn = x, y, true + return + } if d := &rt.scrollDrag; d.st != nil { s := d.st g := d.bars(rt.c.theme.scrollbarWidth()) @@ -295,6 +302,9 @@ func (rt *engine) pointerMove(x, y float32) { const interactive = flagClickable | flagFocusable | flagEditable | flagSelectable | flagDragWindow | flagDraggable | flagTrackPointer func (rt *engine) pointerDown(x, y float32, button int, mods Modifiers, clicks int) { + if rt.capturePointer(InputEvent{Kind: InputPointerDown, Button: button, Clicks: max(1, clicks), Mods: mods}, x, y) { + return + } rt.downing++ defer rt.endDown() chain := rt.hitChain(x, y) @@ -427,6 +437,17 @@ func (rt *engine) pointerUp(button, clicks int) { if button == rt.pressButton { clicks = rt.pressClicks // the engine's, which may have started over } + if rt.capturePointer(InputEvent{Kind: InputPointerUp, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { + if rt.pressed != nil { + rt.pressed.pressed = false + } + rt.pressed = nil + rt.scrollDrag.st = nil + rt.selection.dragging = false + rt.dragCancel() + rt.requestFrame() + return + } if rt.scrollDrag.st != nil { rt.scrollDrag.st = nil // The pointer may have left the bar, over what the hover missed. @@ -445,6 +466,15 @@ func (rt *engine) pointerUp(button, clicks int) { if p := rt.pressed; p != nil && p.input != nil { rt.deliver(p, InputEvent{Kind: InputPointerUp, Button: button, Mods: rt.mods, Clicks: max(clicks, 1)}) } + if s := rt.pressed; s != nil && button == 0 && button == rt.pressButton && s.flags&flagDisabled == 0 && + rt.contains(s, Rect{s.x, s.y, s.w, s.h}, rt.pointerX, rt.pointerY) && !s.dragRec.dragged(rt, s) && + rt.capturePointer(InputEvent{Kind: InputClick, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { + rt.pressed = nil + s.pressed = false + rt.selection.dragging = false + rt.requestFrame() + return + } if button == 1 && rt.menuRelease() { return } @@ -483,6 +513,9 @@ func (rt *engine) pointerUp(button, clicks int) { } func (rt *engine) scroll(dx, dy float32, mods Modifiers, precise, momentum bool) { + if rt.capturePointer(InputEvent{Kind: InputScroll, DX: dx, DY: dy, Mods: mods, Precise: precise, Momentum: momentum}, rt.pointerX, rt.pointerY) { + return + } chain := rt.appendHitChain(rt.chain[:0], rt.pointerX, rt.pointerY) rt.chain = chain[:0] if h := rt.handler(chain); h != nil && rt.deliver(h, InputEvent{Kind: InputScroll, DX: dx, DY: dy, Mods: mods, Precise: precise, Momentum: momentum}) { @@ -691,6 +724,9 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool if rt.inspectKey(mods, key) { return true } + if rt.captureInput(InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat}, rt.focused) { + return true + } k := keyEvent{mods, key} if key == KeyEscape && mods == 0 && rt.dragCancel() { return true @@ -952,6 +988,12 @@ func (rt *engine) shortcut(id uint64, mods Modifiers, key Key) bool { } func (rt *engine) editEvent(ev editEvent) { + if rt.inputCapture != nil { + kind := map[editKind]InputKind{editInsert: InputText, editCompose: InputCompose, editCommand: InputCommand}[ev.kind] + if kind != 0 && rt.captureInput(InputEvent{Kind: kind, Text: ev.text, Caret: ev.caret, Mods: rt.mods}, rt.focused) { + return + } + } if s := rt.states[rt.focused]; s != nil && s.textClient != nil && rt.windowFocus() && s.flags&flagDisabled == 0 && (s.editor == nil || !s.editor.readOnly) { if ev.kind == editInsert || ev.kind == editCompose { var r *TextInputRange diff --git a/ui/input_capture.go b/ui/input_capture.go new file mode 100644 index 000000000..f3f030293 --- /dev/null +++ b/ui/input_capture.go @@ -0,0 +1,71 @@ +package ui + +import "weak" + +// InputTarget identifies the committed focused control (keyboard, text and +// commands) or deepest hit element (pointer and scroll). It is valid only +// during the CaptureInput callback; it does not retain a frame's elements. +type InputTarget struct { + owner weak.Pointer[engine] + id uint64 + serial uint64 +} + +// Distance returns the number of committed ancestors from the target to h, +// zero for the target itself, or -1 for an unrelated or expired handle. +func (t InputTarget) Distance(h Handle) int { + rt := t.owner.Value() + if rt == nil || t.serial == 0 || rt.capturing != t.serial { + return -1 + } + b := h.binding(rt, false) + if b == nil || b.closed || b.id == 0 { + return -1 + } + for id, distance := t.id, 0; id != 0; distance++ { + s := rt.states[id] + if s == nil { + return -1 + } + if id == b.id { + return distance + } + id = s.parent + } + return -1 +} + +// CaptureInput installs this build's window input capture callback. Call once +// per view build, on the UI thread. It runs before focused controls, shortcuts, +// menus and pointer handlers consume input; true prevents native handling. +// Return false to observe without interfering with native editing. Rebuilds +// that omit the registration remove it. The callback must not build UI or run +// a nested event loop. Events and pointer coordinates use window DIPs. +func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { + if raw := c.build(); raw != nil { + raw.rt.inputCapture = fn + } +} + +func (rt *engine) captureInput(ev InputEvent, id uint64) bool { + if rt.inputCapture == nil { + return false + } + rt.captureSerial++ + rt.capturing = rt.captureSerial + defer func() { rt.capturing = 0 }() + ev.Time = rt.frameTime() + return rt.inputCapture(ev, InputTarget{owner: rt.owner, id: id, serial: rt.capturing}) +} + +func (rt *engine) capturePointer(ev InputEvent, x, y float32) bool { + if rt.inputCapture == nil { + return false + } + var id uint64 + if chain := rt.hitChain(x, y); len(chain) != 0 { + id = chain[0] + } + ev.X, ev.Y = x, y + return rt.captureInput(ev, id) +} diff --git a/ui/input_capture_test.go b/ui/input_capture_test.go new file mode 100644 index 000000000..5c914866a --- /dev/null +++ b/ui/input_capture_test.go @@ -0,0 +1,49 @@ +package ui + +import "testing" + +func TestCaptureInputBeforeNativeConsumption(t *testing.T) { + var field Handle + var value string + var capture bool = true + var saved InputTarget + var keys int + tt := NewTester(func(c *Context) { + if capture { + c.CaptureInput(func(ev InputEvent, target InputTarget) bool { + if ev.Kind != InputKeyDown && ev.Kind != InputCommand { + return false + } + if target.Distance(field) != 0 { + t.Fatal("capture did not identify the focused native field") + } + saved = target + if ev.Kind == InputKeyDown { + keys++ + } + return ev.Kind == InputKeyDown && ev.Key == KeyBackspace + }) + } + TextInput(c, &value).Bind(&field).Label("Query").AutoFocus() + }, 300, 100) + tt.Frame() + tt.Type("search") + tt.Key(0, KeyBackspace) + if value != "search" || keys != 1 { + t.Fatalf("capture failed to prevent native deletion: %q, %d key events", value, keys) + } + if saved.Distance(field) != -1 { + t.Fatal("input target remained live outside its callback") + } + tt.Command("selectAll") + tt.Type("next") + if value != "next" { + t.Fatalf("capture swallowed native selectAll: %q", value) + } + capture = false + tt.Frame() + tt.Key(0, KeyBackspace) + if value != "nex" || keys != 1 { + t.Fatalf("removed capture still handled input: %q, %d key events", value, keys) + } +} diff --git a/ui/runtime.go b/ui/runtime.go index 484a9a8fc..cdfa571c2 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -240,6 +240,9 @@ type engine struct { frameAt time.Time pointerX, pointerY float32 + inputCapture func(InputEvent, InputTarget) bool + captureSerial uint64 + capturing uint64 pointerIn bool hover []uint64 // chain is a buffer for the elements under the pointer. @@ -534,6 +537,7 @@ func (rt *engine) runFrame() { rt.nextRegs = rt.nextRegs[:0] clear(rt.kept) rt.c.reset(now, appW, h) + rt.inputCapture = nil rt.view(&rt.c) if rt.closing { // The view closed the window, as a close button does: the @@ -904,6 +908,7 @@ func (rt *engine) armTimer() { // frame ends: the rest of the view builds with a Context that still works, // and reads the theme, the size and its state as before. func (rt *engine) close() { + rt.inputCapture = nil if rt.inFrame { rt.closing = true return @@ -1110,18 +1115,22 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { r, _ := e.clipRect() clip = intersect(clip, e.visualRect(r)) } - if ordered:=e.layeredChildren();ordered!=nil {for _,ch:=range ordered {rt.commitElement(ch,clip,invisible)}}else{ - // Children in flow first, absolute ones above them: the paint order. - for ch := e.first; ch != nil; ch = ch.next { - if ch.flags&flagAbsolute == 0 { + if ordered := e.layeredChildren(); ordered != nil { + for _, ch := range ordered { rt.commitElement(ch, clip, invisible) } - } - for ch := e.first; ch != nil; ch = ch.next { - if ch.flags&flagAbsolute != 0 { - rt.commitElement(ch, clip, invisible) + } else { + // Children in flow first, absolute ones above them: the paint order. + for ch := e.first; ch != nil; ch = ch.next { + if ch.flags&flagAbsolute == 0 { + rt.commitElement(ch, clip, invisible) + } + } + for ch := e.first; ch != nil; ch = ch.next { + if ch.flags&flagAbsolute != 0 { + rt.commitElement(ch, clip, invisible) + } } - } } e.setInk(vis) } From 236fd4d6ab2a391f7e0a3fca1668849e13884b58 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 20:42:18 +0000 Subject: [PATCH 05/38] feat(ui): expose committed retained text geometry --- ui/runtime.go | 2 + ui/text_geometry.go | 214 +++++++++++++++++++++++++++++++++++++++ ui/text_geometry_test.go | 61 +++++++++++ 3 files changed, 277 insertions(+) create mode 100644 ui/text_geometry.go create mode 100644 ui/text_geometry_test.go diff --git a/ui/runtime.go b/ui/runtime.go index 484a9a8fc..2406f5286 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -94,6 +94,7 @@ type engine struct { byPosition map[Key]bool owner weak.Pointer[engine] epoch uint64 + committedEpoch uint64 parts []any public *Context arena *elementOwner @@ -983,6 +984,7 @@ func (rt *engine) commit(root *node, w, h float32) { rt.inkStale = false rt.arrangeFocus() rt.noteGroups() + rt.committedEpoch = rt.epoch } func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { diff --git a/ui/text_geometry.go b/ui/text_geometry.go new file mode 100644 index 000000000..79676b44a --- /dev/null +++ b/ui/text_geometry.go @@ -0,0 +1,214 @@ +package ui + +import "github.com/egoist/mygo/internal/text" + +// CommittedSpace reads a control's committed border-box coordinate system. +// Like CommittedText it expires at the next build or window close, and never +// retains a build Context or Element. All methods run on the UI thread. +type CommittedSpace struct { + owner *elementOwner + epoch uint64 + slot uint32 + id uint64 +} + +// Coordinates captures a control's committed coordinate system after layout. +func (r Handle) Coordinates(c *Context) (CommittedSpace, bool) { + rt := c.runtime() + b := (&r).binding(rt, false) + if b == nil || b.closed || b.id == 0 || rt.committedEpoch != rt.epoch || b.element.owner != rt.arena || b.element.gen != rt.arena.generation { + return CommittedSpace{}, false + } + g := CommittedSpace{rt.arena, rt.epoch, b.element.slot, b.id} + _, ok := g.node() + return g, ok +} + +func (g CommittedSpace) node() (*node, bool) { + if g.owner == nil || g.owner.rt == nil { + return nil, false + } + rt := g.owner.rt + if rt.closed || rt.epoch != g.epoch || rt.committedEpoch != g.epoch { + return nil, false + } + n := rt.nodeAt(g.slot) + if n == nil || n.id != g.id { + return nil, false + } + for p := n; p != nil; p = p.parent { + if p.flags&(flagInvisible|flagInert) != 0 || p.backface && p.transform.facesAway() { + return nil, false + } + } + return n, true +} + +// Bounds returns the transformed border box in window DIPs. +func (g CommittedSpace) Bounds() (Rect, bool) { + n, ok := g.node() + if !ok { + return Rect{}, false + } + return transformRect(n.world, Rect{n.x, n.y, n.w, n.h}), true +} + +// LocalToWindow maps a border-box point to window DIPs. +func (g CommittedSpace) LocalToWindow(point Point) (Point, bool) { + n, ok := g.node() + if !ok { + return Point{}, false + } + x, y := n.world.Point(n.x+point.X, n.y+point.Y) + return Point{x, y}, true +} + +// WindowToLocal inverts LocalToWindow; singular transforms report false. +func (g CommittedSpace) WindowToLocal(point Point) (Point, bool) { + n, ok := g.node() + if !ok { + return Point{}, false + } + inv, ok := n.world.Inverse() + if !ok { + return Point{}, false + } + x, y := inv.Point(point.X, point.Y) + return Point{x - n.x, y - n.y}, true +} + +// CommittedText reads the exact shaped layout painted by a Text or RichText, +// including an inline child of a paragraph. Offsets are runes relative to the +// bound element's text, not the enclosing paragraph. Coordinates are window +// DIPs, including scrolling and transforms. Use only on the UI thread. +// +// A value expires when another build starts or its window closes. Every query +// reports false after expiry. It holds neither document state nor an Element. +type CommittedText struct { + space CommittedSpace +} + +// TextGeometry reads committed text after layout (for example in AfterFrame), +// or between frames. It reports false during an uncommitted build and for +// missing, hidden, non-text or closed controls. It never reshapes text. +func (r Handle) TextGeometry(c *Context) (CommittedText, bool) { + space, ok := r.Coordinates(c) + if !ok { + return CommittedText{}, false + } + g := CommittedText{space} + _, _, _, _, ok = g.layout() + return g, ok +} + +func (g CommittedText) layout() (n, para *node, start, end int, ok bool) { + n, ok = g.space.node() + if !ok || n.kind != kindText { + return n, nil, 0, 0, false + } + para = n + if n.isInline() { + start, end = n.runes[0], n.runes[1] + para = n.parent + for para.isInline() { + start, end = start+para.runes[0], end+para.runes[0] + para = para.parent + } + } else if n.tl != nil { + end = len(n.tl.Runes) + } + ok = para.tl != nil && start >= 0 && end >= start && end <= len(para.tl.Runes) + return +} + +// Text returns the displayed text, including nested inline children. +func (g CommittedText) Text() (string, bool) { + _, p, a, b, ok := g.layout() + if !ok { + return "", false + } + return string(p.tl.Runes[a:b]), true +} + +// Caret returns a whole-grapheme caret, with downstream affinity. +func (g CommittedText) Caret(index int) (Rect, bool) { + n, p, a, b, ok := g.layout() + if !ok || index < 0 || index > b-a { + return Rect{}, false + } + x, y, h := p.tl.CaretAt(text.CaretPosition{Index: a + index, Affinity: text.Downstream}) + return transformRect(n.world, Rect{p.x + p.contentX() + x, p.y + p.contentY() + y, 1, h}), true +} + +// SelectionRects preserves wrapped and bidi-discontiguous selections. +func (g CommittedText) SelectionRects(first, last int) ([]Rect, bool) { + n, p, a, b, ok := g.layout() + if !ok || first < 0 || last < first || last > b-a { + return nil, false + } + var out []Rect + for _, r := range p.tl.Selection(a+first, a+last) { + out = append(out, transformRect(n.world, Rect{p.x + p.contentX() + r.X, p.y + p.contentY() + r.Y, r.W, r.H})) + } + return out, true +} + +// IndexAt hit-tests a window point against the retained shaped glyphs. +func (g CommittedText) IndexAt(point Point) (int, bool) { + n, p, a, b, ok := g.layout() + if !ok { + return 0, false + } + inv, ok := n.world.Inverse() + if !ok { + return 0, false + } + x, y := inv.Point(point.X, point.Y) + i := p.tl.PositionAt(x-p.x-p.contentX(), y-p.y-p.contentY()).Index + return max(a, min(b, i)) - a, true +} + +// TextLine is a visual line's rune range and window-space box. Newline +// separators are excluded, as in the shaped layout's line table. +type TextLine struct { + Start, End int + Bounds Rect +} + +// Lines returns the visual lines intersecting this element's text range. +func (g CommittedText) Lines() ([]TextLine, bool) { + n, p, a, b, ok := g.layout() + if !ok { + return nil, false + } + var out []TextLine + for _, line := range p.tl.Lines { + if line.End < a || line.Start > b { + continue + } + if a != b && (line.End == a && line.Start != a || line.Start == b && line.End != b) { + continue + } + lo, hi := max(a, line.Start), min(b, line.End) + var box Rect + found := false + for _, r := range p.tl.Selection(lo, hi) { + if r.Y >= line.Y+line.Height || r.Y+r.H <= line.Y { + continue + } + f := Rect{p.x + p.contentX() + r.X, p.y + p.contentY() + r.Y, r.W, r.H} + if !found { + box = f + found = true + } else { + box = union(box, f) + } + } + if !found { + x, y, h := p.tl.CaretAt(text.CaretPosition{Index: lo, Affinity: text.Downstream}) + box = Rect{p.x + p.contentX() + x, p.y + p.contentY() + y, 1, h} + } + out = append(out, TextLine{lo - a, hi - a, transformRect(n.world, box)}) + } + return out, true +} diff --git a/ui/text_geometry_test.go b/ui/text_geometry_test.go new file mode 100644 index 000000000..f1f06fcaf --- /dev/null +++ b/ui/text_geometry_test.go @@ -0,0 +1,61 @@ +package ui + +import "testing" + +func TestCommittedInlineTextGeometryLifetime(t *testing.T) { + var h Handle + var ctx *Context + var g CommittedText + shown := true + tt := NewTester(func(c *Context) { + ctx = c + if _, ok := h.TextGeometry(c); ok { + t.Fatal("accepted uncommitted geometry") + } + if shown { + Text(c, "prefix ").Width(120).FontSize(20).Padding(7).Children(func() { + Text(c, "😀 alpha beta gamma").Bind(&h) + }) + } + c.AfterFrame(func() { + if shown { + var ok bool + g, ok = h.TextGeometry(c) + if !ok { + t.Fatal("missing committed inline text") + } + } + }) + }, 180, 200) + defer tt.rt.close() + tt.Frame() + if text, ok := g.Text(); !ok || text != "😀 alpha beta gamma" { + t.Fatalf("inline text: %q %v", text, ok) + } + r, ok := g.Caret(0) + if !ok || r.X <= 7 || r.H <= 0 { + t.Fatalf("lost paragraph prefix/padding: %+v", r) + } + if i, ok := g.IndexAt(Point{r.X, r.Y + r.H/2}); !ok || i != 0 { + t.Fatalf("caret hit: %d %v", i, ok) + } + if rects, ok := g.SelectionRects(0, 18); !ok || len(rects) < 2 { + t.Fatalf("wrapped inline selection: %+v %v", rects, ok) + } + if lines, ok := g.Lines(); !ok || len(lines) < 2 || lines[0].Start != 0 || lines[len(lines)-1].End != 18 { + t.Fatalf("visual lines: %+v %v", lines, ok) + } + old := g + tt.Frame() + if _, ok := old.Text(); ok { + t.Fatal("old frame remained valid") + } + shown = false + tt.Frame() + if _, ok := h.TextGeometry(ctx); ok { + t.Fatal("removed node remained valid") + } + if _, ok := g.Caret(0); ok { + t.Fatal("removed frame geometry remained valid") + } +} From 62757c2b5a308ecb33376ad5aa41b276df33f0ed Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 20:38:21 +0000 Subject: [PATCH 06/38] feat(ui): expose committed window input capture --- docs/input-capture.md | 37 +++++++++++++++++++++ ui/handler.go | 3 ++ ui/input.go | 42 ++++++++++++++++++++++++ ui/input_capture.go | 71 ++++++++++++++++++++++++++++++++++++++++ ui/input_capture_test.go | 49 +++++++++++++++++++++++++++ ui/runtime.go | 27 ++++++++++----- 6 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 docs/input-capture.md create mode 100644 ui/input_capture.go create mode 100644 ui/input_capture_test.go diff --git a/docs/input-capture.md b/docs/input-capture.md new file mode 100644 index 000000000..fb796c642 --- /dev/null +++ b/docs/input-capture.md @@ -0,0 +1,37 @@ +# Window input capture + +Install `Context.CaptureInput(func(InputEvent, InputTarget) bool)` once per view +build. It observes input before focused text clients, element handlers, shortcuts, +menus and native editing consume it. Returning true prevents native handling; +return false for observation. A build that omits registration removes it. The +last registration wins. Callbacks run on the UI thread, must not build UI or +enter a nested event loop, and must remain short. + +Keyboard, text, composition and native menu commands identify the committed +focused element. Pointer and scroll events identify the deepest committed hit +element, including non-interactive text children. `InputTarget.Distance(handle)` +reports zero for an exact binding, the number of ancestors for a containing +binding, and -1 otherwise. An input target expires on callback return; retain +application identity instead, and validate its incarnation before delayed use. +No geometry guessing or private engine access is needed. Pointer positions and +scroll deltas in capture use window DIPs, unlike local `HandleInput` coordinates. + +Capture reports raw pointer press/release/movement and `InputClick` when MyGo's +native hit/press/drag rules qualify a primary release as a click. The latter is +capture-only and includes the native click count. Click +recognition, focus, IME, editor commands and menus remain MyGo's responsibility +unless explicitly prevented. A pointer release prevented by capture cancels the +pending native press/drag so it cannot produce a click later. + +Native menu commands arrive as `InputCommand` (for example `copy`, `paste`, +`selectAll`, `undo`). Do not re-inject fabricated physical keys into the runtime. +A host may normalize a menu command to its existing document accelerator +vocabulary, but must return false unless its document explicitly prevents the +command's default action. The focused native text control then handles the +original command exactly once. Embedded editors may handle the command through +their own focused input handler. + +Capture does not synthesize key events on platforms where an input method +reports only committed/composing text, nor intercept OS-reserved shortcuts. +Applications forwarding document events must suppress duplicate forwarding from +their element-local handlers, not disable those handlers' native editing logic. diff --git a/ui/handler.go b/ui/handler.go index 12a6eb597..15d42ae66 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -52,6 +52,9 @@ const ( // with Ctrl held comes as InputZoom too, and a mouse wheel's notches // as InputScroll with Ctrl in Mods. InputZoom + // InputClick is emitted only to CaptureInput when a primary pointer release + // qualifies as a native click. Returning true suppresses native activation. + InputClick ) // InputEvent is input an element takes as it comes (HandleInput). diff --git a/ui/input.go b/ui/input.go index 08228f998..e5f4a670c 100644 --- a/ui/input.go +++ b/ui/input.go @@ -80,6 +80,9 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { key = physical } delete(rt.byPosition, physical) + if rt.captureInput(InputEvent{Kind: InputKeyUp, Mods: Modifiers(ev.Mods), Key: key, PhysicalKey: physical}, rt.focused) { + break + } if h := rt.focusHandler(); h != nil { rt.deliver(h, InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Mods: Modifiers(ev.Mods)}) } @@ -220,6 +223,10 @@ func (rt *engine) rehover() { } func (rt *engine) pointerMove(x, y float32) { + if (x != rt.pointerX || y != rt.pointerY || !rt.pointerIn) && rt.capturePointer(InputEvent{Kind: InputPointerMove, Button: -1, Mods: rt.mods}, x, y) { + rt.pointerX, rt.pointerY, rt.pointerIn = x, y, true + return + } if d := &rt.scrollDrag; d.st != nil { s := d.st g := d.bars(rt.c.theme.scrollbarWidth()) @@ -295,6 +302,9 @@ func (rt *engine) pointerMove(x, y float32) { const interactive = flagClickable | flagFocusable | flagEditable | flagSelectable | flagDragWindow | flagDraggable | flagTrackPointer func (rt *engine) pointerDown(x, y float32, button int, mods Modifiers, clicks int) { + if rt.capturePointer(InputEvent{Kind: InputPointerDown, Button: button, Clicks: max(1, clicks), Mods: mods}, x, y) { + return + } rt.downing++ defer rt.endDown() chain := rt.hitChain(x, y) @@ -427,6 +437,17 @@ func (rt *engine) pointerUp(button, clicks int) { if button == rt.pressButton { clicks = rt.pressClicks // the engine's, which may have started over } + if rt.capturePointer(InputEvent{Kind: InputPointerUp, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { + if rt.pressed != nil { + rt.pressed.pressed = false + } + rt.pressed = nil + rt.scrollDrag.st = nil + rt.selection.dragging = false + rt.dragCancel() + rt.requestFrame() + return + } if rt.scrollDrag.st != nil { rt.scrollDrag.st = nil // The pointer may have left the bar, over what the hover missed. @@ -445,6 +466,15 @@ func (rt *engine) pointerUp(button, clicks int) { if p := rt.pressed; p != nil && p.input != nil { rt.deliver(p, InputEvent{Kind: InputPointerUp, Button: button, Mods: rt.mods, Clicks: max(clicks, 1)}) } + if s := rt.pressed; s != nil && button == 0 && button == rt.pressButton && s.flags&flagDisabled == 0 && + rt.contains(s, Rect{s.x, s.y, s.w, s.h}, rt.pointerX, rt.pointerY) && !s.dragRec.dragged(rt, s) && + rt.capturePointer(InputEvent{Kind: InputClick, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { + rt.pressed = nil + s.pressed = false + rt.selection.dragging = false + rt.requestFrame() + return + } if button == 1 && rt.menuRelease() { return } @@ -483,6 +513,9 @@ func (rt *engine) pointerUp(button, clicks int) { } func (rt *engine) scroll(dx, dy float32, mods Modifiers, precise, momentum bool) { + if rt.capturePointer(InputEvent{Kind: InputScroll, DX: dx, DY: dy, Mods: mods, Precise: precise, Momentum: momentum}, rt.pointerX, rt.pointerY) { + return + } chain := rt.appendHitChain(rt.chain[:0], rt.pointerX, rt.pointerY) rt.chain = chain[:0] if h := rt.handler(chain); h != nil && rt.deliver(h, InputEvent{Kind: InputScroll, DX: dx, DY: dy, Mods: mods, Precise: precise, Momentum: momentum}) { @@ -691,6 +724,9 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool if rt.inspectKey(mods, key) { return true } + if rt.captureInput(InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat}, rt.focused) { + return true + } k := keyEvent{mods, key} if key == KeyEscape && mods == 0 && rt.dragCancel() { return true @@ -952,6 +988,12 @@ func (rt *engine) shortcut(id uint64, mods Modifiers, key Key) bool { } func (rt *engine) editEvent(ev editEvent) { + if rt.inputCapture != nil { + kind := map[editKind]InputKind{editInsert: InputText, editCompose: InputCompose, editCommand: InputCommand}[ev.kind] + if kind != 0 && rt.captureInput(InputEvent{Kind: kind, Text: ev.text, Caret: ev.caret, Mods: rt.mods}, rt.focused) { + return + } + } if s := rt.states[rt.focused]; s != nil && s.textClient != nil && rt.windowFocus() && s.flags&flagDisabled == 0 && (s.editor == nil || !s.editor.readOnly) { if ev.kind == editInsert || ev.kind == editCompose { var r *TextInputRange diff --git a/ui/input_capture.go b/ui/input_capture.go new file mode 100644 index 000000000..f3f030293 --- /dev/null +++ b/ui/input_capture.go @@ -0,0 +1,71 @@ +package ui + +import "weak" + +// InputTarget identifies the committed focused control (keyboard, text and +// commands) or deepest hit element (pointer and scroll). It is valid only +// during the CaptureInput callback; it does not retain a frame's elements. +type InputTarget struct { + owner weak.Pointer[engine] + id uint64 + serial uint64 +} + +// Distance returns the number of committed ancestors from the target to h, +// zero for the target itself, or -1 for an unrelated or expired handle. +func (t InputTarget) Distance(h Handle) int { + rt := t.owner.Value() + if rt == nil || t.serial == 0 || rt.capturing != t.serial { + return -1 + } + b := h.binding(rt, false) + if b == nil || b.closed || b.id == 0 { + return -1 + } + for id, distance := t.id, 0; id != 0; distance++ { + s := rt.states[id] + if s == nil { + return -1 + } + if id == b.id { + return distance + } + id = s.parent + } + return -1 +} + +// CaptureInput installs this build's window input capture callback. Call once +// per view build, on the UI thread. It runs before focused controls, shortcuts, +// menus and pointer handlers consume input; true prevents native handling. +// Return false to observe without interfering with native editing. Rebuilds +// that omit the registration remove it. The callback must not build UI or run +// a nested event loop. Events and pointer coordinates use window DIPs. +func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { + if raw := c.build(); raw != nil { + raw.rt.inputCapture = fn + } +} + +func (rt *engine) captureInput(ev InputEvent, id uint64) bool { + if rt.inputCapture == nil { + return false + } + rt.captureSerial++ + rt.capturing = rt.captureSerial + defer func() { rt.capturing = 0 }() + ev.Time = rt.frameTime() + return rt.inputCapture(ev, InputTarget{owner: rt.owner, id: id, serial: rt.capturing}) +} + +func (rt *engine) capturePointer(ev InputEvent, x, y float32) bool { + if rt.inputCapture == nil { + return false + } + var id uint64 + if chain := rt.hitChain(x, y); len(chain) != 0 { + id = chain[0] + } + ev.X, ev.Y = x, y + return rt.captureInput(ev, id) +} diff --git a/ui/input_capture_test.go b/ui/input_capture_test.go new file mode 100644 index 000000000..5c914866a --- /dev/null +++ b/ui/input_capture_test.go @@ -0,0 +1,49 @@ +package ui + +import "testing" + +func TestCaptureInputBeforeNativeConsumption(t *testing.T) { + var field Handle + var value string + var capture bool = true + var saved InputTarget + var keys int + tt := NewTester(func(c *Context) { + if capture { + c.CaptureInput(func(ev InputEvent, target InputTarget) bool { + if ev.Kind != InputKeyDown && ev.Kind != InputCommand { + return false + } + if target.Distance(field) != 0 { + t.Fatal("capture did not identify the focused native field") + } + saved = target + if ev.Kind == InputKeyDown { + keys++ + } + return ev.Kind == InputKeyDown && ev.Key == KeyBackspace + }) + } + TextInput(c, &value).Bind(&field).Label("Query").AutoFocus() + }, 300, 100) + tt.Frame() + tt.Type("search") + tt.Key(0, KeyBackspace) + if value != "search" || keys != 1 { + t.Fatalf("capture failed to prevent native deletion: %q, %d key events", value, keys) + } + if saved.Distance(field) != -1 { + t.Fatal("input target remained live outside its callback") + } + tt.Command("selectAll") + tt.Type("next") + if value != "next" { + t.Fatalf("capture swallowed native selectAll: %q", value) + } + capture = false + tt.Frame() + tt.Key(0, KeyBackspace) + if value != "nex" || keys != 1 { + t.Fatalf("removed capture still handled input: %q, %d key events", value, keys) + } +} diff --git a/ui/runtime.go b/ui/runtime.go index 484a9a8fc..cdfa571c2 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -240,6 +240,9 @@ type engine struct { frameAt time.Time pointerX, pointerY float32 + inputCapture func(InputEvent, InputTarget) bool + captureSerial uint64 + capturing uint64 pointerIn bool hover []uint64 // chain is a buffer for the elements under the pointer. @@ -534,6 +537,7 @@ func (rt *engine) runFrame() { rt.nextRegs = rt.nextRegs[:0] clear(rt.kept) rt.c.reset(now, appW, h) + rt.inputCapture = nil rt.view(&rt.c) if rt.closing { // The view closed the window, as a close button does: the @@ -904,6 +908,7 @@ func (rt *engine) armTimer() { // frame ends: the rest of the view builds with a Context that still works, // and reads the theme, the size and its state as before. func (rt *engine) close() { + rt.inputCapture = nil if rt.inFrame { rt.closing = true return @@ -1110,18 +1115,22 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { r, _ := e.clipRect() clip = intersect(clip, e.visualRect(r)) } - if ordered:=e.layeredChildren();ordered!=nil {for _,ch:=range ordered {rt.commitElement(ch,clip,invisible)}}else{ - // Children in flow first, absolute ones above them: the paint order. - for ch := e.first; ch != nil; ch = ch.next { - if ch.flags&flagAbsolute == 0 { + if ordered := e.layeredChildren(); ordered != nil { + for _, ch := range ordered { rt.commitElement(ch, clip, invisible) } - } - for ch := e.first; ch != nil; ch = ch.next { - if ch.flags&flagAbsolute != 0 { - rt.commitElement(ch, clip, invisible) + } else { + // Children in flow first, absolute ones above them: the paint order. + for ch := e.first; ch != nil; ch = ch.next { + if ch.flags&flagAbsolute == 0 { + rt.commitElement(ch, clip, invisible) + } + } + for ch := e.first; ch != nil; ch = ch.next { + if ch.flags&flagAbsolute != 0 { + rt.commitElement(ch, clip, invisible) + } } - } } e.setInk(vis) } From f3aa738d0ac2fe8629a35f7ef6ce6e22fc80a403 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 20:42:18 +0000 Subject: [PATCH 07/38] feat(ui): expose committed retained text geometry --- ui/runtime.go | 2 + ui/text_geometry.go | 214 +++++++++++++++++++++++++++++++++++++++ ui/text_geometry_test.go | 61 +++++++++++ 3 files changed, 277 insertions(+) create mode 100644 ui/text_geometry.go create mode 100644 ui/text_geometry_test.go diff --git a/ui/runtime.go b/ui/runtime.go index cdfa571c2..4dfea89fa 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -94,6 +94,7 @@ type engine struct { byPosition map[Key]bool owner weak.Pointer[engine] epoch uint64 + committedEpoch uint64 parts []any public *Context arena *elementOwner @@ -988,6 +989,7 @@ func (rt *engine) commit(root *node, w, h float32) { rt.inkStale = false rt.arrangeFocus() rt.noteGroups() + rt.committedEpoch = rt.epoch } func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { diff --git a/ui/text_geometry.go b/ui/text_geometry.go new file mode 100644 index 000000000..79676b44a --- /dev/null +++ b/ui/text_geometry.go @@ -0,0 +1,214 @@ +package ui + +import "github.com/egoist/mygo/internal/text" + +// CommittedSpace reads a control's committed border-box coordinate system. +// Like CommittedText it expires at the next build or window close, and never +// retains a build Context or Element. All methods run on the UI thread. +type CommittedSpace struct { + owner *elementOwner + epoch uint64 + slot uint32 + id uint64 +} + +// Coordinates captures a control's committed coordinate system after layout. +func (r Handle) Coordinates(c *Context) (CommittedSpace, bool) { + rt := c.runtime() + b := (&r).binding(rt, false) + if b == nil || b.closed || b.id == 0 || rt.committedEpoch != rt.epoch || b.element.owner != rt.arena || b.element.gen != rt.arena.generation { + return CommittedSpace{}, false + } + g := CommittedSpace{rt.arena, rt.epoch, b.element.slot, b.id} + _, ok := g.node() + return g, ok +} + +func (g CommittedSpace) node() (*node, bool) { + if g.owner == nil || g.owner.rt == nil { + return nil, false + } + rt := g.owner.rt + if rt.closed || rt.epoch != g.epoch || rt.committedEpoch != g.epoch { + return nil, false + } + n := rt.nodeAt(g.slot) + if n == nil || n.id != g.id { + return nil, false + } + for p := n; p != nil; p = p.parent { + if p.flags&(flagInvisible|flagInert) != 0 || p.backface && p.transform.facesAway() { + return nil, false + } + } + return n, true +} + +// Bounds returns the transformed border box in window DIPs. +func (g CommittedSpace) Bounds() (Rect, bool) { + n, ok := g.node() + if !ok { + return Rect{}, false + } + return transformRect(n.world, Rect{n.x, n.y, n.w, n.h}), true +} + +// LocalToWindow maps a border-box point to window DIPs. +func (g CommittedSpace) LocalToWindow(point Point) (Point, bool) { + n, ok := g.node() + if !ok { + return Point{}, false + } + x, y := n.world.Point(n.x+point.X, n.y+point.Y) + return Point{x, y}, true +} + +// WindowToLocal inverts LocalToWindow; singular transforms report false. +func (g CommittedSpace) WindowToLocal(point Point) (Point, bool) { + n, ok := g.node() + if !ok { + return Point{}, false + } + inv, ok := n.world.Inverse() + if !ok { + return Point{}, false + } + x, y := inv.Point(point.X, point.Y) + return Point{x - n.x, y - n.y}, true +} + +// CommittedText reads the exact shaped layout painted by a Text or RichText, +// including an inline child of a paragraph. Offsets are runes relative to the +// bound element's text, not the enclosing paragraph. Coordinates are window +// DIPs, including scrolling and transforms. Use only on the UI thread. +// +// A value expires when another build starts or its window closes. Every query +// reports false after expiry. It holds neither document state nor an Element. +type CommittedText struct { + space CommittedSpace +} + +// TextGeometry reads committed text after layout (for example in AfterFrame), +// or between frames. It reports false during an uncommitted build and for +// missing, hidden, non-text or closed controls. It never reshapes text. +func (r Handle) TextGeometry(c *Context) (CommittedText, bool) { + space, ok := r.Coordinates(c) + if !ok { + return CommittedText{}, false + } + g := CommittedText{space} + _, _, _, _, ok = g.layout() + return g, ok +} + +func (g CommittedText) layout() (n, para *node, start, end int, ok bool) { + n, ok = g.space.node() + if !ok || n.kind != kindText { + return n, nil, 0, 0, false + } + para = n + if n.isInline() { + start, end = n.runes[0], n.runes[1] + para = n.parent + for para.isInline() { + start, end = start+para.runes[0], end+para.runes[0] + para = para.parent + } + } else if n.tl != nil { + end = len(n.tl.Runes) + } + ok = para.tl != nil && start >= 0 && end >= start && end <= len(para.tl.Runes) + return +} + +// Text returns the displayed text, including nested inline children. +func (g CommittedText) Text() (string, bool) { + _, p, a, b, ok := g.layout() + if !ok { + return "", false + } + return string(p.tl.Runes[a:b]), true +} + +// Caret returns a whole-grapheme caret, with downstream affinity. +func (g CommittedText) Caret(index int) (Rect, bool) { + n, p, a, b, ok := g.layout() + if !ok || index < 0 || index > b-a { + return Rect{}, false + } + x, y, h := p.tl.CaretAt(text.CaretPosition{Index: a + index, Affinity: text.Downstream}) + return transformRect(n.world, Rect{p.x + p.contentX() + x, p.y + p.contentY() + y, 1, h}), true +} + +// SelectionRects preserves wrapped and bidi-discontiguous selections. +func (g CommittedText) SelectionRects(first, last int) ([]Rect, bool) { + n, p, a, b, ok := g.layout() + if !ok || first < 0 || last < first || last > b-a { + return nil, false + } + var out []Rect + for _, r := range p.tl.Selection(a+first, a+last) { + out = append(out, transformRect(n.world, Rect{p.x + p.contentX() + r.X, p.y + p.contentY() + r.Y, r.W, r.H})) + } + return out, true +} + +// IndexAt hit-tests a window point against the retained shaped glyphs. +func (g CommittedText) IndexAt(point Point) (int, bool) { + n, p, a, b, ok := g.layout() + if !ok { + return 0, false + } + inv, ok := n.world.Inverse() + if !ok { + return 0, false + } + x, y := inv.Point(point.X, point.Y) + i := p.tl.PositionAt(x-p.x-p.contentX(), y-p.y-p.contentY()).Index + return max(a, min(b, i)) - a, true +} + +// TextLine is a visual line's rune range and window-space box. Newline +// separators are excluded, as in the shaped layout's line table. +type TextLine struct { + Start, End int + Bounds Rect +} + +// Lines returns the visual lines intersecting this element's text range. +func (g CommittedText) Lines() ([]TextLine, bool) { + n, p, a, b, ok := g.layout() + if !ok { + return nil, false + } + var out []TextLine + for _, line := range p.tl.Lines { + if line.End < a || line.Start > b { + continue + } + if a != b && (line.End == a && line.Start != a || line.Start == b && line.End != b) { + continue + } + lo, hi := max(a, line.Start), min(b, line.End) + var box Rect + found := false + for _, r := range p.tl.Selection(lo, hi) { + if r.Y >= line.Y+line.Height || r.Y+r.H <= line.Y { + continue + } + f := Rect{p.x + p.contentX() + r.X, p.y + p.contentY() + r.Y, r.W, r.H} + if !found { + box = f + found = true + } else { + box = union(box, f) + } + } + if !found { + x, y, h := p.tl.CaretAt(text.CaretPosition{Index: lo, Affinity: text.Downstream}) + box = Rect{p.x + p.contentX() + x, p.y + p.contentY() + y, 1, h} + } + out = append(out, TextLine{lo - a, hi - a, transformRect(n.world, box)}) + } + return out, true +} diff --git a/ui/text_geometry_test.go b/ui/text_geometry_test.go new file mode 100644 index 000000000..f1f06fcaf --- /dev/null +++ b/ui/text_geometry_test.go @@ -0,0 +1,61 @@ +package ui + +import "testing" + +func TestCommittedInlineTextGeometryLifetime(t *testing.T) { + var h Handle + var ctx *Context + var g CommittedText + shown := true + tt := NewTester(func(c *Context) { + ctx = c + if _, ok := h.TextGeometry(c); ok { + t.Fatal("accepted uncommitted geometry") + } + if shown { + Text(c, "prefix ").Width(120).FontSize(20).Padding(7).Children(func() { + Text(c, "😀 alpha beta gamma").Bind(&h) + }) + } + c.AfterFrame(func() { + if shown { + var ok bool + g, ok = h.TextGeometry(c) + if !ok { + t.Fatal("missing committed inline text") + } + } + }) + }, 180, 200) + defer tt.rt.close() + tt.Frame() + if text, ok := g.Text(); !ok || text != "😀 alpha beta gamma" { + t.Fatalf("inline text: %q %v", text, ok) + } + r, ok := g.Caret(0) + if !ok || r.X <= 7 || r.H <= 0 { + t.Fatalf("lost paragraph prefix/padding: %+v", r) + } + if i, ok := g.IndexAt(Point{r.X, r.Y + r.H/2}); !ok || i != 0 { + t.Fatalf("caret hit: %d %v", i, ok) + } + if rects, ok := g.SelectionRects(0, 18); !ok || len(rects) < 2 { + t.Fatalf("wrapped inline selection: %+v %v", rects, ok) + } + if lines, ok := g.Lines(); !ok || len(lines) < 2 || lines[0].Start != 0 || lines[len(lines)-1].End != 18 { + t.Fatalf("visual lines: %+v %v", lines, ok) + } + old := g + tt.Frame() + if _, ok := old.Text(); ok { + t.Fatal("old frame remained valid") + } + shown = false + tt.Frame() + if _, ok := h.TextGeometry(ctx); ok { + t.Fatal("removed node remained valid") + } + if _, ok := g.Caret(0); ok { + t.Fatal("removed frame geometry remained valid") + } +} From 1ed4afbfcbba8fa17bdeed9af0a526f6dc62bddd Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 21:03:02 +0000 Subject: [PATCH 08/38] feat(ui): expose live native text input snapshots --- ui/frame.go | 3 +++ ui/text_snapshot.go | 26 ++++++++++++++++++ ui/text_snapshot_test.go | 57 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 ui/text_snapshot.go create mode 100644 ui/text_snapshot_test.go diff --git a/ui/frame.go b/ui/frame.go index c9ce14ee4..3b489ae7f 100644 --- a/ui/frame.go +++ b/ui/frame.go @@ -6,6 +6,9 @@ import "fmt" // Context is the window's stable UI context. Use it on the UI thread while // building a view. Children share it and temporarily change its parent. +// It may be retained for explicitly documented UI-thread handle queries, +// such as Handle.TextSnapshot, between builds. This does not permit building +// UI or retaining Elements in event callbacks. // Publish background results with Window.Update or Services.Invalidate. type Context struct { rt *engine diff --git a/ui/text_snapshot.go b/ui/text_snapshot.go new file mode 100644 index 000000000..ec03a69f1 --- /dev/null +++ b/ui/text_snapshot.go @@ -0,0 +1,26 @@ +package ui + +// TextSnapshot reads the live, already-consumed edit state of a committed +// TextInput or TextArea. Offsets count runes and preserve selection direction. +// The returned string is immutable; passwords are not redacted for this trusted +// host query. Marked/preedit text is excluded, as from the bound string value. +// +// Call on the UI thread, including between builds in CaptureInput, or after +// input in OnChange. The window-stable Context may be retained for this query; +// no Element may be retained. This reads the native editor, not the bound value +// or previous layout. It does not dispatch queued input or change selection. +// Missing, unbound, remounted-before-commit, non-input and closed controls return +// false. Bind on every build before querying from callbacks. +func (r Handle) TextSnapshot(c *Context) (text string, anchor, caret int, ok bool) { + rt := c.runtime() + b := (&r).binding(rt, false) + if b == nil || b.closed || b.id == 0 || b.element.owner != rt.arena || b.element.gen != rt.arena.generation { + return + } + n := rt.nodeAt(b.element.slot) + s := rt.states[b.id] + if n == nil || n.id != b.id || n.kind != kindInput || s == nil || n.st != s || s.seen != rt.frame || s.pass != rt.pass || s.flags&flagEditable == 0 || s.editor == nil || s.editor.document != nil { + return + } + return s.editor.String(), s.editor.anchor, s.editor.caret, true +} diff --git a/ui/text_snapshot_test.go b/ui/text_snapshot_test.go new file mode 100644 index 000000000..2a9d2698b --- /dev/null +++ b/ui/text_snapshot_test.go @@ -0,0 +1,57 @@ +package ui + +import "testing" + +func TestHandleLiveTextSnapshotLifetime(t *testing.T) { + var field Handle + var ctx *Context + value, shown, identity := "a😀b", true, 0 + var changed string + tt := NewTester(func(c *Context) { + ctx = c + if shown { + TextArea(c.Key(identity), &value).Bind(&field).AutoFocus().OnChange(func() { + text, _, _, ok := field.TextSnapshot(c) + if !ok { + t.Fatal("live input unavailable in OnChange") + } + changed = text + }) + } + }, 300, 120) + defer tt.rt.close() + tt.Frame() + tt.Key(Shift, KeyLeft) + if _, a, z, ok := field.TextSnapshot(ctx); !ok || a != 3 || z != 2 { + t.Fatalf("reversed rune selection: %d %d %v", a, z, ok) + } + b := field.binding(tt.rt, false) + client := tt.rt.states[b.id].editor.client + client.ReplaceText(nil, "x") + text, anchor, caret, ok := field.TextSnapshot(ctx) + if !ok || text != "a😀x" || anchor != 3 || caret != 3 || value != "a😀b" { + t.Fatalf("native snapshot before bound-value publication: %q %d %d %v (value %q)", text, anchor, caret, ok, value) + } + tt.Frame() + if changed != "a😀x" { + t.Fatalf("post-edit OnChange snapshot: %q", changed) + } + old := field + shown = false + tt.Frame() + if _, _, _, ok := old.TextSnapshot(ctx); ok { + t.Fatal("removed control remained readable") + } + field, value, shown, identity = Handle{}, "fresh", true, identity+1 + tt.Frame() + if _, _, _, ok := old.TextSnapshot(ctx); ok { + t.Fatal("retired handle aliased remounted input") + } + if text, _, _, ok := field.TextSnapshot(ctx); !ok || text != "fresh" { + t.Fatalf("remount: %q %v", text, ok) + } + tt.rt.close() + if _, _, _, ok := field.TextSnapshot(ctx); ok { + t.Fatal("closed input remained readable") + } +} From c79e8817dd54060486696e4b38f028c8fdc66ffe Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 23:07:57 +0000 Subject: [PATCH 09/38] fix(linux): preserve native glyph coverage under LCD settings Remove the LCD order from a copy of the matched font pattern for the grayscale pass. Keep the original Pango font for shaping and opaque subpixel rendering, and preserve color glyph detection. --- internal/text/pango_linux.go | 67 ++++++++++++++++++++++--------- internal/text/pango_linux_test.go | 40 ++++++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/internal/text/pango_linux.go b/internal/text/pango_linux.go index a1e9eef31..b428e5168 100644 --- a/internal/text/pango_linux.go +++ b/internal/text/pango_linux.go @@ -60,6 +60,7 @@ const ( cairoHintMetricsOn = 2 cairoStatusSuccess = 0 fcResultMatch = 0 + fcRGBANone = 5 fcMatchPattern = 0 fcLangEqual = 0 fcSetApplication = 1 @@ -131,6 +132,7 @@ var pangoLib struct { contextSetBaseDir func(context uintptr, dir int32) contextSetRoundPositions func(context uintptr, round int32) fontGetScaledFont func(font uintptr) uintptr + fontGetPattern func(font uintptr) uintptr fontGetMetrics func(font, language uintptr) uintptr fontDescribeAbsolute func(font uintptr) uintptr fontGetHBFont func(font uintptr) uintptr @@ -182,6 +184,8 @@ var pangoLib struct { cairoFontOptionsHintStyle func(options uintptr, style int32) cairoFontOptionsSubpixel func(options uintptr, order int32) cairoScaledFontFace func(font uintptr) uintptr + cairoFontFaceForPattern func(pattern uintptr) uintptr + cairoFontFaceDestroy func(face uintptr) cairoScaledFontMatrix func(font uintptr, m *cairoMatrix) cairoScaledFontOptions func(font, options uintptr) cairoScaledFontCreate func(face uintptr, fontMatrix, ctm *cairoMatrix, options uintptr) uintptr @@ -204,6 +208,9 @@ var pangoLib struct { fcConfigParseFromMemory func(config uintptr, xml string, complain int32) int32 fcPatternGetString func(pattern uintptr, object string, n int32, out **byte) int32 fcPatternCreate func() uintptr + fcPatternDuplicate func(pattern uintptr) uintptr + fcPatternDel func(pattern uintptr, object string) int32 + fcPatternAddInteger func(pattern uintptr, object string, value int32) int32 fcPatternDestroy func(pattern uintptr) fcPatternAddString func(pattern uintptr, object, s string) int32 fcPatternGetLangSet func(pattern uintptr, object string, n int32, out *uintptr) int32 @@ -245,6 +252,14 @@ func loadPango() error { if err != nil { return err } + ft, err := open("libpangoft2-1.0.so.0") + if err != nil { + return err + } + fc, err := open("libfontconfig.so.1") + if err != nil { + return err + } var missing []string bind := func(lib uintptr, fn any, name string) { sym, err := purego.Dlsym(lib, name) @@ -264,6 +279,11 @@ func loadPango() error { bind(pangocairo, &l.fontMapNew, "pango_cairo_font_map_new") bind(pangocairo, &l.contextSetFontOptions, "pango_cairo_context_set_font_options") bind(pangocairo, &l.fontGetScaledFont, "pango_cairo_font_get_scaled_font") + bind(ft, &l.fontGetPattern, "pango_fc_font_get_pattern") + bind(fc, &l.fcPatternDuplicate, "FcPatternDuplicate") + bind(fc, &l.fcPatternDestroy, "FcPatternDestroy") + bind(fc, &l.fcPatternDel, "FcPatternDel") + bind(fc, &l.fcPatternAddInteger, "FcPatternAddInteger") bind(pango, &l.fontMapCreateContext, "pango_font_map_create_context") bind(pango, &l.fontMapLoadFont, "pango_font_map_load_font") bind(pango, &l.contextSetBaseDir, "pango_context_set_base_dir") @@ -312,6 +332,8 @@ func loadPango() error { bind(cairo, &l.cairoFontOptionsHintStyle, "cairo_font_options_set_hint_style") bind(cairo, &l.cairoFontOptionsSubpixel, "cairo_font_options_set_subpixel_order") bind(cairo, &l.cairoScaledFontFace, "cairo_scaled_font_get_font_face") + bind(cairo, &l.cairoFontFaceForPattern, "cairo_ft_font_face_create_for_pattern") + bind(cairo, &l.cairoFontFaceDestroy, "cairo_font_face_destroy") bind(cairo, &l.cairoScaledFontMatrix, "cairo_scaled_font_get_font_matrix") bind(cairo, &l.cairoScaledFontOptions, "cairo_scaled_font_get_font_options") bind(cairo, &l.cairoScaledFontCreate, "cairo_scaled_font_create") @@ -341,24 +363,19 @@ func loadPango() error { bind(hb, &l.hbNominalGlyph, "hb_font_get_nominal_glyph") bind(hb, &l.hbGlyphHAdvance, "hb_font_get_glyph_h_advance") } - if ft, err := open("libpangoft2-1.0.so.0"); err == nil { - bind(ft, &l.fontMapConfigChanged, "pango_fc_font_map_config_changed") - } - if fc, err := open("libfontconfig.so.1"); err == nil { - bind(fc, &l.fcConfigGetCurrent, "FcConfigGetCurrent") - bind(fc, &l.fcConfigAppFontAddFile, "FcConfigAppFontAddFile") - bind(fc, &l.fcConfigGetFonts, "FcConfigGetFonts") - bind(fc, &l.fcConfigParseFromMemory, "FcConfigParseAndLoadFromMemory") - bind(fc, &l.fcPatternGetString, "FcPatternGetString") - bind(fc, &l.fcPatternCreate, "FcPatternCreate") - bind(fc, &l.fcPatternDestroy, "FcPatternDestroy") - bind(fc, &l.fcPatternAddString, "FcPatternAddString") - bind(fc, &l.fcPatternGetLangSet, "FcPatternGetLangSet") - bind(fc, &l.fcLangSetHasLang, "FcLangSetHasLang") - bind(fc, &l.fcConfigSubstitute, "FcConfigSubstitute") - bind(fc, &l.fcDefaultSubstitute, "FcDefaultSubstitute") - bind(fc, &l.fcFontMatch, "FcFontMatch") - } + bind(ft, &l.fontMapConfigChanged, "pango_fc_font_map_config_changed") + bind(fc, &l.fcConfigGetCurrent, "FcConfigGetCurrent") + bind(fc, &l.fcConfigAppFontAddFile, "FcConfigAppFontAddFile") + bind(fc, &l.fcConfigGetFonts, "FcConfigGetFonts") + bind(fc, &l.fcConfigParseFromMemory, "FcConfigParseAndLoadFromMemory") + bind(fc, &l.fcPatternGetString, "FcPatternGetString") + bind(fc, &l.fcPatternCreate, "FcPatternCreate") + bind(fc, &l.fcPatternAddString, "FcPatternAddString") + bind(fc, &l.fcPatternGetLangSet, "FcPatternGetLangSet") + bind(fc, &l.fcLangSetHasLang, "FcLangSetHasLang") + bind(fc, &l.fcConfigSubstitute, "FcConfigSubstitute") + bind(fc, &l.fcDefaultSubstitute, "FcDefaultSubstitute") + bind(fc, &l.fcFontMatch, "FcFontMatch") return nil } @@ -943,10 +960,20 @@ func (e *pangoEngine) scaledFont(f *Font, scale float32, subpixel bool) uintptr identity := cairoMatrix{xx: 1, yy: 1} opts := l.cairoFontOptionsCreate() l.cairoScaledFontOptions(src, opts) + face := l.cairoScaledFontFace(src) if !subpixel && e.antialias == cairoAntialiasSubpixel { l.cairoFontOptionsAntialias(opts, cairoAntialiasGray) - } - sf := l.cairoScaledFontCreate(l.cairoScaledFontFace(src), &m, &identity, opts) + // Cairo's FreeType face options override GRAY with the pattern's + // LCD order. Copy the matched face without LCD for the coverage + // pass, leaving Pango's font and the opaque subpixel pass intact. + pattern := l.fcPatternDuplicate(l.fontGetPattern(f.native)) + l.fcPatternDel(pattern, "rgba") + l.fcPatternAddInteger(pattern, "rgba", fcRGBANone) + face = l.cairoFontFaceForPattern(pattern) + l.fcPatternDestroy(pattern) + defer l.cairoFontFaceDestroy(face) + } + sf := l.cairoScaledFontCreate(face, &m, &identity, opts) l.cairoFontOptionsDestroy(opts) if sf == 0 || l.cairoScaledFontStatus(sf) != cairoStatusSuccess { return 0 diff --git a/internal/text/pango_linux_test.go b/internal/text/pango_linux_test.go index 35aa40b1d..91e7a1bd8 100644 --- a/internal/text/pango_linux_test.go +++ b/internal/text/pango_linux_test.go @@ -10,8 +10,48 @@ import ( "reflect" "sync" "testing" + + "golang.org/x/image/font/gofont/goregular" ) +func TestPangoGlyphCoverageWithSubpixelSettings(t *testing.T) { + e, err := newPango() + if err != nil { + t.Skip(err) + } + for _, order := range []string{"rgb", "bgr"} { + s := newSystem() + s.eng = e + if err := s.RegisterFont(goregular.TTF, "MyGo Test Glyph Coverage"); err != nil { + t.Fatal(err) + } + s.SetFontRendering("subpixel", "slight", order) + s.TextParams() + l := s.Layout(Params{Text: "O", Style: Style{Family: "MyGo Test Glyph Coverage", Size: 32}}) + g := l.Lines[0].Glyphs[0] + for _, opaque := range []bool{false, true} { + img := s.Glyph(g.Font, g.ID, 1, 0, 0, opaque) + if !img.OK || img.Colored || img.Subpixel != opaque { + t.Fatalf("%s opaque=%v: monochrome coverage is %+v", order, opaque, img) + } + atlas := s.MaskAtlas + if img.Subpixel { + atlas = s.ColorAtlas + } + ink := 0 + for y := range int(img.H) { + row := atlas.Pix[((int(img.Y)+y)*atlas.W+int(img.X))*atlas.BPP:] + for _, v := range row[:int(img.W)*atlas.BPP] { + ink += int(v) + } + } + if ink == 0 { + t.Errorf("%s opaque=%v: empty glyph coverage", order, opaque) + } + } + } +} + // Punctuation and full-width forms in a language that writes Han get the // family the language picks before the generic families (#595), and // nothing else does. From 257fcbf97b5dde7b2f065063aea000948a85d7df Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 23:27:55 +0000 Subject: [PATCH 10/38] feat(ui): inherit pointer hit policy with descendant opt-in --- docs/input-capture.md | 27 ++++++++++++++++++++++++ ui/element.go | 27 ++++++++++++------------ ui/layoutcache_test.go | 2 +- ui/pointer_events.go | 18 ++++++++++++++++ ui/pointer_events_test.go | 43 +++++++++++++++++++++++++++++++++++++++ ui/runtime.go | 15 ++++++++------ 6 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 ui/pointer_events.go create mode 100644 ui/pointer_events_test.go diff --git a/docs/input-capture.md b/docs/input-capture.md index fb796c642..1fa4248d5 100644 --- a/docs/input-capture.md +++ b/docs/input-capture.md @@ -1,5 +1,32 @@ # Window input capture +## Inherited pointer hit policy + +`Element.PointerEvents(false)` makes an element and its descendants transparent +to pointer hit testing, including native controls' internal labels. Unset +descendants inherit the policy; an explicit `PointerEvents(true)` re-enables a +subtree, for example an interactive popover inside a non-interactive overlay +layer. The root defaults to enabled. The policy is committed each frame and +does not change paint, layout, keyboard focus, accessibility or input capture +default-prevention. Ancestors still participate in event bubbling from an +enabled descendant. `Inert` and `Invisible` cannot be undone with this policy. + +This differs from `PassThrough`, which excludes only the element itself: + +```go +ui.Box(c).Fill().PointerEvents(false).Children(func() { + ui.Box(c).PointerEvents(true).Children(func() { + ui.Button(c, "Action").OnClick(action) + }) +}) +``` + +This native UI API is identical on Linux, macOS, Windows and wasm, adds no +backend calls or cgo, and is valid only during the element's current build +pass on the UI thread. Keep a `Handle`, not an `Element`, between frames. + +## Pre-dispatch capture + Install `Context.CaptureInput(func(InputEvent, InputTarget) bool)` once per view build. It observes input before focused text clients, element handlers, shortcuts, menus and native editing consume it. Returning true prevents native handling; diff --git a/ui/element.go b/ui/element.go index 808bcdafb..f835d874e 100644 --- a/ui/element.go +++ b/ui/element.go @@ -263,19 +263,20 @@ type node struct { fitting *fittingBuilder - epoch uint64 - key any - valueInput func(*node) - c *context - id uint64 - flags uint32 - parent *node - first *node - last *node - next *node - nchild int - depth int - st *state + epoch uint64 + key any + valueInput func(*node) + c *context + id uint64 + flags uint32 + pointerEvents int8 + parent *node + first *node + last *node + next *node + nchild int + depth int + st *state // track is the ScrollState of a scroll container (TrackScroll). track *ScrollState diff --git a/ui/layoutcache_test.go b/ui/layoutcache_test.go index 4520c1789..ec4f4d903 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -455,7 +455,7 @@ func TestLayoutInputs(t *testing.T) { "checked expanded vertical tip highlighted accValue accRange hasRange accFill accStep barInset scrollBase " + "listRow rowIndex rowsOf focusGroup groupSelects segment activeDescendant nameFrom nameJoin " + "setPos setSize choosesItems search description invalid followX sort level expandable " + - "chooseMany serial zIndex zIndexSet disabledOpacity" + "chooseMany serial zIndex zIndexSet disabledOpacity pointerEvents" known := map[string]bool{} for _, f := range strings.Fields(read + " " + other) { known[f] = true diff --git a/ui/pointer_events.go b/ui/pointer_events.go new file mode 100644 index 000000000..936fcc1c2 --- /dev/null +++ b/ui/pointer_events.go @@ -0,0 +1,18 @@ +package ui + +// PointerEvents controls pointer hit testing for this element and, by +// inheritance, its descendants. The default is inherited (enabled at the +// root). An explicit true opts a descendant back in below a false ancestor, +// including native controls' internal children. It does not change keyboard +// focus, paint, or layout, and does not override Inert or Invisible. +// Unlike PassThrough, false also suppresses descendants without an override. +// Call only during the element's current build pass, on the UI thread. +func (e Element) PointerEvents(enabled bool) Element { + if n := e.node(); n != nil { + n.pointerEvents = -1 + if enabled { + n.pointerEvents = 1 + } + } + return e +} diff --git a/ui/pointer_events_test.go b/ui/pointer_events_test.go new file mode 100644 index 000000000..3cc3c0893 --- /dev/null +++ b/ui/pointer_events_test.go @@ -0,0 +1,43 @@ +package ui + +import "testing" + +func TestPointerEventsInheritThroughNativeChildren(t *testing.T) { + under, blocked, opted := 0, 0, 0 + prevent, coverEnabled := false, false + tt := NewTester(func(c *Context) { + Button(c, "Under").Size(240, 40).OnClick(func() { under++ }) + Box(c).Absolute().Left(0).Top(0).Size(300, 200).PointerEvents(coverEnabled).Children(func() { + Button(c, "Covered caption").Size(240, 40).OnClick(func() { blocked++ }) + Button(c, "Opted in").Size(240, 40).PointerEvents(true).OnClick(func() { opted++ }) + }) + c.CaptureInput(func(ev InputEvent, _ InputTarget) bool { return prevent && ev.Kind == InputClick }) + }, 400, 240) + if err := tt.Click("Covered caption"); err != nil { + t.Fatal(err) + } + if under != 1 || blocked != 0 { + t.Fatalf("native caption leaked through inherited pointer suppression: under=%d blocked=%d", under, blocked) + } + if err := tt.Click("Opted in"); err != nil { + t.Fatal(err) + } + if opted != 1 { + t.Fatal("explicit descendant opt-in did not restore native activation") + } + prevent = true + if err := tt.Click("Covered caption"); err != nil { + t.Fatal(err) + } + if under != 1 || blocked != 0 { + t.Fatal("pointer policy bypassed capture default prevention") + } + prevent, coverEnabled = false, true + tt.Frame() + if err := tt.Click("Covered caption"); err != nil { + t.Fatal(err) + } + if blocked != 1 || under != 1 { + t.Fatalf("updated inherited policy did not commit: under=%d blocked=%d", under, blocked) + } +} diff --git a/ui/runtime.go b/ui/runtime.go index 4dfea89fa..d159545fe 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -985,14 +985,17 @@ func (rt *engine) commit(root *node, w, h float32) { rt.labels = rt.labels[:0] // The window, which the inspector shares with the root. full := Rect{0, 0, w, h} - rt.commitElement(root, full, false) + rt.commitElement(root, full, false, true) rt.inkStale = false rt.arrangeFocus() rt.noteGroups() rt.committedEpoch = rt.epoch } -func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { +func (rt *engine) commitElement(e *node, clip Rect, hidden, pointerEvents bool) { + if e.pointerEvents != 0 { + pointerEvents = e.pointerEvents > 0 + } inline := e.isInline() if e.kind == kindText && e.first != nil && !inline { placeInline(e, e, 0) @@ -1076,7 +1079,7 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { rt.maxButton = v } switch { - case e.flags&flagPassThrough != 0 || invisible: + case e.flags&flagPassThrough != 0 || invisible || !pointerEvents: case inline: // Inline elements take the pointer over their words. for _, r := range e.frags { @@ -1119,18 +1122,18 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { } if ordered := e.layeredChildren(); ordered != nil { for _, ch := range ordered { - rt.commitElement(ch, clip, invisible) + rt.commitElement(ch, clip, invisible, pointerEvents) } } else { // Children in flow first, absolute ones above them: the paint order. for ch := e.first; ch != nil; ch = ch.next { if ch.flags&flagAbsolute == 0 { - rt.commitElement(ch, clip, invisible) + rt.commitElement(ch, clip, invisible, pointerEvents) } } for ch := e.first; ch != nil; ch = ch.next { if ch.flags&flagAbsolute != 0 { - rt.commitElement(ch, clip, invisible) + rt.commitElement(ch, clip, invisible, pointerEvents) } } } From f98b3599175320868dc9b266d7b2c30b4563c886 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 00:03:44 +0000 Subject: [PATCH 11/38] fix(ui): capture clicks on non-interactive hit elements --- docs/input-capture.md | 19 ++++++++-- ui/handler.go | 3 +- ui/input.go | 16 +++++++- ui/input_capture.go | 69 +++++++++++++++++++++++++++++++++- ui/input_capture_test.go | 81 ++++++++++++++++++++++++++++++++++++++++ ui/runtime.go | 9 +++++ 6 files changed, 190 insertions(+), 7 deletions(-) diff --git a/docs/input-capture.md b/docs/input-capture.md index a7ff65f73..9df6b3e35 100644 --- a/docs/input-capture.md +++ b/docs/input-capture.md @@ -58,13 +58,26 @@ application identity instead, and validate its incarnation before delayed use. No geometry guessing or private engine access is needed. Pointer positions and scroll deltas in capture use window DIPs, unlike local `HandleInput` coordinates. -Capture reports raw pointer press/release/movement and `InputClick` when MyGo's -native hit/press/drag rules qualify a primary release as a click. The latter is -capture-only and includes the native click count. Click +Capture reports raw pointer press/release/movement and one `InputClick` when a +primary gesture qualifies as a click. Native controls keep their existing +press/drag rules. A plain hit without a native gesture also reports a +capture-only click: the same committed hit incarnation must survive to release, +remain enabled and hit-testable, and movement must stay below the native drag +threshold (4 DIPs), even if the pointer later returns. A prevented down/up, +window blur, omitted capture registration or retired hit cancels that candidate. +Plain hits do not acquire native press, focus or activation semantics. The +callback identifies the actual release hit in window DIPs and carries the +engine's click count; native controls still activate at most once. Click recognition, focus, IME, editor commands and menus remain MyGo's responsibility unless explicitly prevented. A pointer release prevented by capture cancels the pending native press/drag so it cannot produce a click later. +Hosts upgrading from interactive-only click capture should remove plain-node +click synthesis or placeholder `OnClick` handlers used solely to enable global +delegation. For example, leave `ui.Box(c).Bind(&article)` non-interactive and +observe `InputClick` with `target.Distance(article)` in the window callback. +Do not forward the same physical click again from an element-local handler. + Native menu commands arrive as `InputCommand` (for example `copy`, `paste`, `selectAll`, `undo`). Do not re-inject fabricated physical keys into the runtime. A host may normalize a menu command to its existing document accelerator diff --git a/ui/handler.go b/ui/handler.go index 15d42ae66..6aa693475 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -53,7 +53,8 @@ const ( // as InputScroll with Ctrl in Mods. InputZoom // InputClick is emitted only to CaptureInput when a primary pointer release - // qualifies as a native click. Returning true suppresses native activation. + // qualifies as a click, including on a plain hit with no native gesture. + // Returning true suppresses any native activation. InputClick ) diff --git a/ui/input.go b/ui/input.go index e5f4a670c..6ab77ccb9 100644 --- a/ui/input.go +++ b/ui/input.go @@ -98,6 +98,9 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { rt.focusChanged() case platform.SurfaceBlur: rt.windowFocused = false + if rt.captureClicks != nil { + rt.captureClicks.id = 0 + } rt.selection.dragging = false // Keys let go of elsewhere never come back up here. rt.modsChanged(0) @@ -362,6 +365,9 @@ func (rt *engine) pointerDown(x, y float32, button int, mods Modifiers, clicks i } rt.requestFrame() if target == nil { + if rt.captureClicks != nil { + rt.captureClicks.plain, rt.captureClicks.clicks = true, clicks + } return } if button == 0 && target.flags&flagMenuButton != 0 && target.flags&flagDisabled == 0 { @@ -434,7 +440,7 @@ func (rt *engine) pointerUp(button, clicks int) { rt.ups = append(rt.ups, [2]int{button, clicks}) return } - if button == rt.pressButton { + if rt.pressed != nil && button == rt.pressButton { clicks = rt.pressClicks // the engine's, which may have started over } if rt.capturePointer(InputEvent{Kind: InputPointerUp, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { @@ -479,7 +485,13 @@ func (rt *engine) pointerUp(button, clicks int) { return } s := rt.pressed - if s == nil || button != rt.pressButton { + if s == nil { + if rt.releaseCapture != nil { + rt.releaseCapture(button) + } + return + } + if button != rt.pressButton { return } if rt.selection.dragging { diff --git a/ui/input_capture.go b/ui/input_capture.go index f3f030293..eb8195e49 100644 --- a/ui/input_capture.go +++ b/ui/input_capture.go @@ -43,7 +43,74 @@ func (t InputTarget) Distance(h Handle) int { // a nested event loop. Events and pointer coordinates use window DIPs. func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { if raw := c.build(); raw != nil { - raw.rt.inputCapture = fn + rt := raw.rt + if fn == nil { + rt.inputCapture, rt.captureClicks, rt.releaseCapture = nil, nil, nil + return + } + if rt.captureClicks == nil { + rt.captureClicks = &inputCaptureState{rt: rt} + rt.releaseCapture = rt.captureClicks.release + } + rt.captureClicks.fn = fn + rt.inputCapture = rt.captureClicks.input + } +} + +// inputCaptureState belongs only to windows that register capture. Plain hits +// have no native press: track their gesture without making them controls. +type inputCaptureState struct { + rt *engine + fn func(InputEvent, InputTarget) bool + id, born uint64 + x, y float32 + clicks int + plain, released bool +} + +func (s *inputCaptureState) input(ev InputEvent, target InputTarget) bool { + if ev.Kind == InputPointerMove && s.id != 0 { + dx, dy := ev.X-s.x, ev.Y-s.y + if dx*dx+dy*dy >= dragStart*dragStart { + s.id = 0 + } + } + prevented := s.fn(ev, target) + switch ev.Kind { + case InputPointerDown: + if ev.Button == 0 { + s.id, s.plain, s.released = 0, false, false + if hit := s.rt.states[target.id]; !prevented && hit != nil && hit.flags&flagDisabled == 0 { + s.id, s.born, s.x, s.y = hit.id, hit.born, ev.X, ev.Y + } + } + case InputPointerUp: + if ev.Button == 0 { + s.released = !prevented + if prevented { + s.id = 0 + } + } + case InputClick: + s.id = 0 + } + return prevented +} + +func (s *inputCaptureState) release(button int) { + if button != 0 { + return + } + id := s.id + s.id = 0 + rt := s.rt + hit := rt.states[id] + if !s.plain || !s.released || hit == nil || hit.born != s.born || hit.flags&flagDisabled != 0 { + return + } + chain := rt.hitChain(rt.pointerX, rt.pointerY) + if len(chain) != 0 && chain[0] == id { + rt.captureInput(InputEvent{Kind: InputClick, Button: button, Clicks: max(1, s.clicks), Mods: rt.mods, X: rt.pointerX, Y: rt.pointerY}, id) } } diff --git a/ui/input_capture_test.go b/ui/input_capture_test.go index 5c914866a..e921495ad 100644 --- a/ui/input_capture_test.go +++ b/ui/input_capture_test.go @@ -2,6 +2,87 @@ package ui import "testing" +func TestCaptureClickOnPlainHit(t *testing.T) { + for _, tc := range []struct { + name string + native bool + prevent InputKind + action string + clicks int + presses int + }{ + {name: "plain", clicks: 1}, + {name: "native", native: true, clicks: 1, presses: 1}, + {name: "prevent native click", native: true, prevent: InputClick, clicks: 1}, + {name: "prevent down", prevent: InputPointerDown}, + {name: "prevent up", prevent: InputPointerUp}, + {name: "prevent plain click", prevent: InputClick, clicks: 1}, + {name: "drag and return", action: "drag"}, + {name: "disabled", action: "disabled"}, + {name: "retired", action: "retired"}, + {name: "replaced", action: "replaced"}, + {name: "pointer disabled", action: "pointer"}, + {name: "capture removed", action: "capture"}, + {name: "window blur", action: "blur"}, + } { + t.Run(tc.name, func(t *testing.T) { + var hit Handle + clicks, presses := 0, 0 + present, capture, enabled, disabled, key := true, true, true, false, "original" + tt := NewTester(func(c *Context) { + if capture { + c.CaptureInput(func(ev InputEvent, target InputTarget) bool { + if ev.Kind == InputClick { + clicks++ + if target.Distance(hit) < 0 || !tc.native && target.Distance(hit) != 0 || ev.X != 50 || ev.Y != 14 || ev.Clicks != 1 { + t.Fatalf("click has stale target or wrong window coordinates/count: %+v", ev) + } + } + return ev.Kind == tc.prevent + }) + } + if !present { + return + } + var e Element + if tc.native { + e = Button(c, "Action").OnClick(func() { presses++ }) + } else { + e = Box(c) + } + e.Key(key).Size(100, 28).Bind(&hit).Disabled(disabled).PointerEvents(enabled) + }, 200, 60) + tt.Press(50, 14) + switch tc.action { + case "drag": + tt.Move(70, 14) + tt.Move(50, 14) + case "disabled": + disabled = true + case "retired": + present = false + tt.Frame() + present = true + case "replaced": + key = "replacement" + case "pointer": + enabled = false + case "capture": + capture = false + tt.Frame() + capture = true + case "blur": + tt.SetFocused(false) + } + tt.Frame() + tt.Release(50, 14) + if clicks != tc.clicks || presses != tc.presses { + t.Fatalf("capture clicks/native presses = %d/%d, want %d/%d", clicks, presses, tc.clicks, tc.presses) + } + }) + } +} + func TestCaptureInputBeforeNativeConsumption(t *testing.T) { var field Handle var value string diff --git a/ui/runtime.go b/ui/runtime.go index 83fd60480..7867aacc1 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -243,6 +243,8 @@ type engine struct { pointerX, pointerY float32 inputCapture func(InputEvent, InputTarget) bool + captureClicks *inputCaptureState + releaseCapture func(button int) captureSerial uint64 capturing uint64 pointerIn bool @@ -815,6 +817,9 @@ func (rt *engine) forgetInput() { // prune forgets the elements the frame did not build, but those of the // pages Routers keep. func (rt *engine) prune() { + if rt.inputCapture == nil { + rt.captureClicks, rt.releaseCapture = nil, nil + } if d := rt.drag; d != nil && d.native { if s := rt.states[d.src]; s == nil || s.seen != rt.frame || s.pass != rt.pass { rt.host.cancelDataDrag() @@ -822,6 +827,9 @@ func (rt *engine) prune() { } for id, s := range rt.states { if s.seen != rt.frame || s.pass != rt.pass { + if rt.captureClicks != nil && rt.captureClicks.id == id { + rt.captureClicks.id = 0 + } if rt.pressed == s { rt.pressed = nil } @@ -911,6 +919,7 @@ func (rt *engine) armTimer() { // and reads the theme, the size and its state as before. func (rt *engine) close() { rt.inputCapture = nil + rt.captureClicks, rt.releaseCapture = nil, nil if rt.inFrame { rt.closing = true return From 593befbe65d98d9700de5743240681355b9b1fc8 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 00:31:29 +0000 Subject: [PATCH 12/38] fix(ui): isolate inspector input from application capture --- docs/input-capture.md | 11 +++++++++ ui/input_capture.go | 9 ++++++- ui/inspector.go | 30 +++++++++++++++++++++++ ui/inspector_off.go | 1 + ui/inspector_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/docs/input-capture.md b/docs/input-capture.md index 9df6b3e35..18a32d0b9 100644 --- a/docs/input-capture.md +++ b/docs/input-capture.md @@ -58,6 +58,17 @@ application identity instead, and validate its incarnation before delayed use. No geometry guessing or private engine access is needed. Pointer positions and scroll deltas in capture use window DIPs, unlike local `HandleInput` coordinates. +Capture is for application input, not the built-in inspector. Keyboard release +and press, text, composition and menu commands focused in the inspector never +reach application capture. Pointer/scroll targets in its committed subtree are +also excluded; a native inspector press retains this isolation while moving or +releasing outside the panel. The check uses committed ownership, not coordinates +or application labels. Inspector controls still receive their normal native +input, and application capture/default-prevention continues while it is open. +An inspector event cancels any pending plain-hit capture click candidate. +Hosts must not use inspector input as document shortcuts or editing commands; +remove any such forwarding. No host-side handler or wire change is needed. + Capture reports raw pointer press/release/movement and one `InputClick` when a primary gesture qualifies as a click. Native controls keep their existing press/drag rules. A plain hit without a native gesture also reports a diff --git a/ui/input_capture.go b/ui/input_capture.go index eb8195e49..b193166ef 100644 --- a/ui/input_capture.go +++ b/ui/input_capture.go @@ -40,7 +40,8 @@ func (t InputTarget) Distance(h Handle) int { // menus and pointer handlers consume input; true prevents native handling. // Return false to observe without interfering with native editing. Rebuilds // that omit the registration remove it. The callback must not build UI or run -// a nested event loop. Events and pointer coordinates use window DIPs. +// a nested event loop. Inspector-owned input is private to developer tools. +// Events and pointer coordinates use window DIPs. func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { if raw := c.build(); raw != nil { rt := raw.rt @@ -118,6 +119,12 @@ func (rt *engine) captureInput(ev InputEvent, id uint64) bool { if rt.inputCapture == nil { return false } + if rt.insp.ownsInput(rt, ev, id) { + if rt.captureClicks != nil { + rt.captureClicks.id = 0 + } + return false + } rt.captureSerial++ rt.capturing = rt.captureSerial defer func() { rt.capturing = 0 }() diff --git a/ui/inspector.go b/ui/inspector.go index 476dfde68..473dfdcc0 100644 --- a/ui/inspector.go +++ b/ui/inspector.go @@ -236,6 +236,36 @@ func (rt *engine) inspectKey(mods Modifiers, key Key) bool { return false } +func (in *inspector) ownsInput(rt *engine, ev InputEvent, id uint64) bool { + if in.panel == 0 { + return false + } + if in.contains(rt, id) { + return true + } + switch ev.Kind { + case InputPointerMove, InputPointerUp, InputClick: + if rt.pressed != nil { + return in.contains(rt, rt.pressed.id) + } + } + return false +} + +func (in *inspector) contains(rt *engine, id uint64) bool { + for id != 0 { + if id == in.panel { + return true + } + s := rt.states[id] + if s == nil { + break + } + id = s.parent + } + return false +} + // noteSource notes where the app builds the element chosen. func (in *inspector) noteSource() { in.source = callSite() } diff --git a/ui/inspector_off.go b/ui/inspector_off.go index 665def5ea..773eb161a 100644 --- a/ui/inspector_off.go +++ b/ui/inspector_off.go @@ -26,4 +26,5 @@ func (in *inspector) snapshot(*engine, *node) func (in *inspector) paintHighlight(*engine, *Painter, float32) {} func (rt *engine) toggleInspector() {} func (rt *engine) inspectKey(Modifiers, Key) bool { return false } +func (in *inspector) ownsInput(*engine, InputEvent, uint64) bool { return false } func (rt *engine) buildInspector(*context, float32, float32, float32) {} diff --git a/ui/inspector_test.go b/ui/inspector_test.go index cfcd6987a..240344d5b 100644 --- a/ui/inspector_test.go +++ b/ui/inspector_test.go @@ -35,6 +35,63 @@ func openInspector(tt *Tester) { tt.Frame() } +func TestInspectorInputIsolatedFromCapture(t *testing.T) { + var appField Handle + appText, prevent := "app content", false + var captured []InputKind + tt := NewTester(func(c *Context) { + c.CaptureInput(func(ev InputEvent, _ InputTarget) bool { + if prevent { + captured = append(captured, ev.Kind) + } + return prevent + }) + TextInput(c, &appText).Bind(&appField).Label("Application field") + Text(c, "Capture marker") + }, 1000, 600) + tt.rt.insp.enabled = true + tt.Key(0, KeyF12) + if err := tt.Click("Capture marker"); err != nil { + t.Fatal(err) + } + tt.Key(Cmd, KeyF) + tt.Type("needle") + if !tt.rt.insp.finding || tt.rt.insp.query != "needle" { + t.Fatal("precondition: inspector search must own text focus") + } + prevent = true + search := tt.rt.states[tt.rt.focused] + x, y := search.x+search.w/2, search.y+search.h/2 + tt.ClickAt(x, y) + app, ok := tt.Find("Application field") + if !ok { + t.Fatal("application field is missing") + } + tt.Press(x, y) + tt.Move(app.X+app.W/2, app.Y+app.H/2) + tt.Release(app.X+app.W/2, app.Y+app.H/2) + tt.Scroll(x, y, 0, 3) + tt.Key(Cmd, KeyA) + tt.Type("replacement") + if tt.rt.insp.query != "replacement" { + t.Errorf("application capture prevented inspector replacement: %q", tt.rt.insp.query) + } + tt.Compose("marked", 3) + tt.Compose("", 0) + tt.Command("selectAll") + tt.Key(0, KeyBackspace) + if tt.rt.insp.query != "" || len(captured) != 0 { + t.Errorf("inspector input escaped to application capture: kinds=%v, query=%q", captured, tt.rt.insp.query) + } + captured = nil + appField.Focus() + tt.Frame() + tt.Type("blocked") + if len(captured) != 1 || captured[0] != InputText || appText != "app content" { + t.Fatalf("ordinary application capture/prevention changed: kinds=%v, text=%q", captured, appText) + } +} + func TestInspectorOpensAndCloses(t *testing.T) { tt, width := inspected(func(c *context) { coreColumn(c).Padding(20).Children(func() { coreText(c, "Hello") }) From 33da0b36a6b6501253afc63c4fb937feb9cd7ab5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 00:54:07 +0000 Subject: [PATCH 13/38] fix(ui): commit deferred handle identities after layout --- docs/architecture.md | 7 +++++ ui/element.go | 1 + ui/layoutcache_test.go | 2 +- ui/lui_extensions_test.go | 55 +++++++++++++++++++++++++++++++++++++++ ui/ref.go | 14 ++++++++++ ui/runtime.go | 5 ++++ 6 files changed, 83 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 18066c71f..142026297 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1969,6 +1969,13 @@ persistent control identity separately for each window. Focus queries read identity without depending on construction order; focus requests wait for a hidden control. `Services` offers persistent clipboard, URL and redraw access. +Handle identities are finalized from the actual committed tree after layout, +including children built by `Fitting` and virtual lists. `AfterFrame` can read +their `Bounds` and `FocusWithin` without replaying input, actions or focus +requests. Omitted bindings lose their identity even when a same-key element +continues or is recreated; isolated measurement probes never publish bindings +to the real window. + Constructors build and style controls eagerly. `Context.Key` supplies an ID before state initialization. A control takes its input as the public constructor that built it returns, as Dear ImGui's widgets do, so what it diff --git a/ui/element.go b/ui/element.go index f835d874e..c40dd35af 100644 --- a/ui/element.go +++ b/ui/element.go @@ -270,6 +270,7 @@ type node struct { id uint64 flags uint32 pointerEvents int8 + committed bool parent *node first *node last *node diff --git a/ui/layoutcache_test.go b/ui/layoutcache_test.go index ec4f4d903..65cddc6dd 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -455,7 +455,7 @@ func TestLayoutInputs(t *testing.T) { "checked expanded vertical tip highlighted accValue accRange hasRange accFill accStep barInset scrollBase " + "listRow rowIndex rowsOf focusGroup groupSelects segment activeDescendant nameFrom nameJoin " + "setPos setSize choosesItems search description invalid followX sort level expandable " + - "chooseMany serial zIndex zIndexSet disabledOpacity pointerEvents" + "chooseMany serial zIndex zIndexSet disabledOpacity pointerEvents committed" known := map[string]bool{} for _, f := range strings.Fields(read + " " + other) { known[f] = true diff --git a/ui/lui_extensions_test.go b/ui/lui_extensions_test.go index ab450554f..b107b972a 100644 --- a/ui/lui_extensions_test.go +++ b/ui/lui_extensions_test.go @@ -8,6 +8,61 @@ type measurementContext interface { AfterFrame(func()) } +func TestHandleIdentityAfterDeferredLayout(t *testing.T) { + for _, kind := range []string{"fitting", "virtual"} { + t.Run(kind, func(t *testing.T) { + var current, retired, probe Handle + var list ListState + list.SetOverscan(0) + shown, bound := true, true + var bounds, oldBounds, probeBounds Rect + var focused bool + tt := NewTester(func(c *Context) { + c.AfterFrame(func() { + bounds, oldBounds, probeBounds = current.Bounds(c), retired.Bounds(c), probe.Bounds(c) + focused = current.FocusWithin(c) + }) + build := func(actual *Context) { + if !shown { + return + } + box := Box(actual.Key("target")).Size(200, 40) + if bound { + box.Bind(¤t) + } + box.Children(func() { Button(actual, "Focus").Focus() }) + } + if kind == "virtual" { + List(c, &list, 1, func(int) { build(c) }).Size(200, 120) + } else { + c.Fitting(false, []func(*Context){ + func(p *Context) { Box(p).Size(180, 30).Bind(&probe) }, + }, func(actual *Context, _ int) { build(actual) }).Size(200, 120) + } + }, 200, 120) + if bounds.W != 200 || bounds.H != 40 || !focused || probeBounds != (Rect{}) { + t.Fatalf("deferred committed identity: bounds=%+v focused=%v probe=%+v", bounds, focused, probeBounds) + } + bound = false + tt.Frame() + if bounds != (Rect{}) || focused { + t.Fatalf("omitted binding kept a live identity: bounds=%+v focused=%v", bounds, focused) + } + retired, current = current, Handle{} + shown = false + tt.Frame() + if oldBounds != (Rect{}) { + t.Fatalf("retired binding kept bounds: %+v", oldBounds) + } + shown, bound = true, true + tt.Frame() + if bounds.W != 200 || bounds.H != 40 || !focused || oldBounds != (Rect{}) { + t.Fatalf("recreated identity: bounds=%+v focused=%v retired=%+v", bounds, focused, oldBounds) + } + }) + } +} + func TestLUIFittingMeasuresFinalLocalRoomWithoutActivatingProbes(t *testing.T) { measured, selected, committed := 0, -1, 0 var failure string diff --git a/ui/ref.go b/ui/ref.go index 148ca1f25..7f8bd8ec7 100644 --- a/ui/ref.go +++ b/ui/ref.go @@ -123,6 +123,7 @@ func (e Element) Bind(r *Handle) Element { return e } b := r.binding(n.c.rt, true) + n.c.rt.commitRefs = commitHandleBindings if b.element.Valid() && b.element != e { panic("ui: Handle bound to two controls in one build") } @@ -214,6 +215,19 @@ func (rt *engine) applyFocusRequests() { rt.applyFocusBindings() } +func commitHandleBindings(rt *engine) { + for _, ref := range rt.refs { + b := ref.Value() + if b == nil || b.closed { + continue + } + b.id = 0 + if n := b.element.lookup(); n != nil && n.c.rt == rt && n.committed { + b.id = n.id + } + } +} + // Bounds returns the control's box in the committed frame, in window DIPs. // It works between builds on the UI thread; hidden or closed controls return // an empty box. Use this instead of retaining an Element in input callbacks. diff --git a/ui/runtime.go b/ui/runtime.go index 7867aacc1..47c0af4d2 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -109,6 +109,7 @@ type engine struct { focusFields map[any]*focusField refs []weak.Pointer[refState] boundRefs []*refState + commitRefs func(*engine) actions []action textInputClosed bool view func(*context) @@ -580,6 +581,9 @@ func (rt *engine) runFrame() { root := rt.c.root layoutTree(root, appW, h) rt.commit(root, w, h) + if rt.commitRefs != nil { + rt.commitRefs(rt) + } // Scrolling can put a new native drop target under a stationary // pointer. Resolve it against this frame's geometry, then rebuild if // needed so DragOver/DataDragOver show the new target too. @@ -1007,6 +1011,7 @@ func (rt *engine) commit(root *node, w, h float32) { } func (rt *engine) commitElement(e *node, clip Rect, hidden, pointerEvents bool) { + e.committed = true if e.pointerEvents != 0 { pointerEvents = e.pointerEvents > 0 } From 183f6afcb33a1c13c84fe7a49eb72721f178c695 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 26 Oct 2026 01:22:09 +0000 Subject: [PATCH 14/38] feat(ui): expose committed menu and scroll observations --- docs/architecture.md | 13 +++++++++++++ ui/lui_extensions_test.go | 25 +++++++++++++++++++++++++ ui/lui_menu.go | 13 +++++++++++++ ui/scroll.go | 16 ++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 142026297..1a4e9ea50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1976,6 +1976,19 @@ requests. Omitted bindings lose their identity even when a same-key element continues or is recreated; isolated measurement probes never publish bindings to the real window. +`Context.NativeMenuOpen(owner)` reads the native menu presentation state for +one committed handle, including a menu queued by the committed frame before +the host presents it. It becomes false on selection or cancellation without +waiting for `OnMenuDismiss`. Native menu builders also run to deliver a chosen +item, so builder execution alone does not mean that a menu remains open. This +query does not change input behavior or observe retained in-window popovers. + +`Handle.ScrollOffset(c)` reads the actual committed offset of any bound scroll +host, including an ordinary `Box.ScrollAxes` without `TrackScroll`. Its axes +match `ScrollState`. A non-scroller, hidden or expired binding returns +`ok == false`, not an invented zero offset. Unapplied `ScrollState` requests +remain distinct from committed position; no scrolling commands are changed. + Constructors build and style controls eagerly. `Context.Key` supplies an ID before state initialization. A control takes its input as the public constructor that built it returns, as Dear ImGui's widgets do, so what it diff --git a/ui/lui_extensions_test.go b/ui/lui_extensions_test.go index b107b972a..57bcb5565 100644 --- a/ui/lui_extensions_test.go +++ b/ui/lui_extensions_test.go @@ -119,6 +119,31 @@ func TestLUIMeasureUsesActualTypographyAndDoesNotCommitFocus(t *testing.T) { } } +func TestHandleReadsCommittedScrollOffset(t *testing.T) { + var handle Handle + var offset Point + var known bool + scrolling := true + tt := NewTester(func(c *Context) { + c.AfterFrame(func() { offset.X, offset.Y, known = handle.ScrollOffset(c) }) + Box(c).Size(100, 60).ScrollAxes(scrolling, scrolling).Bind(&handle).Children(func() { + Box(c).Size(500, 700) + }) + }, 150, 120) + if !known || offset != (Point{}) { + t.Fatalf("committed untracked scroll host missing: offset=%+v known=%v", offset, known) + } + tt.Scroll(20, 20, 35, 50) + if !known || offset.X <= 0 || offset.Y <= 0 { + t.Fatalf("real wheel offset not observed: offset=%+v known=%v", offset, known) + } + scrolling = false + tt.Frame() + if known { + t.Fatal("ordinary bound non-scroller reported a known scroll offset") + } +} + func TestLUIListTuningControlsActualRowsBuilt(t *testing.T) { for _, overscan := range []int{0, 8} { var state ListState diff --git a/ui/lui_menu.go b/ui/lui_menu.go index b010cd401..18825dc94 100644 --- a/ui/lui_menu.go +++ b/ui/lui_menu.go @@ -5,6 +5,19 @@ import ( "image/color" ) +// NativeMenuOpen reports whether owner has a native menu presented or queued +// by the committed frame. Query after layout or between frames on the UI +// thread. Hidden, omitted and expired bindings return false. This does not +// observe retained in-window menus or popovers. +func (c *Context) NativeMenuOpen(owner Handle) bool { + space, ok := owner.Coordinates(c) + if !ok { + return false + } + menu := &c.runtime().menu + return menu.active != nil && menu.active.id == space.id || menu.pending != nil && menu.pending.id == space.id +} + // OnMenuDismiss observes a native menu closing, after its chosen item has // been delivered to ContextMenu or Menu. Cancellation also closes a menu. // The latest observer runs after the final frame, only while its host still diff --git a/ui/scroll.go b/ui/scroll.go index b240f1f1d..89e5c0471 100644 --- a/ui/scroll.go +++ b/ui/scroll.go @@ -2,6 +2,22 @@ package ui import "slices" +// ScrollOffset reads a committed scroll host's actual offset in DIPs, using +// the same axes as ScrollState. Non-scrollers and expired bindings report +// false. Query after layout or between frames on the UI thread; unapplied +// ScrollState requests are not committed offsets. +func (r Handle) ScrollOffset(c *Context) (x, y float32, ok bool) { + space, ok := r.Coordinates(c) + if !ok { + return 0, 0, false + } + n, ok := space.node() + if !ok || n.flags&(flagScrollX|flagScrollY) == 0 || n.st == nil { + return 0, 0, false + } + return float32(n.st.scrollX), float32(n.st.scrollY), true +} + // ScrollState is how far a scroll container scrolls its content, kept in // the app's state with TrackScroll. type ScrollState struct { From 11ecc0c0f1d43c7cf837dd801425be638ffc6014 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 27 Oct 2026 02:10:41 +0000 Subject: [PATCH 15/38] fix(ui): preserve captured clicks after prevented pointer down --- docs/input-capture.md | 12 +++++++++++- ui/input_capture.go | 7 +++++-- ui/input_capture_test.go | 11 ++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/input-capture.md b/docs/input-capture.md index 18a32d0b9..fae289f85 100644 --- a/docs/input-capture.md +++ b/docs/input-capture.md @@ -74,7 +74,7 @@ primary gesture qualifies as a click. Native controls keep their existing press/drag rules. A plain hit without a native gesture also reports a capture-only click: the same committed hit incarnation must survive to release, remain enabled and hit-testable, and movement must stay below the native drag -threshold (4 DIPs), even if the pointer later returns. A prevented down/up, +threshold (4 DIPs), even if the pointer later returns. A prevented up, window blur, omitted capture registration or retired hit cancels that candidate. Plain hits do not acquire native press, focus or activation semantics. The callback identifies the actual release hit in window DIPs and carries the @@ -83,6 +83,16 @@ recognition, focus, IME, editor commands and menus remain MyGo's responsibility unless explicitly prevented. A pointer release prevented by capture cancels the pending native press/drag so it cannot produce a click later. +Preventing pointer-down blocks native focus, selection and press activation, +but does not cancel the separate capture-only `InputClick`. It follows the same +live-hit, movement and lifecycle rules as a plain hit. This lets a host cancel +DOM `mousedown` to keep editor focus while still selecting an autocomplete +candidate on the matching release. This corrects the earlier behavior where +preventing down also discarded the capture click. Hosts that need to suppress +the click must prevent the release, or ignore/prevent `InputClick` itself; +native control activation remains suppressed by the prevented down. Do not +work around this by forwarding another click from a local pointer handler. + Hosts upgrading from interactive-only click capture should remove plain-node click synthesis or placeholder `OnClick` handlers used solely to enable global delegation. For example, leave `ui.Box(c).Bind(&article)` non-interactive and diff --git a/ui/input_capture.go b/ui/input_capture.go index b193166ef..147aac2d4 100644 --- a/ui/input_capture.go +++ b/ui/input_capture.go @@ -80,8 +80,11 @@ func (s *inputCaptureState) input(ev InputEvent, target InputTarget) bool { switch ev.Kind { case InputPointerDown: if ev.Button == 0 { - s.id, s.plain, s.released = 0, false, false - if hit := s.rt.states[target.id]; !prevented && hit != nil && hit.flags&flagDisabled == 0 { + // Canceling down suppresses native focus/press, not the independent + // capture-only click recognized from the matching real release. + s.id, s.plain, s.released = 0, prevented, false + s.clicks = max(1, ev.Clicks) + if hit := s.rt.states[target.id]; hit != nil && hit.flags&flagDisabled == 0 { s.id, s.born, s.x, s.y = hit.id, hit.born, ev.X, ev.Y } } diff --git a/ui/input_capture_test.go b/ui/input_capture_test.go index e921495ad..198ba3880 100644 --- a/ui/input_capture_test.go +++ b/ui/input_capture_test.go @@ -14,7 +14,8 @@ func TestCaptureClickOnPlainHit(t *testing.T) { {name: "plain", clicks: 1}, {name: "native", native: true, clicks: 1, presses: 1}, {name: "prevent native click", native: true, prevent: InputClick, clicks: 1}, - {name: "prevent down", prevent: InputPointerDown}, + {name: "prevent down", prevent: InputPointerDown, clicks: 1}, + {name: "prevent native down", native: true, prevent: InputPointerDown, clicks: 1}, {name: "prevent up", prevent: InputPointerUp}, {name: "prevent plain click", prevent: InputClick, clicks: 1}, {name: "drag and return", action: "drag"}, @@ -24,11 +25,15 @@ func TestCaptureClickOnPlainHit(t *testing.T) { {name: "pointer disabled", action: "pointer"}, {name: "capture removed", action: "capture"}, {name: "window blur", action: "blur"}, + {name: "prevent down then drag", prevent: InputPointerDown, action: "drag"}, + {name: "prevent down then replace", prevent: InputPointerDown, action: "replaced"}, + {name: "prevent down then blur", prevent: InputPointerDown, action: "blur"}, } { t.Run(tc.name, func(t *testing.T) { var hit Handle clicks, presses := 0, 0 present, capture, enabled, disabled, key := true, true, true, false, "original" + var value string tt := NewTester(func(c *Context) { if capture { c.CaptureInput(func(ev InputEvent, target InputTarget) bool { @@ -41,6 +46,7 @@ func TestCaptureClickOnPlainHit(t *testing.T) { return ev.Kind == tc.prevent }) } + TextInput(c, &value).Label("Editor").AutoFocus().Absolute().Left(110).Top(30).Size(80, 28) if !present { return } @@ -76,6 +82,9 @@ func TestCaptureClickOnPlainHit(t *testing.T) { } tt.Frame() tt.Release(50, 14) + if tc.prevent == InputPointerDown && tc.action == "" && !tt.Focused("Editor") { + t.Fatal("prevented down stole native editor focus") + } if clicks != tc.clicks || presses != tc.presses { t.Fatalf("capture clicks/native presses = %d/%d, want %d/%d", clicks, presses, tc.clicks, tc.presses) } From daa6e99e1ee8eb712d3272008812d082293d9fc8 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 27 Oct 2026 20:12:49 +0000 Subject: [PATCH 16/38] feat(editor): report committed document changes Publish editing operations without leaking marked text or echoing authoritative model replacements. --- docs/ui/migration.md | 44 +++++++++++++++++++++++++++++++++ ui/editor/build.go | 31 +++++++++++------------ ui/editor/edit.go | 2 ++ ui/editor/editor.go | 36 +++++++++++++++++++++++++++ ui/editor/hooks_test.go | 54 +++++++++++++++++++++++++++++++++++++++++ ui/editor/input.go | 4 +++ ui/editor/state.go | 25 ++++++++++++++++++- 7 files changed, 180 insertions(+), 16 deletions(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index 4f1335184..c5d90a286 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,49 @@ # Migrating to checked elements +## Code editor committed changes + +`editor.State.OnChange func(text string)` now reports each completed editing +operation synchronously, after text and selections have updated. Typing, +paste/cut, commands, undo/redo and `State.Insert`/`State.Run` notify once when +the resulting text differs. Multiple buffer edits within one operation (such +as typing with automatic indentation) produce one notification. Selection-only +operations and direct `Buffer.Set`, `Buffer.Reset`, `Buffer.Edit`, `Buffer.Undo` +or `Buffer.Redo` do not notify. Use `State.Run(editor.Undo)`/`Redo` when those +actions should publish an edit. + +IME marked text is editor-local preedit: it does not call `OnChange`. +`State.Composing()` is true until `ReplaceText` commits it, `UnmarkText` accepts +it, or the composition is removed. The commit reports the final document once, +even if it equals the last preedit text. Repeated `UnmarkText` does not notify +again. A composition that leaves the committed document unchanged does not +notify. This is an event contract, not an OS-specific IME acceptance claim. +Native adapters that end preedit before delivering a final replacement must +not use `UnmarkText` as an intermediate end notification: it accepts the current +text. They must retain the pending composition until its final replacement or +explicit acceptance. The existing `platform.InputComposition.End` ordering +needs that separate adapter integration before claiming committed-only native +IME persistence; this editor API alone does not change that protocol. + +Replace frame-by-frame comparisons of `Buffer.String()` with `OnChange` when +saving documents. Keep `OnInput` for pre-default key routing only; do not save +from it or from marked-text updates. For authoritative external values, call +`State.SetTextFromModel(text)` on the UI thread. It cancels pending composition, +replaces differing text and clears its undo history without calling `OnChange`; +unmodified text retains its history. Model echoes of the current document need +no replacement. Applications choose whether to defer external updates while +`Composing()` or while a query draft is focused. + +```go +state.OnChange = func(text string) { sendDocumentEdit(text) } +if modelChanged && !state.Composing() && state.Buffer.String() != modelText { + state.SetTextFromModel(modelText) +} +editor.View(c, &state) +``` + +The new callback and methods are opt-in and source-compatible with existing +editor consumers. Direct buffer mutations retain their existing behavior. + Native UI views receive `*ui.Context`, child builders use `func()` callbacks, and widget constructors return `ui.Element` values. This guide covers source migration, build lifetimes, persistent control identity and focus bindings. diff --git a/ui/editor/build.go b/ui/editor/build.go index cb5cd4886..2a171b7d9 100644 --- a/ui/editor/build.go +++ b/ui/editor/build.go @@ -199,45 +199,46 @@ func (ed *editor) accessibility(n *platform.AccessNode) { // menu is the editor's context menu: the editing commands, folding, and // the app's items. func (ed *editor) menu(m *ui.Menu) { + run := func(cmd Command) { ed.retry(func() bool { return ed.run(cmd) }) } tx := ed.texts has := slices.ContainsFunc(ed.sels, func(s sel) bool { return !s.empty() }) ro := ed.o.readOnly if ed.o.onDefinition != nil { if m.Item(tx.GoToDefinition()).Shortcut(0, ui.KeyF12).Chosen() { - ed.run(GoToDefinition) + run(GoToDefinition) } m.Separator() } if m.Item(tx.Cut()).Shortcut(ui.Cmd, ui.KeyX).Disabled(ro).Chosen() { - ed.run(Cut) + run(Cut) } if m.Item(tx.Copy()).Shortcut(ui.Cmd, ui.KeyC).Chosen() { - ed.run(Copy) + run(Copy) } if m.Item(tx.Paste()).Shortcut(ui.Cmd, ui.KeyV).Disabled(ro).Chosen() { - ed.run(Paste) + run(Paste) } m.Separator() if m.Item(tx.SelectAll()).Shortcut(ui.Cmd, ui.KeyA).Chosen() { - ed.run(SelectAll) + run(SelectAll) } if !ro { if m.Item(tx.ToggleComment()).Shortcut(ui.Cmd, ui.KeySlash).Chosen() { - ed.run(ToggleComment) + run(ToggleComment) } } m.Submenu(tx.Selection(), func(m *ui.Menu) { if m.Item(tx.SelectNextOccurrence()).Shortcut(ui.Cmd, ui.KeyD).Chosen() { - ed.run(SelectNext) + run(SelectNext) } if m.Item(tx.SelectAllOccurrences()).Shortcut(ui.Cmd|ui.Shift, ui.KeyL).Chosen() { - ed.run(SelectAllMatches) + run(SelectAllMatches) } if m.Item(tx.ExpandSelection()).Chosen() { - ed.run(SelectLarger) + run(SelectLarger) } if m.Item(tx.SplitIntoLines()).Disabled(!has).Chosen() { - ed.run(SplitSelectionIntoLines) + run(SplitSelectionIntoLines) } }) if !ro { @@ -254,7 +255,7 @@ func (ed *editor) menu(m *ui.Menu) { continue } if m.Item(it.label).Chosen() { - ed.run(it.cmd) + run(it.cmd) } } }) @@ -262,16 +263,16 @@ func (ed *editor) menu(m *ui.Menu) { if ed.o.folding { m.Submenu(tx.Folding(), func(m *ui.Menu) { if m.Item(tx.Fold()).Chosen() { - ed.run(Fold) + run(Fold) } if m.Item(tx.Unfold()).Chosen() { - ed.run(Unfold) + run(Unfold) } if m.Item(tx.FoldAll()).Chosen() { - ed.run(FoldAll) + run(FoldAll) } if m.Item(tx.UnfoldAll()).Chosen() { - ed.run(UnfoldAll) + run(UnfoldAll) } }) } diff --git a/ui/editor/edit.go b/ui/editor/edit.go index 06a989ace..ff062d035 100644 --- a/ui/editor/edit.go +++ b/ui/editor/edit.go @@ -12,6 +12,8 @@ import ( // moving offsets through the edits; nil moves them as the text around // them moves. func (ed *editor) apply(edits []Edit, kind editKind, after func(move func(int, bias) int) []sel) bool { + ed.beginChange() + defer ed.endChange() b := ed.buf() if b == nil || ed.o.readOnly || len(edits) == 0 { return false diff --git a/ui/editor/editor.go b/ui/editor/editor.go index eed7b5622..c224a1d34 100644 --- a/ui/editor/editor.go +++ b/ui/editor/editor.go @@ -53,6 +53,7 @@ type options struct { onGutterClick func(int) contextMenu func(m *ui.Menu) onInput func(ui.InputEvent) bool + onChange func(string) noInputMethod bool // The parts' render functions, nil for the editor's own. renderFindBar func(*ui.Context, ui.Element, *FindBar) @@ -141,6 +142,9 @@ type editor struct { folds []fold marked []ui.TextInputRange // the composition, in bytes per selection markedActive bool + changeDepth int + changeBefore Snapshot + changePending bool autoclosed []autoclose scrollY float64 @@ -358,6 +362,7 @@ func mapSel(s sel, cs []change) sel { // as it shows another: its carets, folds, marks and what the app gave // it of the old text. func (ed *editor) resetForBuffer() { + ed.markedActive, ed.changePending, ed.changeBefore = false, false, Snapshot{} ed.sels = []sel{{goal: -1, id: ed.nextID}} ed.nextID++ ed.selUndo, ed.selRedo = nil, nil @@ -372,6 +377,37 @@ func (ed *editor) resetForBuffer() { ed.scrollY, ed.scrollX, ed.anchorLine, ed.anchorOff = 0, 0, 0, 0 } +func (ed *editor) beginChange() { + if ed.changeDepth == 0 && !ed.changePending && ed.o.onChange != nil { + if b := ed.buf(); b != nil { + ed.changeBefore = b.Snapshot() + } + } + ed.changeDepth++ +} + +func (ed *editor) endChange() { + ed.changeDepth-- + if ed.changeDepth != 0 { + return + } + if ed.markedActive && ed.o.onChange != nil { + ed.changePending = true + return + } + before := ed.changeBefore + ed.changePending, ed.changeBefore = false, Snapshot{} + if b := ed.buf(); b != nil && ed.o.onChange != nil { + after := b.Snapshot() + if before.Version() != after.Version() { + text := after.String() + if text != before.String() { + ed.o.onChange(text) + } + } + } +} + // setSels sets the selections, noting the old ones for Undo Selection. func (ed *editor) setSels(sels []sel, reveal reveal) { if !slices.Equal(sels, ed.sels) { diff --git a/ui/editor/hooks_test.go b/ui/editor/hooks_test.go index c625c2941..7e0d7d21b 100644 --- a/ui/editor/hooks_test.go +++ b/ui/editor/hooks_test.go @@ -2,12 +2,66 @@ package editor import ( "fmt" + "slices" "strings" "testing" "github.com/egoist/mygo/ui" ) +func TestOnChangeReportsCommittedOperations(t *testing.T) { + ct := newEditorTest(t, "", "|") + var changes []string + ct.st.OnChange = func(value string) { changes = append(changes, value) } + ct.tt.Frame() + want := func(values ...string) { + t.Helper() + if !slices.Equal(changes, values) { + t.Fatalf("committed changes = %q, want %q", changes, values) + } + } + ct.typ("a") + want("a") + ed := &ct.st.ed + ed.SetMarkedText(nil, "n", ui.TextInputRange{Start: 1, End: 1}) + ed.SetMarkedText(nil, "ni", ui.TextInputRange{Start: 2, End: 2}) + want("a") + if !ct.st.Composing() { + t.Fatal("preedit was not exposed as composing") + } + ed.ReplaceText(nil, "你😀") + want("a", "a你😀") + if ct.st.Composing() { + t.Fatal("commit left a composition active") + } + ed.SetMarkedText(nil, "中", ui.TextInputRange{Start: 1, End: 1}) + ed.UnmarkText() + ed.UnmarkText() + want("a", "a你😀", "a你😀中") + ct.st.Run(Undo) + want("a", "a你😀", "a你😀中", "a你😀") + ct.st.Run(Redo) + want("a", "a你😀", "a你😀中", "a你😀", "a你😀中") + ed.SetMarkedText(nil, "draft", ui.TextInputRange{Start: 5, End: 5}) + ct.st.SetTextFromModel("remote") + ed.UnmarkText() + want("a", "a你😀", "a你😀中", "a你😀", "a你😀中") + if ct.st.Composing() || ct.st.Buffer.String() != "remote" { + t.Fatal("model replacement retained preedit") + } + ct.st.ReadOnly = true + ct.tt.Frame() + ct.typ("x") + want("a", "a你😀", "a你😀中", "a你😀", "a你😀中") + ct.st.ReadOnly = false + ct.st.Buffer.SetLanguage(LanguageNamed("javascript")) + ct.st.SetTextFromModel("if (a) {\n ") + ct.st.Select(ct.st.Buffer.Snapshot().Len(), ct.st.Buffer.Snapshot().Len()) + ct.tt.Frame() + ct.typ("}") + want("a", "a你😀", "a你😀中", "a你😀", "a你😀中", "if (a) {\n}") +} + // OnInput sees keys and typed text before the editor: what it takes the // editor leaves, the rest goes on as without it. func TestOnInputTakesKeys(t *testing.T) { diff --git a/ui/editor/input.go b/ui/editor/input.go index e04797e8c..5835a8554 100644 --- a/ui/editor/input.go +++ b/ui/editor/input.go @@ -41,6 +41,8 @@ func (ed *editor) offer(ev ui.InputEvent) bool { // the text as it is when another goroutine edited the buffer meanwhile, // rather than leaving the edit out (apply). func (ed *editor) retry(fn func() bool) bool { + ed.beginChange() + defer ed.endChange() for range 4 { ed.sync() ed.stale = false @@ -771,6 +773,8 @@ func (ed *editor) replaceRanges(ts []ui.TextInputRange, s string, kind editKind, } func (ed *editor) UnmarkText() { + ed.beginChange() + defer ed.endChange() ed.markedActive, ed.marked = false, nil if b := ed.buf(); b != nil { b.endGroup() diff --git a/ui/editor/state.go b/ui/editor/state.go index 6ee1532b6..789b95e63 100644 --- a/ui/editor/state.go +++ b/ui/editor/state.go @@ -105,6 +105,12 @@ type State struct { // of press and hold, goes to the editor alone. The key after the // first of a chord of Bindings, as Cmd+K Cmd+0, is the chord's. OnInput func(ev ui.InputEvent) bool + + // OnChange runs synchronously after a committed editing operation, + // once with its resulting text. IME preedit, selection-only operations + // and direct Buffer changes do not call it. Insert and Run are editing + // operations; use SetTextFromModel for authoritative model updates. + OnChange func(text string) // NoInputMethod turns the system's input methods off while the // editor has the keyboard (ui's InputMethod): keys type the // characters of the keyboard's layout, which come to OnInput as @@ -197,7 +203,7 @@ func (st *State) options() options { autoClose: !st.NoAutoClose, folding: !st.NoFolding, activeLine: !st.NoActiveLine, cursorShape: st.CursorShape, fitContent: st.FitContent, placeholder: st.Placeholder, label: st.Label, theme: st.Theme, bindings: st.Bindings, gutterMarks: st.GutterMarks, backgrounds: st.LineBackgrounds, diffBase: st.DiffBase, onCompletion: st.OnCompletion, onHover: st.OnHover, onDefinition: st.OnDefinition, - onGutterClick: st.OnGutterClick, contextMenu: st.ContextMenu, onInput: st.OnInput, noInputMethod: st.NoInputMethod, minDigits: 3, + onGutterClick: st.OnGutterClick, contextMenu: st.ContextMenu, onInput: st.OnInput, onChange: st.OnChange, noInputMethod: st.NoInputMethod, minDigits: 3, renderFindBar: st.RenderFindBar, renderGoToLine: st.RenderGoToLine, renderCompletions: st.RenderCompletions, renderHover: st.RenderHover, } @@ -261,6 +267,23 @@ func (st *State) Insert(text string) { st.ed.retry(func() bool { st.ed.paste(text); return true }) } +// Composing reports whether an input method has uncommitted marked text. +func (st *State) Composing() bool { return st.ed.markedActive } + +// SetTextFromModel replaces authoritative text without calling OnChange. +// It discards pending composition and, when text differs, undo history. +// Call it on the UI thread, as other State methods. +func (st *State) SetTextFromModel(text string) { + st.ensure() + ed := &st.ed + ed.markedActive, ed.marked = false, nil + ed.changePending, ed.changeBefore = false, Snapshot{} + if st.Buffer.String() != lf(text) { + st.Buffer.Reset(text) + ed.sync() + } +} + // Run runs a command as its key would, and reports whether it did // something. func (st *State) Run(cmd Command) bool { From b02411bf6e2658efe99109fc4fa8466e72dad8b1 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 27 Oct 2026 20:13:13 +0000 Subject: [PATCH 17/38] feat(ui): add cancellable owned pointer gestures --- docs/architecture.md | 10 ++++++ internal/linux/surface.go | 8 +++++ internal/platform/surface.go | 2 ++ internal/web/surface.go | 5 ++- internal/windows/surface.go | 3 +- ui/api_gen.go | 14 ++++++++ ui/captured_pointer_test.go | 68 ++++++++++++++++++++++++++++++++++++ ui/context.go | 7 ++-- ui/handler.go | 23 ++++++++++++ ui/input.go | 35 +++++++++++++++++++ ui/runtime.go | 9 +++++ 11 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 ui/captured_pointer_test.go diff --git a/docs/architecture.md b/docs/architecture.md index 1a4e9ea50..db58bc9be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1959,6 +1959,16 @@ Recent (about 290 KB). ## Native UI (`ui`) +`Element.HandleCapturedPointer` opts a primary pointer handler into an owned +gesture. An accepted down receives movement and release outside the element; +Escape, window deactivation, native capture loss, removal, disablement and close +end it with `InputPointerCancel`, never a successful release or click. Discard +transient state on cancellation and commit it only on `InputPointerUp`. +`HandleInput` retains its legacy synthetic release on window deactivation; +existing text and IME handlers do not opt into this contract. Install one input +handler per element. Surface backends report capture loss as `PointerCancel`; +Windows sends a real release before relinquishing its own native capture. + The public API keeps one stable `*Context` per window. Child builders share it and temporarily change its parent. `Element` is a 16-byte checked value: a direct owner record, arena slot and 32-bit generation. The owner detaches diff --git a/internal/linux/surface.go b/internal/linux/surface.go index eedff5385..49838a575 100644 --- a/internal/linux/surface.go +++ b/internal/linux/surface.go @@ -73,6 +73,7 @@ var ( cbSurfaceFocusIn, cbSurfaceFocusOut, cbSurfaceScale, cbIMCommit ptr cbIMPreedit, cbIMPreeditEnd, cbSurfaceRender, cbAreaContext ptr cbSurfaceUnrealize, cbSurfaceTick, cbSurfacePinch ptr + cbSurfaceGrabBroken ptr surfaceCursors = map[platform.Cursor]ptr{} ) @@ -227,6 +228,7 @@ func (s *surface) newArea(gl bool) { connect(s.area, "key-release-event", cbSurfaceKey, data) connect(s.area, "focus-in-event", cbSurfaceFocusIn, data) connect(s.area, "focus-out-event", cbSurfaceFocusOut, data) + connect(s.area, "grab-broken-event", cbSurfaceGrabBroken, data) } // contentWidget returns the widget showing the window's content: the web @@ -701,6 +703,12 @@ func initSurfaceCallbacks() { gtkIMContextSetClientWindow(s.im, 0) } }) + cbSurfaceGrabBroken = purego.NewCallback(func(widget, event, data ptr) bool { + if s := b().surfaceOf(data); s != nil { + s.send(platform.SurfaceEvent{Kind: platform.PointerCancel}) + } + return false + }) cbSurfaceRealize = purego.NewCallback(func(widget, data ptr) { if s := b().surfaceOf(data); s != nil { gtkIMContextSetClientWindow(s.im, s.eventWindow()) diff --git a/internal/platform/surface.go b/internal/platform/surface.go index abb83f932..2754935b9 100644 --- a/internal/platform/surface.go +++ b/internal/platform/surface.go @@ -248,6 +248,8 @@ const ( // press and what follows: the content notes it, as popovers close for // a press outside them, but handles none of it. WebViewPress + // PointerCancel reports capture loss, never a successful release. + PointerCancel // WebViewTabOut reports that Tab moved the keyboard out of WebView // past its page's last element, or Shift+Tab (Mods) past its first: // the surface has the keyboard again, and the content moves the focus diff --git a/internal/web/surface.go b/internal/web/surface.go index 34817dd84..2e3685063 100644 --- a/internal/web/surface.go +++ b/internal/web/surface.go @@ -175,7 +175,10 @@ func (s *surface) listen() { }) s.on(c, "pointercancel", true, func(e js.Value) { x, y := s.point(e) - s.send(platform.SurfaceEvent{Kind: platform.PointerUp, X: x, Y: y, Button: buttonOf(e.Get("button").Int()), Mods: s.mods(e)}) + s.send(platform.SurfaceEvent{Kind: platform.PointerCancel, X: x, Y: y, Mods: s.mods(e)}) + }) + s.on(c, "lostpointercapture", false, func(e js.Value) { + s.send(platform.SurfaceEvent{Kind: platform.PointerCancel}) }) s.on(c, "pointerleave", true, func(js.Value) { s.send(platform.SurfaceEvent{Kind: platform.PointerLeave}) diff --git a/internal/windows/surface.go b/internal/windows/surface.go index 64d46a162..377734795 100644 --- a/internal/windows/surface.go +++ b/internal/windows/surface.go @@ -549,10 +549,10 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool button = 2 } s.buttons &^= 1 << button + s.pointer(platform.PointerUp, lp, button) if s.buttons == 0 { procReleaseCapture.Call() } - s.pointer(platform.PointerUp, lp, button) if m == wmRButtonUp { return 0, false // WM_CONTEXTMENU } @@ -589,6 +589,7 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool s.buttons = 0 if lp != hwnd { s.webCapture = nil + s.send(platform.SurfaceEvent{Kind: platform.PointerCancel}) } return 0, true case wmMouseWheel, wmMouseHWheel: diff --git a/ui/api_gen.go b/ui/api_gen.go index a0d8c2996..37a80565f 100644 --- a/ui/api_gen.go +++ b/ui/api_gen.go @@ -3980,6 +3980,20 @@ func (_handle Element) Grow(f float32) Element { return wrapElement(_node.Grow(f)) } +// HandleCapturedPointer is HandleInput with explicit cancellation of primary +// pointer gestures. A primary down it accepts owns movement and release outside +// the element. Cancellation never activates a click or a context menu. Other +// input handlers retain their existing deactivation behavior. +func (_handle Element) HandleCapturedPointer(fn func(ev InputEvent) bool) Element { + _node := _handle.node() + if _node == nil { + return Element{} + } + _ctx := _node.c + _ = _ctx + return wrapElement(_node.HandleCapturedPointer(fn)) +} + // HandleInput has fn take the element's input as it comes, on the main // thread, before the next frame is built: keys, text and edit commands // while the element has the keyboard focus, the pointer pressed on it, diff --git a/ui/captured_pointer_test.go b/ui/captured_pointer_test.go new file mode 100644 index 000000000..d8ba9e8ea --- /dev/null +++ b/ui/captured_pointer_test.go @@ -0,0 +1,68 @@ +package ui + +import ( + "testing" + + "github.com/egoist/mygo/internal/platform" +) + +func TestCapturedPointerLifetime(t *testing.T) { + for _, end := range []string{"release", "escape", "blur", "cancel", "remove", "disable", "close"} { + t.Run(end, func(t *testing.T) { + present, disabled := true, false + moves, releases, cancels, clicks := 0, 0, 0, 0 + tt := NewTester(func(c *Context) { + if !present { + return + } + Box(c).Key("handle").Size(20, 30).Disabled(disabled). + OnClick(func() { clicks++ }).HandleCapturedPointer(func(ev InputEvent) bool { + switch ev.Kind { + case InputPointerDown: + return ev.Button == 0 + case InputPointerMove: + moves++ + case InputPointerUp: + releases++ + case InputPointerCancel: + cancels++ + } + return true + }) + }, 200, 60) + tt.Press(10, 15) + moves = 0 + tt.Move(180, 15) + if moves != 1 { + t.Fatalf("owned movement outside handle = %d, want 1", moves) + } + switch end { + case "escape": + tt.Key(0, KeyEscape) + case "blur": + tt.SetFocused(false) + case "cancel": + tt.send(platform.SurfaceEvent{Kind: platform.PointerCancel}) + case "remove": + present = false + tt.Frame() + case "disable": + disabled = true + tt.Frame() + case "close": + tt.rt.close() + } + if end != "close" { + tt.Release(180, 15) + } + wantRelease, wantCancel := 0, 1 + if end == "release" { + wantRelease, wantCancel = 1, 0 + } + if releases != wantRelease || cancels != wantCancel || clicks != 0 { + t.Fatalf("release/cancel/click = %d/%d/%d, want %d/%d/0", + releases, cancels, clicks, wantRelease, wantCancel) + } + }) + } +} diff --git a/ui/context.go b/ui/context.go index 348361e36..5591647ff 100644 --- a/ui/context.go +++ b/ui/context.go @@ -547,9 +547,10 @@ type state struct { typing bool // press is where the pointer went down in the element; dragX and dragY // add up its moves since the last frame. - pressX, pressY float32 - dragX, dragY float32 - pressed bool + pressX, pressY float32 + dragX, dragY float32 + pressed bool + capturedPointer bool // drag follows the press's moves; released tells that it ended since // the last frame, going at releaseVX and releaseVY in the element's // axes, releaseWX and releaseWY in the window's. diff --git a/ui/handler.go b/ui/handler.go index 6aa693475..3539d35a0 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -56,6 +56,10 @@ const ( // qualifies as a click, including on a plain hit with no native gesture. // Returning true suppresses any native activation. InputClick + // InputPointerCancel ends an owned pointer gesture without a release or click. + // HandleCapturedPointer receives it on Escape, deactivation, capture loss, + // removal or window close. It must discard, not commit, its preview. + InputPointerCancel ) // InputEvent is input an element takes as it comes (HandleInput). @@ -110,6 +114,25 @@ func (e *node) HandleInput(fn func(ev InputEvent) bool) *node { return e } +// HandleCapturedPointer is HandleInput with explicit cancellation of primary +// pointer gestures. A primary down it accepts owns movement and release outside +// the element. Cancellation never activates a click or a context menu. Other +// input handlers retain their existing deactivation behavior. +func (e *node) HandleCapturedPointer(fn func(ev InputEvent) bool) *node { + s := e.st + e.inputFn = func(ev InputEvent) bool { + if ev.Kind == InputPointerUp || ev.Kind == InputPointerCancel { + s.capturedPointer = false + } + handled := fn(ev) + if ev.Kind == InputPointerDown && ev.Button == 0 && handled { + s.capturedPointer = true + } + return handled + } + return e +} + // TextCaret has the element take text from the system's input methods // while it has the keyboard focus, with their composition at r, the caret, // relative to the element's box: candidate windows show there. Its diff --git a/ui/input.go b/ui/input.go index 6ab77ccb9..88e981136 100644 --- a/ui/input.go +++ b/ui/input.go @@ -52,6 +52,8 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { case platform.PointerUp: rt.pointerMove(x, y) rt.pointerUp(ev.Button, ev.Clicks) + case platform.PointerCancel: + rt.pointerCancel() case platform.WebViewPress: rt.webViewPress(x, y, ev.Button) case platform.PointerLeave: @@ -97,6 +99,9 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { rt.blinkStart = rt.now() rt.focusChanged() case platform.SurfaceBlur: + if rt.pressed != nil && rt.pressed.capturedPointer { + rt.pointerCancel() + } rt.windowFocused = false if rt.captureClicks != nil { rt.captureClicks.id = 0 @@ -305,6 +310,12 @@ func (rt *engine) pointerMove(x, y float32) { const interactive = flagClickable | flagFocusable | flagEditable | flagSelectable | flagDragWindow | flagDraggable | flagTrackPointer func (rt *engine) pointerDown(x, y float32, button int, mods Modifiers, clicks int) { + if rt.pressed != nil && rt.pressed.capturedPointer { + if button != 0 { + return + } + rt.pointerCancel() + } if rt.capturePointer(InputEvent{Kind: InputPointerDown, Button: button, Clicks: max(1, clicks), Mods: mods}, x, y) { return } @@ -415,6 +426,26 @@ func (rt *engine) endDown() { } } +func (rt *engine) pointerCancel() { + p := rt.pressed + if p == nil { + return + } + owned := p.capturedPointer + rt.pressed = nil + rt.pressDragger = 0 + rt.downs = rt.downs[:0] + rt.captureClicks, rt.releaseCapture = nil, nil + p.pressed, p.pressPending, p.released, p.capturedPointer = false, false, false, false + if owned { + rt.deliver(p, InputEvent{Kind: InputPointerCancel, Button: rt.pressButton, Mods: rt.mods}) + } else { + // Legacy handlers ended their gesture on a synthetic release. + rt.deliver(p, InputEvent{Kind: InputPointerUp, Button: rt.pressButton, Mods: rt.mods}) + } + rt.requestFrame() +} + // countPress counts the backend's n-th quick press from the first on the // element pressed: the innermost of the target and the handler, so that // two handlers in one focusable element, or two buttons in one handler, @@ -724,6 +755,10 @@ func (rt *engine) keyDown(mods Modifiers, key Key, repeat bool) bool { // A chord of a key the layout does not type as a US key, as S on a Cyrillic // layout, matches shortcuts by that position; plain typing is untouched. func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool) bool { + if key == KeyEscape && rt.pressed != nil && rt.pressed.capturedPointer { + rt.pointerCancel() + return true + } if platform.KeyByPosition(platform.Key(key), platform.Key(physical), platform.Modifiers(mods)) { if rt.byPosition == nil { rt.byPosition = map[Key]bool{} diff --git a/ui/runtime.go b/ui/runtime.go index 47c0af4d2..c9cc345b4 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -835,6 +835,9 @@ func (rt *engine) prune() { rt.captureClicks.id = 0 } if rt.pressed == s { + if s.capturedPointer { + rt.pointerCancel() + } rt.pressed = nil } if !rt.keptAlive(s) { @@ -922,6 +925,9 @@ func (rt *engine) armTimer() { // frame ends: the rest of the view builds with a Context that still works, // and reads the theme, the size and its state as before. func (rt *engine) close() { + if rt.pressed != nil && rt.pressed.capturedPointer { + rt.pointerCancel() + } rt.inputCapture = nil rt.captureClicks, rt.releaseCapture = nil, nil if rt.inFrame { @@ -1059,6 +1065,9 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden, pointerEvents bool) s.webView = e.webView s.role = e.role s.input, s.caret, s.takesText, s.noIME = e.inputFn, e.caret, e.takesText, e.noIME + if rt.pressed == s && s.capturedPointer && s.flags&flagDisabled != 0 { + rt.pointerCancel() + } s.copyData = e.copyData if s.textClient != e.textClient { if s.textAdapter != nil { From e18607c66489926058a6e5e6643ca6aad0f27067 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 27 Oct 2026 20:27:17 +0000 Subject: [PATCH 18/38] fix(input): separate composition end from acceptance --- docs/ui/migration.md | 66 +++++++++++++++++++--- examples/text-input/main.go | 22 +++++++- internal/e2e/text_input_primitive_test.go | 21 ++++++- internal/platform/text_input.go | 18 ++++-- ui/editor/build.go | 3 +- ui/editor/editor.go | 4 ++ ui/editor/hooks_test.go | 19 +++++++ ui/editor/input.go | 44 ++++++++++++++- ui/editor/state.go | 1 + ui/editor_input.go | 32 +++++++++-- ui/lui_list_window_test.go | 6 +- ui/richtext/ime.go | 36 ++++++++++-- ui/richtext/view.go | 9 +-- ui/text_input_client.go | 17 ++++-- ui/text_input_client_test.go | 67 ++++++++++++++++++++--- 15 files changed, 315 insertions(+), 50 deletions(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index c5d90a286..e30557799 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -17,12 +17,9 @@ it, or the composition is removed. The commit reports the final document once, even if it equals the last preedit text. Repeated `UnmarkText` does not notify again. A composition that leaves the committed document unchanged does not notify. This is an event contract, not an OS-specific IME acceptance claim. -Native adapters that end preedit before delivering a final replacement must -not use `UnmarkText` as an intermediate end notification: it accepts the current -text. They must retain the pending composition until its final replacement or -explicit acceptance. The existing `platform.InputComposition.End` ordering -needs that separate adapter integration before claiming committed-only native -IME persistence; this editor API alone does not change that protocol. +Native end-before-commit adapters now use the mandatory composition phases +below. `EndComposition` hides marking without accepting preedit; `Composing()` +remains true until final replacement, explicit acceptance or cancellation. Replace frame-by-frame comparisons of `Buffer.String()` with `OnChange` when saving documents. Keep `OnInput` for pre-default key routing only; do not save @@ -41,8 +38,61 @@ if modelChanged && !state.Composing() && state.Buffer.String() != modelText { editor.View(c, &state) ``` -The new callback and methods are opt-in and source-compatible with existing -editor consumers. Direct buffer mutations retain their existing behavior. +The editor callback and state methods are opt-in. Direct buffer mutations retain +their existing behavior; use `SetTextFromModel` for controlled model replacement, +not a direct buffer edit during local composition. + +## Text input composition phases + +This is a source-breaking change to custom `ui.TextInputClient` implementations: +add mandatory `EndComposition()` and `CancelComposition()` methods. There is no +optional interface or legacy fallback. Built-in widgets, code/richtext editors +and the text-input example implement the same phases. No native surface API +change is required. + +- `SetMarkedText` starts/updates pending preedit and remembers the original text + and selection for cancellation. Do not publish a committed document from it. +- `EndComposition` clears marking (`MarkedRange` becomes false), but retains the + pending text, replacement range and original state. It must not notify a save + or accept callback. Repeated end is harmless. +- `ReplaceText` applies the final replacement and publishes one committed edit. + The native end-before-commit helper retains its UTF-16 replacement range and + client identity; it drops a pending replacement directed at a different client. +- `UnmarkText` is explicit acceptance, including macOS native unmark. It accepts + current pending text once. Repeated acceptance does not publish again. +- `CancelComposition` restores only unfinished preedit's original text and + selection, clears marking/pending state and publishes no committed edit. + It must be idempotent and must not undo an already committed edit. + +The UI adapter calls `CancelComposition` on release: focus loss, disposal, runtime +close and client replacement. Ordinary focus loss is not acceptance. A native +`ReplaceText` or explicit `UnmarkText` received before release remains committed; +a later release is a no-op for it. Old native client adapters reject callbacks +after replacement and cannot edit the new client. The platform continues to +reset its pending replacement range when its native client is reset. + +For a custom string client, keep `pending` separate from visible `marked` status: + +```go +// SetMarkedText must first retain beforeText/beforeSelection for a new preedit. +func (c *Client) EndComposition() { c.marked = false } +func (c *Client) CancelComposition() { + if !c.pending { return } + c.text, c.selection = c.beforeText, c.beforeSelection + c.marked, c.pending = false, false +} +func (c *Client) UnmarkText() { + if !c.pending { return } + c.marked, c.pending = false, false + c.publishCommitted(c.text) +} +``` + +For a virtual-preedit client, acceptance inserts its virtual text before +publishing; cancellation removes it and restores any selection it replaced. +The code editor also discards the cancelled preedit's undo/redo transaction. +These contracts are covered by deterministic native-order and editor tests, +not a claim of real OS IME or Web/native product parity. Native UI views receive `*ui.Context`, child builders use `func()` callbacks, and widget constructors return `ui.Element` values. This guide covers source diff --git a/examples/text-input/main.go b/examples/text-input/main.go index 28c957f8f..7da87fc9a 100644 --- a/examples/text-input/main.go +++ b/examples/text-input/main.go @@ -15,6 +15,10 @@ type field struct { selection ui.TextInputSelection marked *ui.TextInputRange layout *ui.TextLayout + before string + beforeSel ui.TextInputSelection + composing bool + ended bool } func (f *field) shape() { f.layout = ui.ShapeText(f.text, ui.Font{Size: 18}, 0) } @@ -24,7 +28,7 @@ func (f *field) TextForRange(r ui.TextInputRange) (string, ui.TextInputRange) { } func (f *field) Selection() ui.TextInputSelection { return f.selection } func (f *field) MarkedRange() (ui.TextInputRange, bool) { - if f.marked != nil { + if f.marked != nil && !f.ended { return *f.marked, true } return ui.TextInputRange{}, false @@ -45,16 +49,30 @@ func (f *field) ReplaceText(r *ui.TextInputRange, text string) { end := wanted.Start + ui.UTF16Len(text) f.selection = ui.TextInputSelection{Range: ui.TextInputRange{Start: end, End: end}} f.marked = nil + f.composing, f.ended = false, false f.shape() } func (f *field) SetMarkedText(r *ui.TextInputRange, text string, selected ui.TextInputRange) { + before, selection := f.before, f.beforeSel + if !f.composing { + before, selection = f.text, f.selection + } wanted := f.target(r) f.ReplaceText(&wanted, text) mark := ui.TextInputRange{Start: wanted.Start, End: wanted.Start + ui.UTF16Len(text)} f.marked = &mark + f.before, f.beforeSel, f.composing = before, selection, true f.selection = ui.TextInputSelection{Range: ui.TextInputRange{Start: mark.Start + selected.Start, End: mark.Start + selected.End}} } -func (f *field) UnmarkText() { f.marked = nil } +func (f *field) UnmarkText() { f.marked, f.composing, f.ended = nil, false, false } +func (f *field) EndComposition() { f.ended = f.composing } +func (f *field) CancelComposition() { + if f.composing { + f.text, f.selection = f.before, f.beforeSel + f.shape() + } + f.UnmarkText() +} func (f *field) BoundsForRange(r ui.TextInputRange) (ui.Rect, ui.TextInputRange, bool) { b := f.layout.Caret(r.Start) if r.Start != r.End { diff --git a/internal/e2e/text_input_primitive_test.go b/internal/e2e/text_input_primitive_test.go index 6e9c7ed26..4e978e523 100644 --- a/internal/e2e/text_input_primitive_test.go +++ b/internal/e2e/text_input_primitive_test.go @@ -14,6 +14,10 @@ type nativeInputClient struct { text string selection ui.TextInputSelection mark *ui.TextInputRange + before string + beforeSel ui.TextInputSelection + composing bool + ended bool } func (c *nativeInputClient) TextForRange(r ui.TextInputRange) (string, ui.TextInputRange) { @@ -22,7 +26,7 @@ func (c *nativeInputClient) TextForRange(r ui.TextInputRange) (string, ui.TextIn } func (c *nativeInputClient) Selection() ui.TextInputSelection { return c.selection } func (c *nativeInputClient) MarkedRange() (ui.TextInputRange, bool) { - if c.mark != nil { + if c.mark != nil && !c.ended { return *c.mark, true } return ui.TextInputRange{}, false @@ -43,15 +47,28 @@ func (c *nativeInputClient) ReplaceText(r *ui.TextInputRange, s string) { end := wanted.Start + ui.UTF16Len(s) c.selection = ui.TextInputSelection{Range: ui.TextInputRange{Start: end, End: end}} c.mark = nil + c.composing, c.ended = false, false } func (c *nativeInputClient) SetMarkedText(r *ui.TextInputRange, s string, selected ui.TextInputRange) { + before, selection := c.before, c.beforeSel + if !c.composing { + before, selection = c.text, c.selection + } wanted := c.target(r) c.ReplaceText(&wanted, s) mark := ui.TextInputRange{Start: wanted.Start, End: wanted.Start + ui.UTF16Len(s)} c.mark = &mark + c.before, c.beforeSel, c.composing = before, selection, true c.selection = ui.TextInputSelection{Range: ui.TextInputRange{Start: mark.Start + selected.Start, End: mark.Start + selected.End}} } -func (c *nativeInputClient) UnmarkText() { c.mark = nil } +func (c *nativeInputClient) UnmarkText() { c.mark, c.composing, c.ended = nil, false, false } +func (c *nativeInputClient) EndComposition() { c.ended = c.composing } +func (c *nativeInputClient) CancelComposition() { + if c.composing { + c.text, c.selection = c.before, c.beforeSel + } + c.UnmarkText() +} func (c *nativeInputClient) BoundsForRange(r ui.TextInputRange) (ui.Rect, ui.TextInputRange, bool) { return ui.Rect{X: float32(r.Start)*8 + 8, Y: 8, W: 1, H: 20}, r, true } diff --git a/internal/platform/text_input.go b/internal/platform/text_input.go index 8f36c762d..e653b9df8 100644 --- a/internal/platform/text_input.go +++ b/internal/platform/text_input.go @@ -33,6 +33,7 @@ type TextInputClient interface { MarkedRange() (TextRange, bool) ReplaceText(*TextRange, string) SetMarkedText(*TextRange, string, TextRange) + EndComposition() UnmarkText() BoundsForRange(TextRange) (RectF, TextRange, bool) IndexForPoint(x, y float64) (int, bool) @@ -40,20 +41,27 @@ type TextInputClient interface { // InputComposition preserves the native end-preedit/commit ordering without // owning text. Some input methods unmark before delivering their result. -type InputComposition struct{ pending *TextRange } +type InputComposition struct { + pending *TextRange + client TextInputClient +} -func (c *InputComposition) Reset() { c.pending = nil } +func (c *InputComposition) Reset() { c.pending, c.client = nil, nil } func (c *InputComposition) End(client TextInputClient) { if r, ok := client.MarkedRange(); ok { - c.pending = &r + c.pending, c.client = &r, client } - client.UnmarkText() + client.EndComposition() } func (c *InputComposition) Replace(client TextInputClient, r *TextRange, text string) { + if c.client != nil && c.client != client { + c.Reset() + return + } if r == nil && c.pending != nil { r = c.pending } - c.pending = nil + c.Reset() client.ReplaceText(r, text) } diff --git a/ui/editor/build.go b/ui/editor/build.go index 2a171b7d9..7f850d0cb 100644 --- a/ui/editor/build.go +++ b/ui/editor/build.go @@ -66,8 +66,7 @@ func (ed *editor) build(c *ui.Context, buf *Buffer, o options) ui.Element { if !o.readOnly { e.HandleTextInput(ed).InputMethod(!o.noInputMethod) if o.noInputMethod && ed.markedActive { - // Input methods turned off leave what they composed. - ed.UnmarkText() + ed.CancelComposition() } } e.HandleInput(ed.input) diff --git a/ui/editor/editor.go b/ui/editor/editor.go index c224a1d34..2460e7e39 100644 --- a/ui/editor/editor.go +++ b/ui/editor/editor.go @@ -142,6 +142,9 @@ type editor struct { folds []fold marked []ui.TextInputRange // the composition, in bytes per selection markedActive bool + markedEnded bool + markedBefore Snapshot + markedSels []sel changeDepth int changeBefore Snapshot changePending bool @@ -363,6 +366,7 @@ func mapSel(s sel, cs []change) sel { // it of the old text. func (ed *editor) resetForBuffer() { ed.markedActive, ed.changePending, ed.changeBefore = false, false, Snapshot{} + ed.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil ed.sels = []sel{{goal: -1, id: ed.nextID}} ed.nextID++ ed.selUndo, ed.selRedo = nil, nil diff --git a/ui/editor/hooks_test.go b/ui/editor/hooks_test.go index 7e0d7d21b..3baa0683c 100644 --- a/ui/editor/hooks_test.go +++ b/ui/editor/hooks_test.go @@ -29,6 +29,11 @@ func TestOnChangeReportsCommittedOperations(t *testing.T) { if !ct.st.Composing() { t.Fatal("preedit was not exposed as composing") } + ed.EndComposition() + want("a") + if _, marked := ed.MarkedRange(); marked || !ct.st.Composing() { + t.Fatal("native end accepted rather than hid pending preedit") + } ed.ReplaceText(nil, "你😀") want("a", "a你😀") if ct.st.Composing() { @@ -38,6 +43,20 @@ func TestOnChangeReportsCommittedOperations(t *testing.T) { ed.UnmarkText() ed.UnmarkText() want("a", "a你😀", "a你😀中") + ct.tt.SetFocused(false) + want("a", "a你😀", "a你😀中") + if ct.st.Buffer.String() != "a你😀中" { + t.Fatal("release rolled back accepted input") + } + ct.tt.SetFocused(true) + ed.SetMarkedText(nil, "cancel", ui.TextInputRange{Start: 6, End: 6}) + ed.EndComposition() + ct.tt.SetFocused(false) + want("a", "a你😀", "a你😀中") + if ct.st.Buffer.String() != "a你😀中" || ct.st.Composing() { + t.Fatal("release retained unconfirmed preedit") + } + ct.tt.SetFocused(true) ct.st.Run(Undo) want("a", "a你😀", "a你😀中", "a你😀") ct.st.Run(Redo) diff --git a/ui/editor/input.go b/ui/editor/input.go index 5835a8554..5bbf4bb9a 100644 --- a/ui/editor/input.go +++ b/ui/editor/input.go @@ -619,7 +619,7 @@ func (ed *editor) Selection() ui.TextInputSelection { } func (ed *editor) MarkedRange() (ui.TextInputRange, bool) { - if !ed.markedActive || len(ed.marked) == 0 { + if !ed.markedActive || ed.markedEnded || len(ed.marked) == 0 { return ui.TextInputRange{}, false } t := &ed.view.text @@ -680,8 +680,10 @@ func (ed *editor) replaceText(r *ui.TextInputRange, s string) { ed.insert(s) return } + ed.markedEnded = false ts := ed.targets(r) ed.markedActive, ed.marked = false, nil + ed.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil ed.replaceRanges(ts, s, editCompose, -1, -1) if b := ed.buf(); b != nil { b.endGroup() @@ -698,6 +700,16 @@ func (ed *editor) setMarkedText(r *ui.TextInputRange, s string, selected ui.Text if ed.o.readOnly { return } + if s == "" { + ed.CancelComposition() + return + } + if !ed.markedActive { + ed.markedBefore = ed.buf().Snapshot() + ed.markedSels = slices.Clone(ed.sels) + ed.buf().endGroup() + } + ed.markedEnded = false ts := ed.targets(r) // Selected counts UTF-16 units of s. sa, sz := utf16ByteOffset(s, selected.Start), utf16ByteOffset(s, selected.End) @@ -776,11 +788,41 @@ func (ed *editor) UnmarkText() { ed.beginChange() defer ed.endChange() ed.markedActive, ed.marked = false, nil + ed.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil if b := ed.buf(); b != nil { b.endGroup() } } +func (ed *editor) EndComposition() { + ed.markedEnded = ed.markedActive +} + +func (ed *editor) CancelComposition() { + if !ed.markedActive { + return + } + if b := ed.buf(); b != nil { + b.mu.Lock() + if n := len(b.undo); n > 0 { + tx := b.undo[n-1] + if tx.owner == ed && tx.kind == editCompose && tx.before.Same(ed.markedBefore.t) && tx.after.Same(b.text) { + b.undoStep() + b.redo = nil + } + } + b.mu.Unlock() + } + ed.sync() + ed.sels = ed.markedSels + ed.markedActive, ed.marked = false, nil + ed.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil + ed.changePending, ed.changeBefore = false, Snapshot{} + if ed.changeDepth > 0 { + ed.changeBefore = ed.buf().Snapshot() + } +} + func (ed *editor) BoundsForRange(r ui.TextInputRange) (ui.Rect, ui.TextInputRange, bool) { ed.sync() t := &ed.view.text diff --git a/ui/editor/state.go b/ui/editor/state.go index 789b95e63..41c08a32a 100644 --- a/ui/editor/state.go +++ b/ui/editor/state.go @@ -277,6 +277,7 @@ func (st *State) SetTextFromModel(text string) { st.ensure() ed := &st.ed ed.markedActive, ed.marked = false, nil + ed.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil ed.changePending, ed.changeBefore = false, Snapshot{} if st.Buffer.String() != lf(text) { st.Buffer.Reset(text) diff --git a/ui/editor_input.go b/ui/editor_input.go index 37ba54fba..0392a4849 100644 --- a/ui/editor_input.go +++ b/ui/editor_input.go @@ -17,6 +17,7 @@ type widgetTextInput struct { rt *engine id uint64 processing bool + ended bool } var _ TextInputClient = (*widgetTextInput)(nil) @@ -163,7 +164,7 @@ func (w *widgetTextInput) Selection() TextInputSelection { func (w *widgetTextInput) MarkedRange() (TextInputRange, bool) { w.prepare() - if w.ed.compose == "" { + if w.ed.compose == "" || w.ended { return TextInputRange{}, false } start := w.ed.buf.utf16At(w.ed.caret) @@ -175,6 +176,7 @@ func (w *widgetTextInput) MarkedRange() (TextInputRange, bool) { func (w *widgetTextInput) ReplaceText(r *TextInputRange, s string) { w.prepare() + w.ended = false ed := w.ed if ed.readOnly { return @@ -211,6 +213,11 @@ func (w *widgetTextInput) SetMarkedText(r *TextInputRange, s string, selected Te if ed.readOnly { return } + if s == "" { + w.CancelComposition() + return + } + w.ended = false if ed.compose == "" || r != nil { a, z := ed.selection() ranges := ed.selectedRanges() @@ -248,11 +255,26 @@ func (w *widgetTextInput) SetMarkedText(r *TextInputRange, s string, selected Te func (w *widgetTextInput) UnmarkText() { w.prepare() ed := w.ed - transaction := ed.compositionActive ed.commitCompose() - // GTK/IMM32 can end preedit before committing their replacement. The - // platform remembers the range; both operations share this undo step. - ed.compositionActive = transaction + w.ended = false + w.publish() +} + +func (w *widgetTextInput) EndComposition() { + w.prepare() + w.ended = w.ed.compositionActive +} + +func (w *widgetTextInput) CancelComposition() { + w.prepare() + ed := w.ed + if !ed.compositionActive { + return + } + ed.compose, ed.compositionActive, w.ended = "", false, false + ed.composeCaret, ed.composeSelected = 0, TextInputRange{} + ed.takeBack(false) + ed.redo = nil w.publish() } diff --git a/ui/lui_list_window_test.go b/ui/lui_list_window_test.go index 6cebb2f56..d1212faa1 100644 --- a/ui/lui_list_window_test.go +++ b/ui/lui_list_window_test.go @@ -257,7 +257,7 @@ func TestListRequestedWindowFollowsPinnedKeyAcrossDistantReorder(t *testing.T) { // Native focus transfer ends preedit through the existing text-input client // boundary. A finished composition must not pin an otherwise unused row. -func TestListRequestedWindowCommitsNativeCompositionBeforeBlurEviction(t *testing.T) { +func TestListRequestedWindowCancelsNativeCompositionBeforeBlurEviction(t *testing.T) { var s ListState s.SetItemExtent(30) s.SetOverscan(1) @@ -291,8 +291,8 @@ func TestListRequestedWindowCommitsNativeCompositionBeforeBlurEviction(t *testin if _, marked := client.MarkedRange(); marked { t.Fatal("focus transfer left native preedit unresolved") } - if text != "ä½ " { - t.Fatalf("native composition was not committed before blur: %q", text) + if text != "" { + t.Fatalf("blur saved unfinished native composition instead of preserving original text: %q", text) } s.ScrollToEnd() tt.Frame() diff --git a/ui/richtext/ime.go b/ui/richtext/ime.go index 9bbf17e2b..d4e4806a5 100644 --- a/ui/richtext/ime.go +++ b/ui/richtext/ime.go @@ -96,7 +96,7 @@ func (v *view) Selection() ui.TextInputSelection { } func (v *view) MarkedRange() (ui.TextInputRange, bool) { - if v.compose == "" { + if v.compose == "" || v.compositionEnded { return ui.TextInputRange{}, false } start := v.units(v.caret) @@ -105,6 +105,7 @@ func (v *view) MarkedRange() (ui.TextInputRange, bool) { func (v *view) ReplaceText(r *ui.TextInputRange, s string) { v.sync() + v.compositionEnded = false if v.opts.ReadOnly { return } @@ -141,6 +142,11 @@ func (v *view) SetMarkedText(r *ui.TextInputRange, s string, selected ui.TextInp if v.opts.ReadOnly { return } + if s == "" { + v.CancelComposition() + return + } + v.compositionEnded = false if v.compose == "" || r != nil { a, z := v.selection() if r != nil { @@ -175,11 +181,31 @@ func (v *view) SetMarkedText(r *ui.TextInputRange, s string, selected ui.TextInp } func (v *view) UnmarkText() { - transaction := v.composing v.commitCompose() - // GTK and IMM32 can end the composition before committing their - // replacement, which shares its step of undo. - v.composing = transaction + v.compositionEnded = false + v.publish() +} + +func (v *view) EndComposition() { + v.compositionEnded = v.composing +} + +func (v *view) CancelComposition() { + v.sync() + if !v.composing { + return + } + restore := v.mode == sameStep + v.compose, v.composing, v.compositionEnded = "", false, false + v.composeCaret, v.composeSelected = 0, ui.TextInputRange{} + if restore { + v.doc.lock() + v.doc.takeBack(false) + v.doc.redo = nil + v.doc.mu.Unlock() + v.sync() + } + v.mode, v.coalesce = newStep, false v.publish() } diff --git a/ui/richtext/view.go b/ui/richtext/view.go index 4aceaab21..385bae72c 100644 --- a/ui/richtext/view.go +++ b/ui/richtext/view.go @@ -114,10 +114,11 @@ type view struct { desiredX float32 hasDesired bool // An input method's composition, shown at the caret. - compose string - composeCaret int - composeSelected ui.TextInputRange - composing bool + compose string + composeCaret int + composeSelected ui.TextInputRange + composing bool + compositionEnded bool // mode is how the next edit joins the steps of undo, coalesce whether // typing goes on. mode editMode diff --git a/ui/text_input_client.go b/ui/text_input_client.go index 7b1339093..dc5dc48d7 100644 --- a/ui/text_input_client.go +++ b/ui/text_input_client.go @@ -34,7 +34,10 @@ type Point struct{ X, Y float32 } // A nil replacement range means the current marked range, if present, // otherwise the selection. SetMarkedText's selected range is relative to // the new marked text. ReplaceText commits and clears preedit; UnmarkText -// clears marked status, retaining the document's text. Selection is +// explicitly accepts it. EndComposition removes marking without accepting +// pending text; CancelComposition restores the text before unconfirmed preedit. +// Release (focus loss, disposal or client replacement) cancels, not accepts. +// End and cancel must be idempotent and must not publish pending text. Selection is // queried again after every mutation, before the next native event. type TextInputClient interface { TextForRange(TextInputRange) (string, TextInputRange) @@ -42,6 +45,8 @@ type TextInputClient interface { MarkedRange() (TextInputRange, bool) ReplaceText(*TextInputRange, string) SetMarkedText(*TextInputRange, string, TextInputRange) + EndComposition() + CancelComposition() UnmarkText() BoundsForRange(TextInputRange) (Rect, TextInputRange, bool) IndexForPoint(Point) (int, bool) @@ -97,9 +102,7 @@ func (a *textInputAdapter) client() (TextInputClient, *state) { func (a *textInputAdapter) release() { if s := a.rt.states[a.id]; s != nil && s.textAdapter == a && s.textClient != nil { - if _, marked := s.textClient.MarkedRange(); marked { - s.textClient.UnmarkText() - } + s.textClient.CancelComposition() } } func (a *textInputAdapter) TextForRange(r platform.TextRange) (string, platform.TextRange) { @@ -142,6 +145,12 @@ func (a *textInputAdapter) UnmarkText() { a.changed() } } +func (a *textInputAdapter) EndComposition() { + if c, _ := a.client(); c != nil { + c.EndComposition() + a.changed() + } +} func (a *textInputAdapter) BoundsForRange(r platform.TextRange) (platform.RectF, platform.TextRange, bool) { if c, s := a.client(); c != nil { b, actual, ok := c.BoundsForRange(r) diff --git a/ui/text_input_client_test.go b/ui/text_input_client_test.go index 707f9e0f1..ce6fc40ac 100644 --- a/ui/text_input_client_test.go +++ b/ui/text_input_client_test.go @@ -13,6 +13,10 @@ type primitiveClient struct { mark *TextInputRange queries []TextInputRange changes, unmarks int + commits []string + before string + beforeSelection TextInputSelection + composing bool } func (c *primitiveClient) TextForRange(r TextInputRange) (string, TextInputRange) { @@ -46,15 +50,36 @@ func (c *primitiveClient) replace(r TextInputRange, text string) { func (c *primitiveClient) ReplaceText(r *TextInputRange, text string) { c.replace(c.rangeFor(r), text) c.mark = nil + c.composing = false + c.commits = append(c.commits, c.text) } func (c *primitiveClient) SetMarkedText(r *TextInputRange, text string, selected TextInputRange) { + if !c.composing { + c.before, c.beforeSelection, c.composing = c.text, c.selection, true + } wanted := c.rangeFor(r) c.replace(wanted, text) mark := TextInputRange{Start: wanted.Start, End: wanted.Start + UTF16Len(text)} c.mark = &mark c.selection = TextInputSelection{Range: TextInputRange{Start: mark.Start + selected.Start, End: mark.Start + selected.End}} } -func (c *primitiveClient) UnmarkText() { c.mark = nil; c.unmarks++ } +func (c *primitiveClient) EndComposition() { c.mark = nil } +func (c *primitiveClient) CancelComposition() { + if c.composing { + c.text, c.selection = c.before, c.beforeSelection + } + if c.composing || c.mark != nil { + c.unmarks++ + } + c.mark, c.composing = nil, false +} +func (c *primitiveClient) UnmarkText() { + if c.composing { + c.commits = append(c.commits, c.text) + } + c.mark, c.composing = nil, false + c.unmarks++ +} func (c *primitiveClient) BoundsForRange(r TextInputRange) (Rect, TextInputRange, bool) { return Rect{float32(r.Start) * 4, 5, max(1, float32(r.End-r.Start)*4), 20}, r, true } @@ -100,25 +125,49 @@ func TestTextInputClientCompositionAndReplacement(t *testing.T) { func TestTextInputClientNativeEndBeforeCommit(t *testing.T) { c := &primitiveClient{text: "abc", selection: TextInputSelection{Range: TextInputRange{Start: 3, End: 3}}} - tt := primitiveTester(c) + current := c + tt := coreNewTester(func(ctx *context) { + coreBox(ctx).Size(250, 80).HandleTextInput(current).AutoFocus() + }, 300, 100) defer tt.rt.close() client := tt.h.ime.Client client.SetMarkedText(nil, "😀", TextInputRange{Start: 2, End: 2}) var composition platform.InputComposition composition.End(client) - if c.mark != nil || c.text != "abc😀" { - t.Fatal("unmark removed document content") + if c.mark != nil || c.text != "abc😀" || len(c.commits) != 0 { + t.Fatal("native end accepted or removed pending preedit", c.text, c.commits) } composition.Replace(client, nil, "é") - if c.text != "abcé" { + if c.text != "abcé" || len(c.commits) != 1 { t.Fatal("end-before-commit duplicated preedit", c.text) } client.SetMarkedText(nil, "候補", TextInputRange{Start: 2, End: 2}) composition.End(client) - composition.Reset() // the native client was switched or reset - composition.Replace(client, nil, "!") - if c.text != "abcé候補!" { - t.Fatal("stale commit range survived reset", c.text) + tt.SetFocused(false) + if c.text != "abcé" || len(c.commits) != 1 { + t.Fatal("release saved pending preedit instead of restoring committed text", c.text, c.commits) + } + composition.Reset() + tt.SetFocused(true) + client.SetMarkedText(nil, "ä½ ", TextInputRange{Start: 1, End: 1}) + client.UnmarkText() + client.UnmarkText() + if c.text != "abcé你" || len(c.commits) != 2 { + t.Fatal("explicit native accept did not commit once", c.text, c.commits) + } + tt.SetFocused(false) + if c.text != "abcé你" || len(c.commits) != 2 { + t.Fatal("release rolled back committed input", c.text, c.commits) + } + tt.SetFocused(true) + client.SetMarkedText(nil, "old", TextInputRange{Start: 3, End: 3}) + composition.End(client) + current = &primitiveClient{text: "new"} + tt.Frame() + composition.Replace(tt.h.ime.Client, nil, "stale") + composition.Replace(client, nil, "stale") + if c.text != "abcé你" || len(c.commits) != 2 || current.text != "new" || len(current.commits) != 0 { + t.Fatal("old composition survived client replacement", c.text, c.commits, current.text) } } From 59f83324264abfbf739bae541ede2947aa01ff45 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 27 Oct 2026 20:38:08 +0000 Subject: [PATCH 19/38] fix(ui): cancel captured gestures when content becomes inert --- ui/runtime.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/runtime.go b/ui/runtime.go index c9cc345b4..a5346b274 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -1094,6 +1094,9 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden, pointerEvents bool) // the focus, and has no text to find; so does what is inert, which // shows. invisible := e.flags&(flagInvisible|flagInert) != 0 || hidden || e.backface && e.transform.facesAway() + if invisible && rt.pressed == s && s.capturedPointer { + rt.pointerCancel() + } if invisible { s.vw, s.vh = 0, 0 if rt.focused == e.id { From f6855100ebab60823549fc6a9c3c38a2ff1f6a90 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 05:19:47 +0000 Subject: [PATCH 20/38] feat(ui): expose window focus lifecycle revision --- docs/ui/migration.md | 31 +++++++++++++++++++++++++++++++ ui/context.go | 14 ++++++++++++++ ui/input.go | 6 ++++++ ui/runtime.go | 3 ++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index e30557799..3ce64b495 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,36 @@ # Migrating to checked elements +## Window focus lifecycle + +`Context.WindowFocusEpoch()` is an additive, UI-thread query of the window's +focus lifecycle, valid between builds and inside `CaptureInput`. Its nonzero +revision advances immediately when the real surface changes between focused +and blurred. Duplicate notifications do not advance it. A blur/refocus round +trip therefore changes the revision even if no render occurs. Nil contexts +and closed windows return zero. + +Applications retaining input-session ownership must capture this revision +when a session begins and compare it at event dispatch, alongside their +retained client identity and `Handle.Focused(c)`. Do not cache the revision +only during render: that misses a new session beginning before the next frame. + +```go +// On the UI thread, inside the window's capture callback: +if owner.client != currentClient || owner.epoch != c.WindowFocusEpoch() || !handle.Focused(c) { + clearOwner() +} +if startsSession && handle.Focused(c) { + owner.client = currentClient + owner.epoch = c.WindowFocusEpoch() +} +``` + +Revisions are scoped to one window/context, not globally unique client IDs. +They do not replace retained-node identity checks or track movement between +controls in a focused window. No event fields, input-consumption behavior, +composition phases, timers or render barriers change. This is a native-event +lifecycle contract, not a claim of real OS IME acceptance. + ## Code editor committed changes `editor.State.OnChange func(text string)` now reports each completed editing diff --git a/ui/context.go b/ui/context.go index 5591647ff..44f4a716e 100644 --- a/ui/context.go +++ b/ui/context.go @@ -70,6 +70,20 @@ type context struct { depthStarts []int } +// WindowFocusEpoch returns the window's current focus lifecycle revision. +// It starts at one and advances synchronously on each change between focused +// and blurred, even when no view is built between events. Duplicate focus or +// blur notifications do not advance it. It does not track control focus. +// Compare revisions only within the same Context, on the UI thread. This query +// is valid between builds, including CaptureInput; it does not request a frame. +// A nil context or a closed window returns zero. +func (c *Context) WindowFocusEpoch() uint64 { + if rt := c.runtime(); rt != nil { + return rt.windowFocusEpoch + } + return 0 +} + const chunkSize = 32 // alloc returns a zeroed element from the frame's arena. diff --git a/ui/input.go b/ui/input.go index 88e981136..5b0c0d30d 100644 --- a/ui/input.go +++ b/ui/input.go @@ -95,10 +95,16 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { case platform.SurfaceCommand: rt.editEvent(editEvent{kind: editCommand, text: ev.Text}) case platform.SurfaceFocus: + if !rt.windowFocused { + rt.windowFocusEpoch++ + } rt.windowFocused = true rt.blinkStart = rt.now() rt.focusChanged() case platform.SurfaceBlur: + if rt.windowFocused { + rt.windowFocusEpoch++ + } if rt.pressed != nil && rt.pressed.capturedPointer { rt.pointerCancel() } diff --git a/ui/runtime.go b/ui/runtime.go index a5346b274..934e62759 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -292,6 +292,7 @@ type engine struct { // windowFocus reads; focusRead, that the last frame read it, so that a // change of it draws a frame. windowFocused, focusRead bool + windowFocusEpoch uint64 // toasts are the toasts the window holds, oldest first, and // toastList those showing, as ToastViewportBase gives them; their time // stops while toastsPaused. A viewport of them was built in the pass @@ -451,7 +452,7 @@ type keyEvent struct { } func newRuntime(view func(*context), h host) *engine { - rt := &engine{view: view, host: h, text: textSystem(), states: map[uint64]*state{}, windowFocused: true} + rt := &engine{view: view, host: h, text: textSystem(), states: map[uint64]*state{}, windowFocused: true, windowFocusEpoch: 1} rt.c.rt = rt rt.owner = weak.Make(rt) rt.arena = &elementOwner{rt: rt} From f362d0510019014ef9af83e08b77d188f1368f60 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 05:22:29 +0000 Subject: [PATCH 21/38] docs(ui): clarify capture context query lifetime --- docs/ui/migration.md | 8 ++++++++ ui/context.go | 10 ++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index 3ce64b495..7fda1a45f 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -9,6 +9,14 @@ and blurred. Duplicate notifications do not advance it. A blur/refocus round trip therefore changes the revision even if no render occurs. Nil contexts and closed windows return zero. +The private frame builder and its Elements remain valid only during a view +build. This read-only query uses the stable public `Context` described in +`ui/frame.go`; it does not extend any frame-owned lifetime. For capture-time +checks, query the public context used to register the currently installed +`CaptureInput` callback, inside that callback. Keep only the revision value +as session ownership, not a render-time revision snapshot or an old callback's +frame data. `CaptureInput` supplies an event and target, not a new context. + Applications retaining input-session ownership must capture this revision when a session begins and compare it at event dispatch, alongside their retained client identity and `Handle.Focused(c)`. Do not cache the revision diff --git a/ui/context.go b/ui/context.go index 44f4a716e..8a4915faa 100644 --- a/ui/context.go +++ b/ui/context.go @@ -11,10 +11,10 @@ import ( "github.com/egoist/mygo/transfer" ) -// Context builds a window's user interface for one frame. The window's -// view function receives it on the main thread; it is only valid during -// that call. A frame may rebuild the view in several passes; neither a -// Context nor its Elements may be saved for a later call. +// context is the frame-scoped builder behind the public Context. The window's +// view uses it on the main thread, only for that call. Neither its frame data +// nor Elements may be retained. Explicitly documented UI-thread queries on +// the public Context, such as WindowFocusEpoch, have their own lifetime. type context struct { rt *engine parent *node @@ -76,6 +76,8 @@ type context struct { // blur notifications do not advance it. It does not track control focus. // Compare revisions only within the same Context, on the UI thread. This query // is valid between builds, including CaptureInput; it does not request a frame. +// In CaptureInput, query the Context that registered the current callback, +// not a revision cached during a view build. // A nil context or a closed window returns zero. func (c *Context) WindowFocusEpoch() uint64 { if rt := c.runtime(); rt != nil { From e14dc0d41a2043504d2cf87f3f5389dd439932ac Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 07:33:34 +0000 Subject: [PATCH 22/38] fix(ui): deliver capture-loss cancellation to input observers --- docs/ui/migration.md | 37 +++++++++++++++++++++++ ui/input.go | 35 ++++++++++++++-------- ui/input_capture.go | 70 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 123 insertions(+), 19 deletions(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index 7fda1a45f..fbeb4edc0 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,42 @@ # Migrating to checked elements +## Captured pointer cancellation + +`Context.CaptureInput` now observes `InputPointerCancel` when an outstanding +press loses native capture, including a down it prevented or a plain hit with +no native pointer owner. An observed press also cancels on window blur; an +owned `HandleCapturedPointer` gesture retains its existing cancellation paths. +No frame or focus change is required for native capture loss to end a gesture. + +Cancellation first clears the old press and click candidate, then notifies +capture, then the original native handler. Returning true cannot veto the +native owner's cancellation. Reentrant cancellation or window closure cannot +revive the old click candidate. A later real up cannot click that candidate; +a fresh down retains the existing release/click recognition rules. Completed +gestures do not receive a second cancellation from late capture-loss events. + +The cancellation target is the original committed hit, not a new hit under the +pointer. If that identity has retired, the target is empty: all `Distance` +queries return -1, but window-level cleanup must still run. Pointer coordinates +remain in window DIPs. Legacy native handlers keep their existing synthetic +release cleanup; capture receives cancellation, never that synthetic release. + +Handle cancellation before target filtering, discard previews and clear held +buttons instead of committing them. Do not turn it into a release or a click: + +```go +c.CaptureInput(func(ev ui.InputEvent, target ui.InputTarget) bool { + if ev.Kind == ui.InputPointerCancel { + cancelPointerGesture() + return false + } + return routeInput(ev, target) +}) +``` + +This changes no surface event, focus epoch, composition protocol or wire schema. +It is a synchronous runtime contract, not a claim of GUI or OS IME acceptance. + ## Window focus lifecycle `Context.WindowFocusEpoch()` is an additive, UI-thread query of the window's diff --git a/ui/input.go b/ui/input.go index 5b0c0d30d..26e5122c5 100644 --- a/ui/input.go +++ b/ui/input.go @@ -107,6 +107,8 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { } if rt.pressed != nil && rt.pressed.capturedPointer { rt.pointerCancel() + } else { + rt.cancelCapturePointer() } rt.windowFocused = false if rt.captureClicks != nil { @@ -434,22 +436,29 @@ func (rt *engine) endDown() { func (rt *engine) pointerCancel() { p := rt.pressed - if p == nil { - return + ev := InputEvent{Kind: InputPointerCancel, Button: rt.pressButton, Mods: rt.mods, Time: rt.now()} + var handler func(InputEvent) bool + if p != nil { + handler = p.input + ev.X, ev.Y = p.local(rt.pointerX, rt.pointerY) + if !p.capturedPointer { + // Legacy handlers ended their gesture on a synthetic release. + ev.Kind = InputPointerUp + } + p.pressed, p.pressPending, p.released, p.capturedPointer = false, false, false, false } - owned := p.capturedPointer rt.pressed = nil rt.pressDragger = 0 rt.downs = rt.downs[:0] - rt.captureClicks, rt.releaseCapture = nil, nil - p.pressed, p.pressPending, p.released, p.capturedPointer = false, false, false, false - if owned { - rt.deliver(p, InputEvent{Kind: InputPointerCancel, Button: rt.pressButton, Mods: rt.mods}) - } else { - // Legacy handlers ended their gesture on a synthetic release. - rt.deliver(p, InputEvent{Kind: InputPointerUp, Button: rt.pressButton, Mods: rt.mods}) + rt.cancelCapturePointer() + // Capture may close the window or retire the owner. Notify the original + // handler once, without looking up a replacement or allowing a veto. + if handler != nil { + handler(ev) + } + if p != nil && !rt.closed && !rt.closing { + rt.requestFrame() } - rt.requestFrame() } // countPress counts the backend's n-th quick press from the first on the @@ -488,7 +497,9 @@ func (rt *engine) pointerUp(button, clicks int) { rt.scrollDrag.st = nil rt.selection.dragging = false rt.dragCancel() - rt.requestFrame() + if !rt.closed && !rt.closing { + rt.requestFrame() + } return } if rt.scrollDrag.st != nil { diff --git a/ui/input_capture.go b/ui/input_capture.go index 147aac2d4..547260bb8 100644 --- a/ui/input_capture.go +++ b/ui/input_capture.go @@ -42,6 +42,9 @@ func (t InputTarget) Distance(h Handle) int { // that omit the registration remove it. The callback must not build UI or run // a nested event loop. Inspector-owned input is private to developer tools. // Events and pointer coordinates use window DIPs. +// InputPointerCancel ends an observed press, even when its down was prevented +// or had no native owner. It cannot be prevented: true does not stop the native +// owner from cancelling. Its target is the original hit, or empty if retired. func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { if raw := c.build(); raw != nil { rt := raw.rt @@ -61,23 +64,47 @@ func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { // inputCaptureState belongs only to windows that register capture. Plain hits // have no native press: track their gesture without making them controls. type inputCaptureState struct { - rt *engine - fn func(InputEvent, InputTarget) bool - id, born uint64 - x, y float32 - clicks int - plain, released bool + rt *engine + fn func(InputEvent, InputTarget) bool + id, born uint64 + x, y float32 + clicks int + plain, released bool + held uint8 + pressID, pressBorn uint64 + revision uint64 } func (s *inputCaptureState) input(ev InputEvent, target InputTarget) bool { + switch ev.Kind { + case InputPointerDown: + s.revision++ + button := uint8(1) << uint(ev.Button) + if s.held == 0 || s.held&button != 0 { + s.pressID, s.pressBorn = target.id, 0 + if hit := s.rt.states[target.id]; hit != nil { + s.pressBorn = hit.born + } + } + s.held |= button + case InputPointerUp: + s.held &^= uint8(1) << uint(ev.Button) + } if ev.Kind == InputPointerMove && s.id != 0 { dx, dy := ev.X-s.x, ev.Y-s.y if dx*dx+dy*dy >= dragStart*dragStart { s.id = 0 } } + revision := s.revision prevented := s.fn(ev, target) switch ev.Kind { + case InputPointerDown, InputPointerUp, InputClick: + if revision != s.revision || s.rt.closed || s.rt.closing || s.rt.captureClicks != s { + return true + } + } + switch ev.Kind { case InputPointerDown: if ev.Button == 0 { // Canceling down suppresses native focus/press, not the independent @@ -101,6 +128,34 @@ func (s *inputCaptureState) input(ev InputEvent, target InputTarget) bool { return prevented } +// cancelCapturePointer disarms the gesture before notifying capture. The click +// candidate alone cannot identify a press: movement and prevention clear it. +func (rt *engine) cancelCapturePointer() { + s := rt.captureClicks + if s == nil { + return + } + held, id := s.held, s.pressID + if hit := rt.states[id]; hit == nil || hit.born != s.pressBorn || hit.seen != rt.frame || hit.pass != rt.pass { + id = 0 + } + s.revision++ + s.held, s.pressID, s.pressBorn = 0, 0, 0 + s.id, s.plain, s.released = 0, false, false + if held == 0 { + return + } + button := 0 + if held&1 == 0 { + button = 1 + if held&2 == 0 { + button = 2 + } + } + // Cancellation is a fact, not a preventable default action. + rt.captureInput(InputEvent{Kind: InputPointerCancel, Button: button, Mods: rt.mods, X: rt.pointerX, Y: rt.pointerY}, id) +} + func (s *inputCaptureState) release(button int) { if button != 0 { return @@ -129,8 +184,9 @@ func (rt *engine) captureInput(ev InputEvent, id uint64) bool { return false } rt.captureSerial++ + previous := rt.capturing rt.capturing = rt.captureSerial - defer func() { rt.capturing = 0 }() + defer func() { rt.capturing = previous }() ev.Time = rt.frameTime() return rt.inputCapture(ev, InputTarget{owner: rt.owner, id: id, serial: rt.capturing}) } From e6b9038057ef7fab2c81a11013d5a4ec9983e36b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 07:42:52 +0000 Subject: [PATCH 23/38] fix(ui): register capture cancellation only when used --- ui/input_capture.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/input_capture.go b/ui/input_capture.go index 547260bb8..e847a0392 100644 --- a/ui/input_capture.go +++ b/ui/input_capture.go @@ -54,6 +54,7 @@ func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { } if rt.captureClicks == nil { rt.captureClicks = &inputCaptureState{rt: rt} + rt.captureClicks.cancel = rt.captureClicks.cancelPointer rt.releaseCapture = rt.captureClicks.release } rt.captureClicks.fn = fn @@ -66,6 +67,7 @@ func (c *Context) CaptureInput(fn func(InputEvent, InputTarget) bool) { type inputCaptureState struct { rt *engine fn func(InputEvent, InputTarget) bool + cancel func() id, born uint64 x, y float32 clicks int @@ -128,13 +130,16 @@ func (s *inputCaptureState) input(ev InputEvent, target InputTarget) bool { return prevented } -// cancelCapturePointer disarms the gesture before notifying capture. The click -// candidate alone cannot identify a press: movement and prevention clear it. func (rt *engine) cancelCapturePointer() { - s := rt.captureClicks - if s == nil { - return + if s := rt.captureClicks; s != nil { + s.cancel() } +} + +// cancelPointer disarms the gesture before notifying capture. The click +// candidate alone cannot identify a press: movement and prevention clear it. +func (s *inputCaptureState) cancelPointer() { + rt := s.rt held, id := s.held, s.pressID if hit := rt.states[id]; hit == nil || hit.born != s.pressBorn || hit.seen != rt.frame || hit.pass != rt.pass { id = 0 From 6efea658eff864e243297e2f99a7fc7c35c50db0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 08:59:19 +0000 Subject: [PATCH 24/38] fix(ui): publish final caret after frame callbacks --- docs/ui/migration.md | 13 +++++++++++++ ui/runtime.go | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index fbeb4edc0..0e2a437bf 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,18 @@ # Migrating to checked elements +## Final frame text-input geometry + +Full and repaint frames now publish the text-input caret after all `AfterFrame` +callbacks finish. Custom `HandleTextInput` clients can acquire current +`CommittedSpace` and `CommittedText` geometry in those callbacks; the final +caret sent to the platform uses that geometry rather than the element-origin +fallback queried before it was available. Geometry still expires at the next +build, and closing a window from a callback prevents further publication. + +Non-frame input and client mutations still synchronize text input immediately. +They can precede layout, so this does not guarantee zero transient fallback or +qualify GUI or OS-specific IME positioning. + ## Captured pointer cancellation `Context.CaptureInput` now observes `InputPointerCancel` when an outstanding diff --git a/ui/runtime.go b/ui/runtime.go index 934e62759..d4b4e4b29 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -629,7 +629,6 @@ func (rt *engine) runFrame() { rt.text.EndFrame() rt.settleKeys() rt.regs, rt.nextRegs = rt.nextRegs, rt.regs - rt.updateTextInput() rt.updateCursor() if rt.access { rt.host.updateAccessibility(rt.accessTree()) @@ -989,6 +988,10 @@ func (rt *engine) endFrame() { } if rt.closing { rt.close() + return + } + if !rt.closed { + rt.updateTextInput() } } From b2f717d9ae3d29dccf49c122a49226f6ab3d64a6 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 16:24:08 +0000 Subject: [PATCH 25/38] fix(ui): preserve overlay focus return owners --- docs/architecture.md | 8 +++-- ui/runtime.go | 19 +++++++----- ui/scope.go | 73 ++++++++++++++++++++++++++++++++++++++++---- ui/toast.go | 3 +- ui/widgets.go | 5 +++ 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index db58bc9be..cedd14d65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2921,8 +2921,12 @@ either. above it. Overlays register Escape as overlay shortcuts (`OverlayShortcut`), which the keys the focus and the elements around it leave reach, the last registered (the overlay on top) first. Each element - built at the top of `Overlay` notes the focus as it opens (`openers`), - which pruning gives back once it is gone with the focus that was in it. + built at the top of `Overlay` notes the focus as it opens (`openers`). + After collecting the committed focus order, before `arrangeFocus` and + pruning, return targets follow retired overlay owners only, stopping at + a live owner. A closing overlay gives focus back to a focusable opener + compatible with the remaining modal, without replacing live focus + elsewhere. An element attached to another (`AttachTo`, which `PopoverBase` and the popups of selects and comboboxes use) is that element's popover (`Element.popover`, and `state.anchor` once committed): its layout diff --git a/ui/runtime.go b/ui/runtime.go index d4b4e4b29..fe45ba0d4 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -197,11 +197,13 @@ type engine struct { // openers are the elements that had the focus as overlays opened, by // overlay; downs the elements the pointer went down on since the last // pass (0 for none), for PressedOutside. - openers map[uint64]uint64 - downs []uint64 - regs []shortcutReg - nextRegs []shortcutReg - delivered []shortcutReg + openers map[uint64]uint64 + // restoreOverlayFocus is linked only by views that open overlays. + restoreOverlayFocus func(*engine, uint64) + downs []uint64 + regs []shortcutReg + nextRegs []shortcutReg + delivered []shortcutReg // trans are the transitions of the elements given one, by ID // (transition.go): exitsBuilt tells that the last frame built elements @@ -857,9 +859,8 @@ func (rt *engine) prune() { } } } - rt.restoreFocus() // The focus does not stay in a page kept out of sight. - if s := rt.states[rt.focused]; s != nil && (s.seen != rt.frame || s.pass != rt.pass) { + if s := rt.states[rt.focused]; s == nil || s.seen != rt.frame || s.pass != rt.pass { rt.focused = 0 } } @@ -998,6 +999,7 @@ func (rt *engine) endFrame() { // commit records the laid out frame in the elements' states: their // boxes, the hit list in paint order, the focus order. func (rt *engine) commit(root *node, w, h float32) { + previousModal := rt.modalLayer rt.hits = rt.hits[:0] clear(rt.posed) rt.posed = rt.posed[:0] @@ -1015,6 +1017,9 @@ func (rt *engine) commit(root *node, w, h float32) { full := Rect{0, 0, w, h} rt.commitElement(root, full, false, true) rt.inkStale = false + if rt.restoreOverlayFocus != nil { + rt.restoreOverlayFocus(rt, previousModal) + } rt.arrangeFocus() rt.noteGroups() rt.committedEpoch = rt.epoch diff --git a/ui/scope.go b/ui/scope.go index 4ea687144..f4d4edd7d 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -140,16 +140,77 @@ func (rt *engine) openOverlay(back *node) { // restoreFocus gives the focus back to the element that had it as an // overlay opened, once the overlay is gone with the focus that was in it. -func (rt *engine) restoreFocus() { +func (rt *engine) restoreFocus(previousModal uint64) { + if len(rt.openers) == 0 { + return + } + live := func(id uint64) bool { + s := rt.states[id] + return s != nil && s.seen == rt.frame && s.pass == rt.pass + } + owner := func(id uint64) uint64 { + for n := len(rt.states); id != 0 && n > 0; n-- { + if _, ok := rt.openers[id]; ok { + return id + } + s := rt.states[id] + if s == nil { + break + } + id = s.parent + } + return 0 + } + resolve := func(id uint64) uint64 { + for n := len(rt.openers); id != 0 && n >= 0; n-- { + if live(id) { + if slices.Contains(rt.focusOrder, id) { + return id + } + return 0 + } + o := owner(id) + if o == 0 || live(o) { + break + } + id = rt.openers[o] + } + return 0 + } + // Read every return chain before changing the map or recycling states. + type focusReturn struct{ overlay, target uint64 } + returns := make([]focusReturn, 0, len(rt.openers)) for id, opener := range rt.openers { - if rt.states[id] != nil { - continue + if !live(opener) { + opener = resolve(opener) } - delete(rt.openers, id) - if rt.states[rt.focused] == nil && rt.states[opener] != nil { - rt.focused = opener + returns = append(returns, focusReturn{id, opener}) + } + focused := rt.focused + if !live(focused) { + focused = 0 + o := owner(rt.focused) + // A press on a modal backdrop can clear the focus before it closes. + if rt.focused == 0 { + o = owner(previousModal) + } + if o != 0 && !live(o) { + focused = resolve(rt.openers[o]) + // Future return owners may be outside the current modal; only + // an immediate restoration must stay in the remaining one. + if rt.modal != 0 && rt.scopeOf(focused) != rt.modal { + focused = 0 + } + } + } + for _, r := range returns { + if live(r.overlay) { + rt.openers[r.overlay] = r.target + } else { + delete(rt.openers, r.overlay) } } + rt.focused = focused } // insideModal reports whether the elements being built are in the dialog diff --git a/ui/toast.go b/ui/toast.go index da9ebd4ac..f5a51b632 100644 --- a/ui/toast.go +++ b/ui/toast.go @@ -103,6 +103,7 @@ func (c *context) ToastAction(message, label string, action func()) { // them with ToastViewportBase. func (c *context) AddToast(t Toast) string { rt := c.rt + rt.restoreOverlayFocus = (*engine).restoreFocus rt.nextToast++ if t.ID == "" { t.ID = "toast-" + strconv.FormatUint(rt.nextToast, 10) @@ -244,7 +245,7 @@ func toastViewport(c *context, keep bool, fn func(viewport *node, toasts []Toast } rt.toastList = list var viewport *node - coreOverlay(c, func() { + buildOverlay(c, func() { // Keyed, as an element of the engine's own: in the place of the // overlay's first child, it would take the identity, and so the // hover and the focus, of what a dialog that just closed built diff --git a/ui/widgets.go b/ui/widgets.go index 51b21d61e..a9f182944 100644 --- a/ui/widgets.go +++ b/ui/widgets.go @@ -570,6 +570,11 @@ func (e *node) Fit(f Fit) *node { e.fit = f; return e } // another element with AttachTo. Each, as it goes with the focus in it, // gives the focus back to the element that had it as it came. func coreOverlay(c *context, fn func()) { + c.rt.restoreOverlayFocus = (*engine).restoreFocus + buildOverlay(c, fn) +} + +func buildOverlay(c *context, fn func()) { saved := c.parent o := c.overlayRoot() c.parent = o From 43c83de431ec4dbc591e37bf97dc5456ecbeb3e6 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 16:41:45 +0000 Subject: [PATCH 26/38] fix(ui): restore light-dismissed popover focus --- docs/architecture.md | 5 ++++- ui/runtime.go | 3 +++ ui/scope.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index cedd14d65..1e6c32ecd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2926,7 +2926,10 @@ either. pruning, return targets follow retired overlay owners only, stopping at a live owner. A closing overlay gives focus back to a focusable opener compatible with the remaining modal, without replacing live focus - elsewhere. + elsewhere. Outside presses record their overlay owners until the final + commit, across rebuild passes. Only retired owners on one return chain + can restore cleared focus; a query without closure restores nothing. + The frame consumes these candidates, including on an early exit. An element attached to another (`AttachTo`, which `PopoverBase` and the popups of selects and comboboxes use) is that element's popover (`Element.popover`, and `state.anchor` once committed): its layout diff --git a/ui/runtime.go b/ui/runtime.go index fe45ba0d4..2757a222e 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -198,6 +198,8 @@ type engine struct { // overlay; downs the elements the pointer went down on since the last // pass (0 for none), for PressedOutside. openers map[uint64]uint64 + // outsideFocus records overlay owners reported by PressedOutside this frame. + outsideFocus []uint64 // restoreOverlayFocus is linked only by views that open overlays. restoreOverlayFocus func(*engine, uint64) downs []uint64 @@ -979,6 +981,7 @@ func (rt *engine) close() { // endFrame ends a frame, and closes the engine when its window closed // during it. func (rt *engine) endFrame() { + rt.outsideFocus = rt.outsideFocus[:0] rt.inFrame = false callbacks := rt.afterFrame rt.afterFrame = nil diff --git a/ui/scope.go b/ui/scope.go index f4d4edd7d..0684b79d4 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -141,6 +141,8 @@ func (rt *engine) openOverlay(back *node) { // restoreFocus gives the focus back to the element that had it as an // overlay opened, once the overlay is gone with the focus that was in it. func (rt *engine) restoreFocus(previousModal uint64) { + outside := rt.outsideFocus + rt.outsideFocus = rt.outsideFocus[:0] if len(rt.openers) == 0 { return } @@ -161,6 +163,18 @@ func (rt *engine) restoreFocus(previousModal uint64) { } return 0 } + onReturnChain := func(id, target uint64) bool { + for n := len(rt.openers); id != 0 && n > 0; n-- { + if id == target { + return true + } + if live(id) { + break + } + id = owner(rt.openers[id]) + } + return false + } resolve := func(id uint64) uint64 { for n := len(rt.openers); id != 0 && n >= 0; n-- { if live(id) { @@ -190,9 +204,27 @@ func (rt *engine) restoreFocus(previousModal uint64) { if !live(focused) { focused = 0 o := owner(rt.focused) - // A press on a modal backdrop can clear the focus before it closes. if rt.focused == 0 { - o = owner(previousModal) + // Outside presses and modal backdrops can clear focus before closing. + candidates := append(outside, owner(previousModal)) + ambiguous := false + for i, id := range candidates { + if id == 0 || live(id) { + continue + } + for _, other := range candidates[:i] { + if other != 0 && !live(other) && + !onReturnChain(id, other) && !onReturnChain(other, id) { + ambiguous = true + } + } + if o == 0 || onReturnChain(o, id) { + o = id + } + } + if ambiguous { + o = 0 + } } if o != 0 && !live(o) { focused = resolve(rt.openers[o]) @@ -276,6 +308,14 @@ func (e *node) PressedOutside() bool { if rt.pressedWithin(rt.downs[len(rt.downs)-1], e) { return false } + for p := e; p != nil; p = p.parent { + if _, ok := rt.openers[p.id]; ok { + if !slices.Contains(rt.outsideFocus, p.id) { + rt.outsideFocus = append(rt.outsideFocus, p.id) + } + break + } + } rt.consumed = true return true } From 992640dd4af612cff64ce91ebb7a834117f63402 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 16:24:08 +0000 Subject: [PATCH 27/38] fix(ui): preserve overlay focus return owners --- docs/architecture.md | 8 +++-- ui/runtime.go | 19 +++++++----- ui/scope.go | 73 ++++++++++++++++++++++++++++++++++++++++---- ui/toast.go | 3 +- ui/widgets.go | 5 +++ 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index db58bc9be..cedd14d65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2921,8 +2921,12 @@ either. above it. Overlays register Escape as overlay shortcuts (`OverlayShortcut`), which the keys the focus and the elements around it leave reach, the last registered (the overlay on top) first. Each element - built at the top of `Overlay` notes the focus as it opens (`openers`), - which pruning gives back once it is gone with the focus that was in it. + built at the top of `Overlay` notes the focus as it opens (`openers`). + After collecting the committed focus order, before `arrangeFocus` and + pruning, return targets follow retired overlay owners only, stopping at + a live owner. A closing overlay gives focus back to a focusable opener + compatible with the remaining modal, without replacing live focus + elsewhere. An element attached to another (`AttachTo`, which `PopoverBase` and the popups of selects and comboboxes use) is that element's popover (`Element.popover`, and `state.anchor` once committed): its layout diff --git a/ui/runtime.go b/ui/runtime.go index d4b4e4b29..fe45ba0d4 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -197,11 +197,13 @@ type engine struct { // openers are the elements that had the focus as overlays opened, by // overlay; downs the elements the pointer went down on since the last // pass (0 for none), for PressedOutside. - openers map[uint64]uint64 - downs []uint64 - regs []shortcutReg - nextRegs []shortcutReg - delivered []shortcutReg + openers map[uint64]uint64 + // restoreOverlayFocus is linked only by views that open overlays. + restoreOverlayFocus func(*engine, uint64) + downs []uint64 + regs []shortcutReg + nextRegs []shortcutReg + delivered []shortcutReg // trans are the transitions of the elements given one, by ID // (transition.go): exitsBuilt tells that the last frame built elements @@ -857,9 +859,8 @@ func (rt *engine) prune() { } } } - rt.restoreFocus() // The focus does not stay in a page kept out of sight. - if s := rt.states[rt.focused]; s != nil && (s.seen != rt.frame || s.pass != rt.pass) { + if s := rt.states[rt.focused]; s == nil || s.seen != rt.frame || s.pass != rt.pass { rt.focused = 0 } } @@ -998,6 +999,7 @@ func (rt *engine) endFrame() { // commit records the laid out frame in the elements' states: their // boxes, the hit list in paint order, the focus order. func (rt *engine) commit(root *node, w, h float32) { + previousModal := rt.modalLayer rt.hits = rt.hits[:0] clear(rt.posed) rt.posed = rt.posed[:0] @@ -1015,6 +1017,9 @@ func (rt *engine) commit(root *node, w, h float32) { full := Rect{0, 0, w, h} rt.commitElement(root, full, false, true) rt.inkStale = false + if rt.restoreOverlayFocus != nil { + rt.restoreOverlayFocus(rt, previousModal) + } rt.arrangeFocus() rt.noteGroups() rt.committedEpoch = rt.epoch diff --git a/ui/scope.go b/ui/scope.go index 4ea687144..f4d4edd7d 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -140,16 +140,77 @@ func (rt *engine) openOverlay(back *node) { // restoreFocus gives the focus back to the element that had it as an // overlay opened, once the overlay is gone with the focus that was in it. -func (rt *engine) restoreFocus() { +func (rt *engine) restoreFocus(previousModal uint64) { + if len(rt.openers) == 0 { + return + } + live := func(id uint64) bool { + s := rt.states[id] + return s != nil && s.seen == rt.frame && s.pass == rt.pass + } + owner := func(id uint64) uint64 { + for n := len(rt.states); id != 0 && n > 0; n-- { + if _, ok := rt.openers[id]; ok { + return id + } + s := rt.states[id] + if s == nil { + break + } + id = s.parent + } + return 0 + } + resolve := func(id uint64) uint64 { + for n := len(rt.openers); id != 0 && n >= 0; n-- { + if live(id) { + if slices.Contains(rt.focusOrder, id) { + return id + } + return 0 + } + o := owner(id) + if o == 0 || live(o) { + break + } + id = rt.openers[o] + } + return 0 + } + // Read every return chain before changing the map or recycling states. + type focusReturn struct{ overlay, target uint64 } + returns := make([]focusReturn, 0, len(rt.openers)) for id, opener := range rt.openers { - if rt.states[id] != nil { - continue + if !live(opener) { + opener = resolve(opener) } - delete(rt.openers, id) - if rt.states[rt.focused] == nil && rt.states[opener] != nil { - rt.focused = opener + returns = append(returns, focusReturn{id, opener}) + } + focused := rt.focused + if !live(focused) { + focused = 0 + o := owner(rt.focused) + // A press on a modal backdrop can clear the focus before it closes. + if rt.focused == 0 { + o = owner(previousModal) + } + if o != 0 && !live(o) { + focused = resolve(rt.openers[o]) + // Future return owners may be outside the current modal; only + // an immediate restoration must stay in the remaining one. + if rt.modal != 0 && rt.scopeOf(focused) != rt.modal { + focused = 0 + } + } + } + for _, r := range returns { + if live(r.overlay) { + rt.openers[r.overlay] = r.target + } else { + delete(rt.openers, r.overlay) } } + rt.focused = focused } // insideModal reports whether the elements being built are in the dialog diff --git a/ui/toast.go b/ui/toast.go index da9ebd4ac..f5a51b632 100644 --- a/ui/toast.go +++ b/ui/toast.go @@ -103,6 +103,7 @@ func (c *context) ToastAction(message, label string, action func()) { // them with ToastViewportBase. func (c *context) AddToast(t Toast) string { rt := c.rt + rt.restoreOverlayFocus = (*engine).restoreFocus rt.nextToast++ if t.ID == "" { t.ID = "toast-" + strconv.FormatUint(rt.nextToast, 10) @@ -244,7 +245,7 @@ func toastViewport(c *context, keep bool, fn func(viewport *node, toasts []Toast } rt.toastList = list var viewport *node - coreOverlay(c, func() { + buildOverlay(c, func() { // Keyed, as an element of the engine's own: in the place of the // overlay's first child, it would take the identity, and so the // hover and the focus, of what a dialog that just closed built diff --git a/ui/widgets.go b/ui/widgets.go index 51b21d61e..a9f182944 100644 --- a/ui/widgets.go +++ b/ui/widgets.go @@ -570,6 +570,11 @@ func (e *node) Fit(f Fit) *node { e.fit = f; return e } // another element with AttachTo. Each, as it goes with the focus in it, // gives the focus back to the element that had it as it came. func coreOverlay(c *context, fn func()) { + c.rt.restoreOverlayFocus = (*engine).restoreFocus + buildOverlay(c, fn) +} + +func buildOverlay(c *context, fn func()) { saved := c.parent o := c.overlayRoot() c.parent = o From 088012f759d2c95a3db50bde38a7eac290aed8b3 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 28 Oct 2026 16:41:45 +0000 Subject: [PATCH 28/38] fix(ui): restore light-dismissed popover focus --- docs/architecture.md | 5 ++++- ui/runtime.go | 3 +++ ui/scope.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index cedd14d65..1e6c32ecd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2926,7 +2926,10 @@ either. pruning, return targets follow retired overlay owners only, stopping at a live owner. A closing overlay gives focus back to a focusable opener compatible with the remaining modal, without replacing live focus - elsewhere. + elsewhere. Outside presses record their overlay owners until the final + commit, across rebuild passes. Only retired owners on one return chain + can restore cleared focus; a query without closure restores nothing. + The frame consumes these candidates, including on an early exit. An element attached to another (`AttachTo`, which `PopoverBase` and the popups of selects and comboboxes use) is that element's popover (`Element.popover`, and `state.anchor` once committed): its layout diff --git a/ui/runtime.go b/ui/runtime.go index fe45ba0d4..2757a222e 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -198,6 +198,8 @@ type engine struct { // overlay; downs the elements the pointer went down on since the last // pass (0 for none), for PressedOutside. openers map[uint64]uint64 + // outsideFocus records overlay owners reported by PressedOutside this frame. + outsideFocus []uint64 // restoreOverlayFocus is linked only by views that open overlays. restoreOverlayFocus func(*engine, uint64) downs []uint64 @@ -979,6 +981,7 @@ func (rt *engine) close() { // endFrame ends a frame, and closes the engine when its window closed // during it. func (rt *engine) endFrame() { + rt.outsideFocus = rt.outsideFocus[:0] rt.inFrame = false callbacks := rt.afterFrame rt.afterFrame = nil diff --git a/ui/scope.go b/ui/scope.go index f4d4edd7d..0684b79d4 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -141,6 +141,8 @@ func (rt *engine) openOverlay(back *node) { // restoreFocus gives the focus back to the element that had it as an // overlay opened, once the overlay is gone with the focus that was in it. func (rt *engine) restoreFocus(previousModal uint64) { + outside := rt.outsideFocus + rt.outsideFocus = rt.outsideFocus[:0] if len(rt.openers) == 0 { return } @@ -161,6 +163,18 @@ func (rt *engine) restoreFocus(previousModal uint64) { } return 0 } + onReturnChain := func(id, target uint64) bool { + for n := len(rt.openers); id != 0 && n > 0; n-- { + if id == target { + return true + } + if live(id) { + break + } + id = owner(rt.openers[id]) + } + return false + } resolve := func(id uint64) uint64 { for n := len(rt.openers); id != 0 && n >= 0; n-- { if live(id) { @@ -190,9 +204,27 @@ func (rt *engine) restoreFocus(previousModal uint64) { if !live(focused) { focused = 0 o := owner(rt.focused) - // A press on a modal backdrop can clear the focus before it closes. if rt.focused == 0 { - o = owner(previousModal) + // Outside presses and modal backdrops can clear focus before closing. + candidates := append(outside, owner(previousModal)) + ambiguous := false + for i, id := range candidates { + if id == 0 || live(id) { + continue + } + for _, other := range candidates[:i] { + if other != 0 && !live(other) && + !onReturnChain(id, other) && !onReturnChain(other, id) { + ambiguous = true + } + } + if o == 0 || onReturnChain(o, id) { + o = id + } + } + if ambiguous { + o = 0 + } } if o != 0 && !live(o) { focused = resolve(rt.openers[o]) @@ -276,6 +308,14 @@ func (e *node) PressedOutside() bool { if rt.pressedWithin(rt.downs[len(rt.downs)-1], e) { return false } + for p := e; p != nil; p = p.parent { + if _, ok := rt.openers[p.id]; ok { + if !slices.Contains(rt.outsideFocus, p.id) { + rt.outsideFocus = append(rt.outsideFocus, p.id) + } + break + } + } rt.consumed = true return true } From d1ff11953a09a364f955935e6d35c5fb81ae68ab Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 31 Oct 2026 00:14:35 +0000 Subject: [PATCH 29/38] fix(text): preserve text families before emoji fallback Remove fontconfig's global color preference in the Pango fontmap substitution hook so mixed family lists keep bare digits in text faces. Leave emoji itemization and explicit color=false unchanged. Require Pango 1.48 for the hook and document it. --- docs/architecture.md | 5 +++++ internal/text/pango_linux.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 1e6c32ecd..f4193f04b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3319,6 +3319,11 @@ either. stands for the family of GTK's `gtk-font-name` first (`platform.Theme.UIFont`, which package ui gives `SetUIFamily` with each change of the appearance). + Linux text needs Pango 1.48 or later. Its fontmap substitution hook + removes fontconfig's global `color=true` preference, which can otherwise + put an emoji fallback ahead of the requested text faces for bare digits. + Pango still itemizes emoji with the `emoji` family, preserving color + glyphs and variation, keycap, ZWJ and flag sequences. - **Atlas lifetime.** A mask drawn for the first time goes to the transient zone at the bottom of the atlas, which the next frame frees, and moves to the lasting zone at the top once another frame draws it, so diff --git a/internal/text/pango_linux.go b/internal/text/pango_linux.go index b428e5168..5962dc5fe 100644 --- a/internal/text/pango_linux.go +++ b/internal/text/pango_linux.go @@ -177,6 +177,7 @@ var pangoLib struct { languageFromString func(tag string) uintptr languageGetDefault func() uintptr fontMapConfigChanged func(fontMap uintptr) + fontMapSetSubstitute func(fontMap, callback, data, notify uintptr) cairoFontOptionsCreate func() uintptr cairoFontOptionsDestroy func(options uintptr) cairoFontOptionsAntialias func(options uintptr, antialias int32) @@ -207,6 +208,7 @@ var pangoLib struct { fcConfigGetFonts func(config uintptr, set int32) *fcFontSet fcConfigParseFromMemory func(config uintptr, xml string, complain int32) int32 fcPatternGetString func(pattern uintptr, object string, n int32, out **byte) int32 + fcPatternGetBool func(pattern uintptr, object string, n int32, out *int32) int32 fcPatternCreate func() uintptr fcPatternDuplicate func(pattern uintptr) uintptr fcPatternDel func(pattern uintptr, object string) int32 @@ -220,6 +222,17 @@ var pangoLib struct { fcFontMatch func(config, pattern uintptr, result *int32) uintptr } +var pangoSubstitute = purego.NewCallback(func(pattern, _ uintptr) uintptr { + // Fontconfig can turn an emoji fallback into a global color preference, + // ahead of the requested text families. Pango already itemizes emoji + // with the "emoji" family; keep color from overriding family order. + var color int32 + if pangoLib.fcPatternGetBool(pattern, "color", 0, &color) == fcResultMatch && color == int32(gTrue) { + pangoLib.fcPatternDel(pattern, "color") + } + return 0 +}) + func loadPango() error { open := func(names ...string) (uintptr, error) { var last error @@ -350,6 +363,8 @@ func loadPango() error { bind(cairo, &l.cairoSetScaledFont, "cairo_set_scaled_font") bind(cairo, &l.cairoSetSourceRGBA, "cairo_set_source_rgba") bind(cairo, &l.cairoShowGlyphs, "cairo_show_glyphs") + bind(ft, &l.fontMapSetSubstitute, "pango_fc_font_map_set_default_substitute") // Pango 1.48 + bind(fc, &l.fcPatternGetBool, "FcPatternGetBool") if len(missing) > 0 { return fmt.Errorf("Pango or cairo lacks %s", strings.Join(missing, ", ")) } @@ -474,6 +489,7 @@ func newPango() (*pangoEngine, error) { if e.fontMap == 0 { return nil, errors.New("pango_cairo_font_map_new failed") } + l.fontMapSetSubstitute(e.fontMap, pangoSubstitute, 0, 0) e.context = l.fontMapCreateContext(e.fontMap) // Until the app has the desktop's settings, GDK's defaults on X11 // without a settings daemon: grayscale antialiasing, medium hinting. From 982f5a6791f08ef2d447e0f1780fc7e304ebc277 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Wed, 4 Nov 2026 15:31:21 -0800 Subject: [PATCH 30/38] feat(ui): expose read-only build text flow query --- docs/ui/migration.md | 26 ++++++++++++++++++++++++++ ui/frame.go | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index 0e2a437bf..067bf078f 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,31 @@ # Migrating to checked elements +## Build-time text flow query + +`Context.InTextFlow()` is an additive, read-only UI-thread query of the current +build parent. It reports true inside a text element's `Children` callback and +false outside an active build, including for a nil or closed context. The result +is scoped to the current callback: do not cache it or infer it from a retained +application tree. It does not change the rule that text accepts only text +elements, such as `Text`, `Link` and `RichText`. + +Adapters that support both inline text and standalone layout can choose their +primitive from the actual context, without changing link activation: + +```go +var link ui.Element +if c.InTextFlow() { + link = ui.Link(c, "", url) +} else { + link = ui.LinkBase(c, url) +} +link.Children(func() { ui.Text(c, "Documentation") }) +``` + +Keep arbitrary layout and icons on the standalone path. This query does not make +non-text children legal inside text, expose retained nodes, or change wire APIs. +Existing callers need no migration. + ## Final frame text-input geometry Full and repaint frames now publish the text-input caret after all `AfterFrame` diff --git a/ui/frame.go b/ui/frame.go index 3b489ae7f..309cf6a13 100644 --- a/ui/frame.go +++ b/ui/frame.go @@ -40,6 +40,13 @@ func (c *Context) build() *context { // Valid reports whether this context is building a view. func (c *Context) Valid() bool { return c.build() != nil } +// InTextFlow reports whether children built now must be text elements. +// Call it on the UI thread. It returns false outside an active build. +func (c *Context) InTextFlow() bool { + ctx := c.build() + return ctx != nil && ctx.parent != nil && ctx.parent.kind == kindText +} + // Invalidate requests a frame from any goroutine. func (c *Context) Invalidate() { if c != nil { From bd776a231d732fe8086a685405cf6d26bacc7532 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 07:15:39 -0800 Subject: [PATCH 31/38] feat(ui): open existing context menus at coordinates --- docs/ui/migration.md | 18 ++++++++++++++++++ ui/lui_menu.go | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/ui/migration.md b/docs/ui/migration.md index 067bf078f..3e80c8b9a 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,23 @@ # Migrating to checked elements +## Programmatic context-menu opening + +`Element.OpenContextMenuAt(x, y)` requests the element's existing `ContextMenu` +at window coordinates in DIPs. Call it before `ContextMenu` in the build that +opens the menu, not every rebuild. Continue building `ContextMenu` and +`OnMenuDismiss` so selection is delivered before dismissal, including when the +user cancels. Disabled or expired elements cannot open a menu. Existing +secondary-click and keyboard context-menu behavior is unchanged. + +```go +owner := ui.Box(c) +if requested { + requested = false + owner.OpenContextMenuAt(x, y) +} +owner.ContextMenu(buildMenu).OnMenuDismiss(closed) +``` + ## Build-time text flow query `Context.InTextFlow()` is an additive, read-only UI-thread query of the current diff --git a/ui/lui_menu.go b/ui/lui_menu.go index 18825dc94..ec66d7146 100644 --- a/ui/lui_menu.go +++ b/ui/lui_menu.go @@ -5,6 +5,18 @@ import ( "image/color" ) +// OpenContextMenuAt requests this element's ContextMenu at window coordinates +// in DIPs. Call before ContextMenu while building, once per opening; continue +// building ContextMenu and OnMenuDismiss to receive its choice and dismissal. +// Like Element itself, this method is scoped to the current UI build pass. +func (e Element) OpenContextMenuAt(x, y float32) Element { + n := e.node() + if n != nil && !n.disabled() { + n.c.rt.askMenu(n.st, x, y, false) + } + return e +} + // NativeMenuOpen reports whether owner has a native menu presented or queued // by the committed frame. Query after layout or between frames on the UI // thread. Hidden, omitted and expired bindings return false. This does not From 69409d1ec19c03fe4d8474c80e00101cc268fadf Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 07:20:08 -0800 Subject: [PATCH 32/38] fix(input): retain native key character metadata Carry layout characters from NSEvent through surface key events to capture and focused handlers. Key text is metadata, distinct from committed InputText. --- internal/darwin/surface.go | 4 ++-- ui/handler.go | 5 ++-- ui/input.go | 14 ++++++------ ui/input_capture_test.go | 47 +++++++++++++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/internal/darwin/surface.go b/internal/darwin/surface.go index 0d2d90222..621852832 100644 --- a/internal/darwin/surface.go +++ b/internal/darwin/surface.go @@ -926,7 +926,7 @@ func registerSurfaceClass() { // giving the composition up, as GTK's and IMM32's filtering // keeps them on Linux and Windows. if !ime || !s.hasMarkedText() { - s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Mods: mods, Repeat: sendBool(ev, "isARepeat")}) + s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Mods: mods, Repeat: sendBool(ev, "isARepeat"), Text: typedChars(stringOf(send(ev, "characters")))}) } // Input methods see the key while a text input has the focus; // they answer with insertText: or setMarkedText:. @@ -944,7 +944,7 @@ func registerSurfaceClass() { }), method("keyUp:", func(self id, _ objc.SEL, ev id) { if s := b().surfaceOf(self); s != nil { - s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Mods: eventMods(ev)}) + s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Mods: eventMods(ev), Text: typedChars(stringOf(send(ev, "characters")))}) } }), method("flagsChanged:", func(self id, _ objc.SEL, ev id) { diff --git a/ui/handler.go b/ui/handler.go index 3539d35a0..2333cfaf1 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -72,8 +72,9 @@ type InputEvent struct { PhysicalKey Key Mods Modifiers Repeat bool - // Text of InputText, InputCompose and InputCommand, and Caret the - // rune of an InputCompose's caret. + // Text of InputText, InputCompose and InputCommand. For key events, + // Text is the layout's characters when reported by the platform, not + // a text insertion; InputText commits text. Caret is an InputCompose's rune. Text string Caret int // X and Y locate the pointer relative to the element's box. diff --git a/ui/input.go b/ui/input.go index 26e5122c5..8333f1264 100644 --- a/ui/input.go +++ b/ui/input.go @@ -72,7 +72,7 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { rt.zoom(float32(ev.Scale), Modifiers(ev.Mods)) case platform.KeyPressed: rt.modsChanged(Modifiers(ev.Mods)) - taken = rt.keyDownPhysical(Modifiers(ev.Mods), Key(ev.Key), Key(ev.PhysicalKey), ev.Repeat) + taken = rt.keyDownPhysical(Modifiers(ev.Mods), Key(ev.Key), Key(ev.PhysicalKey), ev.Repeat, ev.Text) case platform.ModifiersChanged: rt.modsChanged(Modifiers(ev.Mods)) case platform.KeyReleased: @@ -82,11 +82,11 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { key = physical } delete(rt.byPosition, physical) - if rt.captureInput(InputEvent{Kind: InputKeyUp, Mods: Modifiers(ev.Mods), Key: key, PhysicalKey: physical}, rt.focused) { + if rt.captureInput(InputEvent{Kind: InputKeyUp, Mods: Modifiers(ev.Mods), Key: key, PhysicalKey: physical, Text: ev.Text}, rt.focused) { break } if h := rt.focusHandler(); h != nil { - rt.deliver(h, InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Mods: Modifiers(ev.Mods)}) + rt.deliver(h, InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Mods: Modifiers(ev.Mods), Text: ev.Text}) } case platform.TextInput: rt.editEvent(rt.replaced(editEvent{kind: editInsert, text: ev.Text}, ev)) @@ -765,13 +765,13 @@ func (rt *engine) claimedBy(k keyEvent, window bool) bool { // keyDown handles a key pressed, and reports whether an element took it as // it came (HandleInput). func (rt *engine) keyDown(mods Modifiers, key Key, repeat bool) bool { - return rt.keyDownPhysical(mods, key, KeyUnknown, repeat) + return rt.keyDownPhysical(mods, key, KeyUnknown, repeat, "") } // keyDownPhysical is keyDown with the key by position (InputEvent.PhysicalKey). // A chord of a key the layout does not type as a US key, as S on a Cyrillic // layout, matches shortcuts by that position; plain typing is untouched. -func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool) bool { +func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool, text string) bool { if key == KeyEscape && rt.pressed != nil && rt.pressed.capturedPointer { rt.pointerCancel() return true @@ -788,14 +788,14 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, repeat bool if rt.inspectKey(mods, key) { return true } - if rt.captureInput(InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat}, rt.focused) { + if rt.captureInput(InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat, Text: text}, rt.focused) { return true } k := keyEvent{mods, key} if key == KeyEscape && mods == 0 && rt.dragCancel() { return true } - if h := rt.focusHandler(); h != nil && !rt.claimed(k) && rt.deliver(h, InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat}) { + if h := rt.focusHandler(); h != nil && !rt.claimed(k) && rt.deliver(h, InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Mods: mods, Repeat: repeat, Text: text}) { rt.blinkStart = rt.now() return true } diff --git a/ui/input_capture_test.go b/ui/input_capture_test.go index 198ba3880..05483d6f9 100644 --- a/ui/input_capture_test.go +++ b/ui/input_capture_test.go @@ -1,6 +1,51 @@ package ui -import "testing" +import ( + "testing" + + "github.com/egoist/mygo/internal/platform" +) + +func TestKeyCharacterMetadata(t *testing.T) { + for _, prevent := range []bool{false, true} { + t.Run(map[bool]string{false: "focused handler", true: "capture only"}[prevent], func(t *testing.T) { + var captured, handled []InputEvent + tt := NewTester(func(c *Context) { + c.CaptureInput(func(ev InputEvent, _ InputTarget) bool { + if ev.Kind != InputKeyDown && ev.Kind != InputKeyUp { + return false + } + captured = append(captured, ev) + return prevent + }) + Box(c).Size(80, 30).Focusable().AutoFocus().HandleInput(func(ev InputEvent) bool { + handled = append(handled, ev) + return true + }) + }, 100, 60) + tt.Frame() + for _, text := range []string{"!", "Ñ‹", ""} { + for _, kind := range []platform.SurfaceEventKind{platform.KeyPressed, platform.KeyReleased} { + tt.rt.event(platform.SurfaceEvent{Kind: kind, Key: platform.KeyUnknown, PhysicalKey: platform.Key1, Mods: platform.ModShift, Repeat: kind == platform.KeyPressed, Text: text}) + } + } + if len(captured) != 6 || !prevent && len(handled) != 6 || prevent && len(handled) != 0 { + t.Fatalf("capture/handler delivery counts = %d/%d", len(captured), len(handled)) + } + for _, events := range [][]InputEvent{captured, handled} { + for i, ev := range events { + wantKind := InputKeyDown + if i%2 != 0 { + wantKind = InputKeyUp + } + if ev.Kind != wantKind || ev.Key != KeyUnknown || ev.PhysicalKey != Key1 || ev.Mods != Shift || ev.Repeat != (i%2 == 0) || ev.Text != []string{"!", "Ñ‹", ""}[i/2] { + t.Fatalf("event %d lost keyboard metadata: %+v", i, ev) + } + } + } + }) + } +} func TestCaptureClickOnPlainHit(t *testing.T) { for _, tc := range []struct { From a8d45644feb1e768a33b02d5c8f0c7c70602169d Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 07:22:11 -0800 Subject: [PATCH 33/38] fix(input): preserve primary capture across other buttons --- ui/captured_pointer_test.go | 59 +++++++++++++++++++++++++++++++++++++ ui/handler.go | 2 +- ui/input.go | 11 +++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ui/captured_pointer_test.go b/ui/captured_pointer_test.go index d8ba9e8ea..fdc4dc4a9 100644 --- a/ui/captured_pointer_test.go +++ b/ui/captured_pointer_test.go @@ -1,11 +1,70 @@ package ui import ( + "fmt" "testing" "github.com/egoist/mygo/internal/platform" ) +func TestCapturedPointerSurvivesOtherButtons(t *testing.T) { + for _, button := range []int{1, 2} { + for _, intercept := range []bool{false, true} { + for _, cancel := range []bool{false, true} { + t.Run(fmt.Sprintf("button=%d/intercept=%v/cancel=%v", button, intercept, cancel), func(t *testing.T) { + moves, releases, cancels, downs, ups := 0, 0, 0, 0, 0 + tt := NewTester(func(c *Context) { + c.CaptureInput(func(ev InputEvent, _ InputTarget) bool { + return intercept && ev.Button == button && (ev.Kind == InputPointerDown || ev.Kind == InputPointerUp) + }) + Box(c).Size(20, 30).HandleCapturedPointer(func(ev InputEvent) bool { + switch ev.Kind { + case InputPointerDown: + if ev.Button == button { + downs++ + } + case InputPointerUp: + if ev.Button == button { + ups++ + } else if ev.Button == 0 { + releases++ + } + case InputPointerMove: + moves++ + case InputPointerCancel: + cancels++ + } + return true + }) + }, 200, 60) + tt.Press(10, 15) + tt.send(platform.SurfaceEvent{Kind: platform.PointerDown, X: 10, Y: 15, Button: button}) + tt.send(platform.SurfaceEvent{Kind: platform.PointerUp, X: 10, Y: 15, Button: button}) + moves = 0 + tt.Move(180, 15) + if moves != 1 { + t.Fatalf("primary movement after other-button release = %d; want 1", moves) + } + if cancel { + tt.Key(0, KeyEscape) + } + tt.Release(180, 15) + wantOther, wantRelease, wantCancel := 1, 1, 0 + if intercept { + wantOther = 0 + } + if cancel { + wantRelease, wantCancel = 0, 1 + } + if downs != wantOther || ups != wantOther || releases != wantRelease || cancels != wantCancel { + t.Fatalf("other down/up, primary release/cancel = %d/%d, %d/%d; want %d/%d, %d/%d", downs, ups, releases, cancels, wantOther, wantOther, wantRelease, wantCancel) + } + }) + } + } + } +} + func TestCapturedPointerLifetime(t *testing.T) { for _, end := range []string{"release", "escape", "blur", "cancel", "remove", "disable", "close"} { t.Run(end, func(t *testing.T) { diff --git a/ui/handler.go b/ui/handler.go index 2333cfaf1..2d4bf2a25 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -122,7 +122,7 @@ func (e *node) HandleInput(fn func(ev InputEvent) bool) *node { func (e *node) HandleCapturedPointer(fn func(ev InputEvent) bool) *node { s := e.st e.inputFn = func(ev InputEvent) bool { - if ev.Kind == InputPointerUp || ev.Kind == InputPointerCancel { + if ev.Kind == InputPointerUp && ev.Button == 0 || ev.Kind == InputPointerCancel { s.capturedPointer = false } handled := fn(ev) diff --git a/ui/input.go b/ui/input.go index 8333f1264..8d8667398 100644 --- a/ui/input.go +++ b/ui/input.go @@ -320,6 +320,10 @@ const interactive = flagClickable | flagFocusable | flagEditable | flagSelectabl func (rt *engine) pointerDown(x, y float32, button int, mods Modifiers, clicks int) { if rt.pressed != nil && rt.pressed.capturedPointer { if button != 0 { + ev := InputEvent{Kind: InputPointerDown, Button: button, Clicks: max(1, clicks), Mods: mods} + if !rt.capturePointer(ev, x, y) { + rt.deliver(rt.pressed, ev) + } return } rt.pointerCancel() @@ -486,6 +490,13 @@ func (rt *engine) pointerUp(button, clicks int) { rt.ups = append(rt.ups, [2]int{button, clicks}) return } + if rt.pressed != nil && rt.pressed.capturedPointer && button != rt.pressButton { + ev := InputEvent{Kind: InputPointerUp, Button: button, Clicks: max(1, clicks), Mods: rt.mods} + if !rt.capturePointer(ev, rt.pointerX, rt.pointerY) { + rt.deliver(rt.pressed, ev) + } + return + } if rt.pressed != nil && button == rt.pressButton { clicks = rt.pressClicks // the engine's, which may have started over } From 006f3ca1466010f45ff189289ebcb18cb4d655f9 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 07:25:27 -0800 Subject: [PATCH 34/38] fix: attach native key glyph metadata --- internal/linux/surface.go | 15 +++++++++++++-- internal/windows/surface.go | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/internal/linux/surface.go b/internal/linux/surface.go index 49838a575..73ea82633 100644 --- a/internal/linux/surface.go +++ b/internal/linux/surface.go @@ -8,6 +8,7 @@ import ( "math" "os" "sync" + "unicode" "unicode/utf8" "github.com/ebitengine/purego" @@ -518,6 +519,15 @@ func (s *surface) typed(event ptr) { s.commit(string(r)) } +// keyvalText is layout glyph metadata, not an input-method text commit. +func keyvalText(keyval uint32) string { + r := rune(gdkKeyvalToUnicode(keyval)) + if !unicode.IsPrint(r) { + return "" + } + return string(r) +} + // commit sends text typed or committed by an input method. func (s *surface) commit(text string) { if c := s.input.Client; c != nil { @@ -877,6 +887,7 @@ func initSurfaceCallbacks() { return false } k := keyvalKey(field[uint32](event, 28)) + text := keyvalText(field[uint32](event, 28)) mods := gdkMods(field[uint32](event, 24)) physical := eventPhysicalKey(event) // A key the layout types no US key for, as S on a Cyrillic layout, @@ -886,7 +897,7 @@ func initSurfaceCallbacks() { if k == platform.KeyUnknown { switch { case kind == platform.KeyReleased && physical != platform.KeyUnknown: - s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Mods: mods}) + s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Mods: mods, Text: text}) return false case !platform.KeyByPosition(k, physical, mods): return false @@ -896,7 +907,7 @@ func initSurfaceCallbacks() { s.pressing = event defer func() { s.pressing = 0 }() } - s.send(platform.SurfaceEvent{Kind: kind, Key: k, PhysicalKey: physical, Mods: mods}) + s.send(platform.SurfaceEvent{Kind: kind, Key: k, PhysicalKey: physical, Mods: mods, Text: text}) return true }) cbSurfaceFocusIn = purego.NewCallback(func(widget, event, data ptr) bool { diff --git a/internal/windows/surface.go b/internal/windows/surface.go index 377734795..f856d9f9b 100644 --- a/internal/windows/surface.go +++ b/internal/windows/surface.go @@ -6,6 +6,7 @@ import ( "log" "math" "time" + "unicode" "unicode/utf16" "unsafe" @@ -30,6 +31,9 @@ var ( procSystemParametersInfoW = user32.NewProc("SystemParametersInfoW") procGetDoubleClickTime = user32.NewProc("GetDoubleClickTime") procGetMessageTime = user32.NewProc("GetMessageTime") + procGetKeyboardState = user32.NewProc("GetKeyboardState") + procGetKeyboardLayout = user32.NewProc("GetKeyboardLayout") + procToUnicodeEx = user32.NewProc("ToUnicodeEx") procGetSystemMetrics = user32.NewProc("GetSystemMetrics") procSetDIBitsToDevice = gdi32.NewProc("SetDIBitsToDevice") procImmGetContext = imm32.NewProc("ImmGetContext") @@ -640,7 +644,7 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool return 0, true } if k, physical, ok := keyAndPosition(wp, lp, false, modifiers); ok { - s.keyTaken = s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: k, PhysicalKey: physical, Mods: modifiers, Repeat: lp&(1<<30) != 0}) + s.keyTaken = s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: k, PhysicalKey: physical, Mods: modifiers, Repeat: lp&(1<<30) != 0, Text: keyText(wp, lp)}) } if !s.keyTaken && s.menuShortcut(wp, true) { return 0, true @@ -653,7 +657,7 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool s.send(platform.SurfaceEvent{Kind: platform.ModifiersChanged, Mods: modifiers}) } if k, physical, ok := keyAndPosition(wp, lp, true, modifiers); ok { - s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: k, PhysicalKey: physical, Mods: modifiers}) + s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: k, PhysicalKey: physical, Mods: modifiers, Text: keyText(wp, lp)}) } return 0, m == wmKeyUp case wmSysChar: @@ -764,6 +768,32 @@ func wheelLines() int { return surfaceWheelLines } +// keyText queries the current layout without changing the keyboard buffer +// used by TranslateMessage (dead keys, Alt+numpad and WM_CHAR). +func keyText(vk, lp uintptr) string { + // ToUnicodeEx's non-mutating flag requires Windows 10 version 1607. + if windowsBuild() < 14393 { + return "" + } + var state [256]byte + if ok, _, _ := procGetKeyboardState.Call(uintptr(unsafe.Pointer(&state[0]))); ok == 0 { + return "" + } + layout, _, _ := procGetKeyboardLayout.Call(0) + var chars [16]uint16 + n, _, _ := procToUnicodeEx.Call(vk, lp>>16&0xFF, uintptr(unsafe.Pointer(&state[0])), uintptr(unsafe.Pointer(&chars[0])), uintptr(len(chars)), 4, layout) + if int32(n) <= 0 || n > uintptr(len(chars)) { + return "" // Dead keys and keys without a printable translation. + } + text := string(utf16.Decode(chars[:n])) + for _, r := range text { + if !unicode.IsPrint(r) { + return "" + } + } + return text +} + // keyAndPosition returns the key of a WM_KEYDOWN or WM_KEYUP by virtual // key, and by position (the scancode in lp). ok is false for a key to leave // out: one whose virtual key is unmapped, unless a shortcut modifier is From 56b52a3dc7f6fd330bc332da71983232a6f10d6b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 09:10:39 -0800 Subject: [PATCH 35/38] Retire released gestures before callbacks Prevent reentrant window close from completing a released pointer twice. Preserve explicit menu icons while replacing or removing repeated legacy PNG fallbacks, and keep retained bounds in window DIPs under zoom. --- ui/captured_pointer_test.go | 21 +++++++++++++++++++++ ui/input.go | 16 +++++++++++----- ui/lui_menu.go | 12 ++++++++---- ui/menu.go | 15 ++++++++------- ui/ref.go | 2 +- 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/ui/captured_pointer_test.go b/ui/captured_pointer_test.go index fdc4dc4a9..b103ab3b5 100644 --- a/ui/captured_pointer_test.go +++ b/ui/captured_pointer_test.go @@ -7,6 +7,27 @@ import ( "github.com/egoist/mygo/internal/platform" ) +func TestCapturedPointerReleaseClosesOnce(t *testing.T) { + var tt *Tester + var completed []InputEvent + tt = NewTester(func(c *Context) { + Box(c).Size(100, 40).HandleCapturedPointer(func(ev InputEvent) bool { + if ev.Kind == InputPointerUp || ev.Kind == InputPointerCancel { + completed = append(completed, ev) + if ev.Kind == InputPointerUp && !ev.Canceled { + tt.rt.close() + } + } + return true + }) + }, 200, 60) + tt.Press(10, 15) + tt.Release(10, 15) + if len(completed) != 1 || completed[0].Kind != InputPointerUp || completed[0].Canceled { + t.Fatalf("completed = %+v; want one successful release", completed) + } +} + func TestCapturedPointerSurvivesOtherButtons(t *testing.T) { for _, button := range []int{1, 2} { for _, intercept := range []bool{false, true} { diff --git a/ui/input.go b/ui/input.go index 64eac4539..5abb7fb4f 100644 --- a/ui/input.go +++ b/ui/input.go @@ -697,27 +697,33 @@ func (rt *engine) pointerUp(button, clicks int, canceled bool) { return } } - if p := rt.pressed; p != nil && p.input != nil { - rt.deliver(p, InputEvent{Kind: InputPointerUp, Button: button, Mods: rt.mods, Clicks: max(clicks, 1), Canceled: canceled}) + s := rt.pressed + releasing := s != nil && button == rt.pressButton + if releasing { + rt.pressed = nil + } + if s != nil && s.input != nil { + rt.deliver(s, InputEvent{Kind: InputPointerUp, Button: button, Mods: rt.mods, Clicks: max(clicks, 1), Canceled: canceled}) + } + if rt.closed || rt.closing { + return } if button == 1 && !canceled && rt.menuRelease() { return } - s := rt.pressed if s == nil { if rt.releaseCapture != nil { rt.releaseCapture(button) } return } - if button != rt.pressButton { + if !releasing { return } if rt.selection.dragging { rt.moveTextSelection(rt.pointerX, rt.pointerY) rt.selection.dragging = false } - rt.pressed = nil r := &s.releaseLater r.due, r.x, r.y = true, rt.pointerX, rt.pointerY // A press of the primary button that moved ends a drag; a click does diff --git a/ui/lui_menu.go b/ui/lui_menu.go index 7b00d85f2..c5e143c50 100644 --- a/ui/lui_menu.go +++ b/ui/lui_menu.go @@ -62,10 +62,14 @@ func (e Element) OnMenuDismiss(fn func()) Element { func (it *MenuItem) ImagePNG(data []byte) *MenuItem { if it.p != nil { it.p.ImagePNG = append([]byte(nil), data...) - if it.p.Icon == nil && len(data) != 0 { - if _, err := png.DecodeConfig(bytes.NewReader(data)); err == nil { - linkMenuIcons() - it.p.Icon = &platform.MenuIcon{PNG: append([]byte(nil), data...), Template: false} + if it.p.Icon == nil || it.p.Icon == it.imageIcon { + it.p.Icon, it.imageIcon = nil, nil + if len(data) != 0 { + if _, err := png.DecodeConfig(bytes.NewReader(data)); err == nil { + linkMenuIcons() + it.imageIcon = &platform.MenuIcon{PNG: append([]byte(nil), data...), Template: false} + it.p.Icon = it.imageIcon + } } } } diff --git a/ui/menu.go b/ui/menu.go index f547b70d1..aac670c57 100644 --- a/ui/menu.go +++ b/ui/menu.go @@ -190,13 +190,14 @@ func (ref menuRef) is(it *MenuItem) bool { // MenuItem is an item of a Menu. Its methods set it up and return it, for // chaining, and Chosen reports the choice. type MenuItem struct { - run *menuRun - seq int - label string - key string - disabled bool - sub bool - p *platform.MenuItem // when the menu opens + run *menuRun + seq int + label string + key string + disabled bool + sub bool + p *platform.MenuItem // when the menu opens + imageIcon *platform.MenuIcon // What a menu drawn in the window shows of it beyond p (Danger, // Trailing, Content). danger bool diff --git a/ui/ref.go b/ui/ref.go index d757cf4ce..dd8aebcb3 100644 --- a/ui/ref.go +++ b/ui/ref.go @@ -242,5 +242,5 @@ func (r Handle) Bounds(c *Context) Rect { if s == nil { return Rect{} } - return Rect{s.x, s.y, s.w, s.h} + return rt.windowRect(Rect{s.x, s.y, s.w, s.h}) } From 1cbe160b420c8bcae7a3a427dbddb2515dc95821 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 09:12:14 -0800 Subject: [PATCH 36/38] Regenerate migration API inventory for fork --- internal/uimigrate/uiapi.go | 571 +++++++++++++++++++----------------- 1 file changed, 294 insertions(+), 277 deletions(-) diff --git a/internal/uimigrate/uiapi.go b/internal/uimigrate/uiapi.go index c47560ddf..539812155 100644 --- a/internal/uimigrate/uiapi.go +++ b/internal/uimigrate/uiapi.go @@ -45,6 +45,7 @@ var uiResults = map[string]string{ "Icon": "Element", "Image": "Element", "Link": "Element", + "LinkBase": "Element", "List": "Element", "MenuBase": "MenuParts", "MenuButton": "Element", @@ -121,283 +122,299 @@ var uiResults = map[string]string{ // elementChains lists the methods of Element that return an Element. var elementChains = map[string]bool{ - "Absolute": true, - "Access": true, - "AccessDisabled": true, - "AccessHidden": true, - "AccessSelected": true, - "ActiveDescendant": true, - "AlignContent": true, - "AlignItems": true, - "AlignSelf": true, - "AnimatePose": true, - "AspectRatio": true, - "Attach": true, - "AttachEdgeMargin": true, - "AttachTo": true, - "AttachedOrigin": true, - "AutoComplete": true, - "AutoFocus": true, - "BackfaceHidden": true, - "Background": true, - "Balance": true, - "Basis": true, - "BasisPercent": true, - "Bind": true, - "Blur": true, - "Bold": true, - "Border": true, - "BorderColor": true, - "BorderConicGradient": true, - "BorderEndWidth": true, - "BorderLinearGradient": true, - "BorderStartWidth": true, - "BorderStyle": true, - "BorderWidth": true, - "Bottom": true, - "BottomPercent": true, - "Browse": true, - "Busy": true, - "Cache": true, - "CaretColor": true, - "CaretWidth": true, - "Center": true, - "Checked": true, - "ChildExit": true, - "Children": true, - "Clip": true, - "ClipInset": true, - "ClipInsetPercent": true, - "ClipX": true, - "ClipY": true, - "Column": true, - "ColumnCount": true, - "ColumnIndex": true, - "ColumnSpan": true, - "ColumnStart": true, - "ColumnTracks": true, - "Columns": true, - "ConicGradient": true, - "ContentTransition": true, - "ContextMenu": true, - "Controls": true, - "CopyData": true, - "Current": true, - "Cursor": true, - "Debug": true, - "DecorationColor": true, - "DecorationThickness": true, - "DescribedBy": true, - "Description": true, - "DimDisabled": true, - "Direction": true, - "Disabled": true, - "Dividers": true, - "DottedUnderline": true, - "Drag": true, - "DragData": true, - "DragDataFrom": true, - "DragWindow": true, - "Draggable": true, - "Draw": true, - "DrawObjects": true, - "DrawOver": true, - "Effect": true, - "Ellipsis": true, - "End": true, - "EndPercent": true, - "Error": true, - "ErrorMessage": true, - "Expanded": true, - "Fill": true, - "FillHeight": true, - "FillWidth": true, - "Fit": true, - "FixedLineHeight": true, - "Focus": true, - "FocusBind": true, - "FocusGroup": true, - "FocusRing": true, - "FocusRingColor": true, - "FocusRingOffset": true, - "Focusable": true, - "Font": true, - "FontFeatures": true, - "FontSize": true, - "FontWeight": true, - "Gap": true, - "GapX": true, - "GapY": true, - "Gradient": true, - "Grayscale": true, - "Grid": true, - "GridRows": true, - "Grow": true, - "HandleInput": true, - "HandleTextInput": true, - "HasPopup": true, - "Height": true, - "HeightPercent": true, - "Hidden": true, - "HitTest": true, - "IgnoreInput": true, - "InputMethod": true, - "InsetShadow": true, - "Invalid": true, - "Invisible": true, - "Italic": true, - "ItemKey": true, - "Justify": true, - "JustifyItems": true, - "JustifySelf": true, - "KeepFocus": true, - "KeepOpen": true, - "KeepScrollbars": true, - "Key": true, - "KeyShortcuts": true, - "Label": true, - "LabelledBy": true, - "Lang": true, - "Left": true, - "LeftPercent": true, - "LetterSpacing": true, - "Level": true, - "LineHeight": true, - "LinearGradient": true, - "Lines": true, - "Margin": true, - "MarginEnd": true, - "MarginStart": true, - "MarginX": true, - "MarginY": true, - "MaskConicGradient": true, - "MaskLinearGradient": true, - "MaskRadialGradient": true, - "Masks": true, - "Material": true, - "MaxHeight": true, - "MaxHeightPercent": true, - "MaxLength": true, - "MaxLines": true, - "MaxWidth": true, - "MaxWidthPercent": true, - "MaximizeButton": true, - "Menu": true, - "MinHeight": true, - "MinHeightPercent": true, - "MinWidth": true, - "MinWidthPercent": true, - "Mixed": true, - "Modal": true, - "Multiselectable": true, - "NoShadow": true, - "NoWrap": true, - "ObjectPosition": true, - "ObserveInput": true, - "OnChange": true, - "OnClick": true, - "OnPaste": true, - "OnShortcut": true, - "OnSubmit": true, - "OnTextKey": true, - "Opacity": true, - "Orientation": true, - "Padding": true, - "PaddingEnd": true, - "PaddingStart": true, - "PaddingX": true, - "PaddingY": true, - "PassThrough": true, - "Password": true, - "Perspective": true, - "Placeholder": true, - "PlaceholderColor": true, - "PopoverMenu": true, - "PopoverMenuButton": true, - "PositionInSet": true, - "PreserveSpaces": true, - "Pretty": true, - "RadialGradient": true, - "Radius": true, - "Range": true, - "ReadOnly": true, - "Ref": true, - "Reverse": true, - "Right": true, - "RightPercent": true, - "Role": true, - "RoleDescription": true, - "Rotate": true, - "RotateX": true, - "RotateY": true, - "Row": true, - "RowCount": true, - "RowIndex": true, - "RowSpan": true, - "RowStart": true, - "RowTracks": true, - "RowWidth": true, - "Rows": true, - "Scale": true, - "ScrollIntoView": true, - "ScrollSnap": true, - "ScrollWith": true, - "ScrollbarInsets": true, - "Scrollbars": true, - "Search": true, - "Selectable": true, - "Selected": true, - "Selection": true, - "SelectionColor": true, - "SetChanged": true, - "SetTextSelection": true, - "Shadow": true, - "Shrink": true, - "SingleLine": true, - "Size": true, - "Skew": true, - "SnapAlign": true, - "SnapAlignX": true, - "SnapAlignY": true, - "Sort": true, - "SplitReset": true, - "Start": true, - "StartPercent": true, - "Step": true, - "Sticky": true, - "Strikethrough": true, - "Stripes": true, - "TabSize": true, - "TabStop": true, - "TextAlign": true, - "TextBackground": true, - "TextCaret": true, - "TextColor": true, - "TextRanges": true, - "TextShadow": true, - "TextStroke": true, - "Tooltip": true, - "Top": true, - "TopPercent": true, - "TrackLines": true, - "TrackScroll": true, - "Transform": true, - "TransformOrigin": true, - "Transition": true, - "Translate": true, - "TranslatePercent": true, - "TranslateZ": true, - "Underline": true, - "UnderlineOffset": true, - "Unselectable": true, - "Value": true, - "Vertical": true, - "WavyUnderline": true, - "Width": true, - "WidthPercent": true, - "Wrap": true, - "WrapReverse": true, - "ZIndex": true, + "Absolute": true, + "Access": true, + "AccessDisabled": true, + "AccessHidden": true, + "AccessSelected": true, + "ActiveDescendant": true, + "AlignContent": true, + "AlignItems": true, + "AlignSelf": true, + "AnimatePose": true, + "AspectRatio": true, + "Attach": true, + "AttachEdgeMargin": true, + "AttachTo": true, + "AttachToHandle": true, + "AttachedOrigin": true, + "AutoComplete": true, + "AutoFocus": true, + "BackfaceHidden": true, + "Background": true, + "Balance": true, + "Basis": true, + "BasisPercent": true, + "Bind": true, + "Blur": true, + "Bold": true, + "Border": true, + "BorderColor": true, + "BorderConicGradient": true, + "BorderEndWidth": true, + "BorderLinearGradient": true, + "BorderStartWidth": true, + "BorderStyle": true, + "BorderWidth": true, + "Bottom": true, + "BottomPercent": true, + "Browse": true, + "Busy": true, + "Cache": true, + "CaretColor": true, + "CaretWidth": true, + "Center": true, + "Checked": true, + "ChildExit": true, + "Children": true, + "Clip": true, + "ClipInset": true, + "ClipInsetPercent": true, + "ClipX": true, + "ClipY": true, + "CollisionPadding": true, + "Column": true, + "ColumnCount": true, + "ColumnIndex": true, + "ColumnSpan": true, + "ColumnStart": true, + "ColumnTracks": true, + "Columns": true, + "ConicGradient": true, + "ContentTransition": true, + "ContextMenu": true, + "ContextMenuBase": true, + "Controls": true, + "CopyData": true, + "Current": true, + "Cursor": true, + "Debug": true, + "DecorationColor": true, + "DecorationThickness": true, + "DescribedBy": true, + "Description": true, + "DimDisabled": true, + "Direction": true, + "Disabled": true, + "DisabledOpacity": true, + "Dividers": true, + "DottedUnderline": true, + "Drag": true, + "DragData": true, + "DragDataFrom": true, + "DragWindow": true, + "Draggable": true, + "Draw": true, + "DrawObjects": true, + "DrawOver": true, + "Effect": true, + "Ellipsis": true, + "End": true, + "EndPercent": true, + "Error": true, + "ErrorMessage": true, + "Expanded": true, + "Fill": true, + "FillHeight": true, + "FillWidth": true, + "Fit": true, + "FixedLineHeight": true, + "Focus": true, + "FocusBind": true, + "FocusGroup": true, + "FocusRing": true, + "FocusRingColor": true, + "FocusRingOffset": true, + "Focusable": true, + "Font": true, + "FontFeatures": true, + "FontItalic": true, + "FontSize": true, + "FontWeight": true, + "Gap": true, + "GapX": true, + "GapY": true, + "Gradient": true, + "Grayscale": true, + "Grid": true, + "GridRows": true, + "Grow": true, + "HandleCapturedPointer": true, + "HandleInput": true, + "HandleTextInput": true, + "HasPopup": true, + "Height": true, + "HeightPercent": true, + "Hidden": true, + "HitTest": true, + "IgnoreInput": true, + "Inert": true, + "InputMethod": true, + "InsetShadow": true, + "Invalid": true, + "Invisible": true, + "Italic": true, + "ItemKey": true, + "Justify": true, + "JustifyItems": true, + "JustifySelf": true, + "KeepFocus": true, + "KeepOpen": true, + "KeepScrollbars": true, + "Key": true, + "KeyShortcuts": true, + "Label": true, + "LabelledBy": true, + "Lang": true, + "Left": true, + "LeftPercent": true, + "LetterSpacing": true, + "Level": true, + "LineHeight": true, + "LinearGradient": true, + "Lines": true, + "Margin": true, + "MarginEnd": true, + "MarginStart": true, + "MarginX": true, + "MarginY": true, + "MaskConicGradient": true, + "MaskLinearGradient": true, + "MaskRadialGradient": true, + "Masks": true, + "MatchAnchorWidth": true, + "Material": true, + "MaxHeight": true, + "MaxHeightPercent": true, + "MaxLength": true, + "MaxLines": true, + "MaxWidth": true, + "MaxWidthPercent": true, + "MaximizeButton": true, + "Menu": true, + "MinHeight": true, + "MinHeightPercent": true, + "MinHeightPercentOffset": true, + "MinWidth": true, + "MinWidthPercent": true, + "MinWidthPercentOffset": true, + "Mixed": true, + "Modal": true, + "Multiselectable": true, + "NoShadow": true, + "NoWrap": true, + "ObjectPosition": true, + "ObserveInput": true, + "OnChange": true, + "OnClick": true, + "OnMenuDismiss": true, + "OnPaste": true, + "OnShortcut": true, + "OnSubmit": true, + "OnTextKey": true, + "Opacity": true, + "OpenContextMenuAt": true, + "Orientation": true, + "Padding": true, + "PaddingEnd": true, + "PaddingStart": true, + "PaddingX": true, + "PaddingY": true, + "PassThrough": true, + "Password": true, + "Perspective": true, + "Placeholder": true, + "PlaceholderColor": true, + "PointerEvents": true, + "PopoverMenu": true, + "PopoverMenuButton": true, + "PositionInSet": true, + "PreserveSpaces": true, + "Pretty": true, + "RadialGradient": true, + "Radius": true, + "Range": true, + "ReadOnly": true, + "Ref": true, + "Reverse": true, + "Right": true, + "RightPercent": true, + "Role": true, + "RoleDescription": true, + "Rotate": true, + "RotateX": true, + "RotateY": true, + "Row": true, + "RowCount": true, + "RowIndex": true, + "RowSpan": true, + "RowStart": true, + "RowTracks": true, + "RowWidth": true, + "Rows": true, + "Scale": true, + "ScrollAxes": true, + "ScrollIntoView": true, + "ScrollSnap": true, + "ScrollWith": true, + "ScrollbarInsets": true, + "Scrollbars": true, + "Search": true, + "Selectable": true, + "Selected": true, + "Selection": true, + "SelectionColor": true, + "SetChanged": true, + "SetTextSelection": true, + "Shadow": true, + "Shrink": true, + "SingleLine": true, + "Size": true, + "Skew": true, + "SnapAlign": true, + "SnapAlignX": true, + "SnapAlignY": true, + "Sort": true, + "SplitReset": true, + "Start": true, + "StartPercent": true, + "Step": true, + "Sticky": true, + "Strikethrough": true, + "Stripes": true, + "TabSize": true, + "TabStop": true, + "TextAlign": true, + "TextBackground": true, + "TextCaret": true, + "TextColor": true, + "TextDecorations": true, + "TextRanges": true, + "TextShadow": true, + "TextStroke": true, + "Tooltip": true, + "Top": true, + "TopPercent": true, + "TrackLines": true, + "TrackScroll": true, + "Transform": true, + "TransformOrigin": true, + "Transition": true, + "Translate": true, + "TranslatePercent": true, + "TranslateZ": true, + "Underline": true, + "UnderlineOffset": true, + "Unselectable": true, + "Value": true, + "Vertical": true, + "WavyUnderline": true, + "WhiteSpace": true, + "Width": true, + "WidthPercent": true, + "Wrap": true, + "WrapReverse": true, + "ZIndex": true, } // uiParts lists package ui's custom parts types. From e12a64d92d6da18b2e7f8eeb123f9e4469bd4c5b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 09:15:43 -0800 Subject: [PATCH 37/38] Expose context menu positions in window DIPs --- ui/lui_menu.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/lui_menu.go b/ui/lui_menu.go index c5e143c50..a69d9298e 100644 --- a/ui/lui_menu.go +++ b/ui/lui_menu.go @@ -108,7 +108,8 @@ func (e Element) ContextMenuBase(present func(x, y float32)) Element { request := &n.c.rt.menu if request.asked == n.id && !request.button { request.asked = 0 - present(request.x, request.y) + z := n.c.rt.zoomFactor() + present(request.x*z, request.y*z) } return e } From 4e17292d1de81e74d34d10231a93dcc78af4f763 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 09:31:52 -0800 Subject: [PATCH 38/38] Preserve native menu priority after input capture --- internal/platform/surface.go | 4 ++++ internal/windows/surface.go | 13 +++++++------ ui/handler.go | 3 +++ ui/input.go | 9 ++++++--- ui/webview.go | 2 +- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/internal/platform/surface.go b/internal/platform/surface.go index b5bdea6e2..ec79bcfe8 100644 --- a/internal/platform/surface.go +++ b/internal/platform/surface.go @@ -409,6 +409,10 @@ type SurfaceEvent struct { Repeat bool Text string Caret int + // BeforeContent runs synchronously for a key press after input capture and + // before ordinary content handling. It lets a native application-menu + // shortcut consume the key; edit-role shortcuts still follow the content. + BeforeContent func() bool // Replace makes TextInput replace, and TextComposition compose over, // the runes From to To of the last TextInputState.Text, instead of the // selection. diff --git a/internal/windows/surface.go b/internal/windows/surface.go index e7500e808..0f73112ed 100644 --- a/internal/windows/surface.go +++ b/internal/windows/surface.go @@ -911,12 +911,13 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool s.send(platform.SurfaceEvent{Kind: platform.ModifiersChanged, Mods: modifiers}) } k, physical, ok := keyAndPosition(wp, lp, false, modifiers) + beforeContent := func() bool { return s.menuShortcut(wp, false) } if ok { - s.keyTaken = s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: k, PhysicalKey: physical, Code: keyCode(wp, lp), Mods: modifiers, Repeat: lp&(1<<30) != 0, Text: keyText(wp, lp)}) + s.keyTaken = s.send(platform.SurfaceEvent{Kind: platform.KeyPressed, Key: k, PhysicalKey: physical, Code: keyCode(wp, lp), Mods: modifiers, Repeat: lp&(1<<30) != 0, Text: keyText(wp, lp), BeforeContent: beforeContent}) } else { - s.keyTaken = s.rawKey(platform.RawKeyPressed, wp, lp, physical, modifiers) + s.keyTaken = s.rawKey(platform.RawKeyPressed, wp, lp, physical, modifiers, beforeContent) } - if !s.keyTaken && (s.menuShortcut(wp, false) || s.menuShortcut(wp, true)) { + if !s.keyTaken && s.menuShortcut(wp, true) { return 0, true } // Alt+F4, Alt+Space and F10 keep working, and Alt the menu bar, unless @@ -937,7 +938,7 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool // key goes up all the same, by its position. s.held.Lift(platform.KeyUnknown, platform.KeyForScancode(uint16(lp>>16&0xFF)), modifiers, s.send) } else { - taken = s.rawKey(platform.RawKeyReleased, wp, lp, physical, modifiers) + taken = s.rawKey(platform.RawKeyReleased, wp, lp, physical, modifiers, nil) } return 0, m == wmKeyUp || taken case wmSysChar: @@ -1099,12 +1100,12 @@ func keyCode(vk, lp uintptr) platform.KeyCode { // rawKey sends a key shortcuts have no name for, which keyAndPosition leaves // out, by its position, and reports whether the content took it. kind is // RawKeyPressed or RawKeyReleased. -func (s *surface) rawKey(kind platform.SurfaceEventKind, wp, lp uintptr, physical platform.Key, modifiers platform.Modifiers) bool { +func (s *surface) rawKey(kind platform.SurfaceEventKind, wp, lp uintptr, physical platform.Key, modifiers platform.Modifiers, beforeContent func() bool) bool { code := keyCode(wp, lp) if code == platform.CodeUnknown { return false } - return s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Code: code, Mods: modifiers, Repeat: kind == platform.RawKeyPressed && lp&(1<<30) != 0, Text: keyText(wp, lp)}) + return s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Code: code, Mods: modifiers, Repeat: kind == platform.RawKeyPressed && lp&(1<<30) != 0, Text: keyText(wp, lp), BeforeContent: beforeContent}) } // keyAndPosition returns the key of a WM_KEYDOWN or WM_KEYUP by virtual diff --git a/ui/handler.go b/ui/handler.go index a3cf413c4..e4944000d 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -241,6 +241,9 @@ func (rt *engine) rawKey(ev platform.SurfaceEvent) bool { if rt.captureInput(input, rt.focused) { return true } + if kind == InputKeyDown && ev.BeforeContent != nil && ev.BeforeContent() { + return true + } rt.observeFocus(input) return rt.deliver(rt.focusHandler(), input) } diff --git a/ui/input.go b/ui/input.go index 5abb7fb4f..9dad8d433 100644 --- a/ui/input.go +++ b/ui/input.go @@ -108,7 +108,7 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { case platform.KeyPressed: rt.modsChanged(Modifiers(ev.Mods)) rt.holdKey(Modifiers(ev.Mods), Key(ev.Key), Key(ev.PhysicalKey)) - taken = rt.keyDownPhysical(Modifiers(ev.Mods), Key(ev.Key), Key(ev.PhysicalKey), KeyCode(ev.Code), ev.Repeat, ev.Text) + taken = rt.keyDownPhysical(Modifiers(ev.Mods), Key(ev.Key), Key(ev.PhysicalKey), KeyCode(ev.Code), ev.Repeat, ev.Text, ev.BeforeContent) case platform.ModifiersChanged: rt.modsChanged(Modifiers(ev.Mods)) case platform.RawKeyPressed, platform.RawKeyReleased: @@ -1021,13 +1021,13 @@ func (rt *engine) claimedBy(k keyEvent, window bool) bool { // keyDown handles a key pressed, and reports whether an element took it as // it came (HandleInput). func (rt *engine) keyDown(mods Modifiers, key Key, repeat bool) bool { - return rt.keyDownPhysical(mods, key, KeyUnknown, CodeUnknown, repeat, "") + return rt.keyDownPhysical(mods, key, KeyUnknown, CodeUnknown, repeat, "", nil) } // keyDownPhysical is keyDown with the key by position (InputEvent.PhysicalKey). // A chord of a key the layout does not type as a US key, as S on a Cyrillic // layout, matches shortcuts by that position; plain typing is untouched. -func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCode, repeat bool, text string) bool { +func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCode, repeat bool, text string, beforeContent func() bool) bool { if platform.KeyByPosition(platform.Key(key), platform.Key(physical), platform.Modifiers(mods)) { if rt.byPosition == nil { rt.byPosition = map[Key]bool{} @@ -1044,6 +1044,9 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCod if rt.captureInput(input, rt.focused) { return true } + if beforeContent != nil && beforeContent() { + return true + } rt.observeFocus(input) if key == KeyEscape && rt.pressed != nil && rt.pressed.capturedPointer { rt.pointerCancel(rt.pressButton) diff --git a/ui/webview.go b/ui/webview.go index 590a4ad85..ade0908c8 100644 --- a/ui/webview.go +++ b/ui/webview.go @@ -282,7 +282,7 @@ func (rt *engine) webViewKey(v NativeWebView, mods Modifiers, key, physical Key, rt.focused = id rt.focusVisible = false } - rt.keyDownPhysical(mods, key, physical, CodeUnknown, repeat, "") + rt.keyDownPhysical(mods, key, physical, CodeUnknown, repeat, "", nil) rt.updateTextInput() }