diff --git a/LUI-FORK.md b/LUI-FORK.md new file mode 100644 index 000000000..9cd1e36d8 --- /dev/null +++ b/LUI-FORK.md @@ -0,0 +1,61 @@ +# LUI compatibility fork + +This fork integrates upstream MyGo through commit +`1db4e93bd48efa7e3f5a93985c8e89a4c6fa9e3d`, retaining 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. + +`ZIndex` uses upstream's opt-in paint/commit ordering, not a separate sorter. +Upstream `TextLayout`, accessibility, physical keys and `KeyCode` remain intact. +Committed handle geometry, live text snapshots, focus epochs and build-time +text-flow queries retain their separate lifetime and coordinate contracts. + +## Source and behavior migration + +- The fork's committed `TextLine` result is now `CommittedTextLine`: + `CommittedText.Lines() ([]CommittedTextLine, bool)`. Its ranges are + node-relative rune offsets and its `Bounds` are window DIPs. Upstream's + `TextLine` remains UTF-16, with layout-local `Rect` and baseline/RTL metrics. + Change explicit committed-result annotations from `[]ui.TextLine` to + `[]ui.CommittedTextLine`; inferred callers need no rename. Do not convert + between the types by aliasing offsets or coordinates. +- `Handle.ScrollOffset(c)` retains `(x, y float32, ok bool)`. +- Custom `ui.TextInputClient` implementations must implement both + `EndComposition()` (transient end, pending transaction retained) and + `CancelComposition()` (rollback), alongside `UnmarkText()` (accept). + Release on blur, replacement or disposal cancels unfinished preedit rather + than accepting it. The internal native client contract also requires typed + cancellation; retired adapters cannot mutate replacement clients. See + [the migration guide](docs/ui/migration.md#text-input-composition-phases). +- `InputEvent.Text` carries real native glyph metadata on normal/raw key down + and up alongside logical, physical and code identities. It is not a second + insertion route. Consumed input denotes actual capture/handler/shortcut + ownership, not ordinary builtin printable-key bookkeeping. +- Native cancellation remains canceled `InputPointerUp` for `HandleInput`; + `HandleCapturedPointer` translates its owned primary cancellation into one + `InputPointerCancel`. Ordinary completed release never becomes cancellation. +- Menus use upstream `SetMenuPresentation`. Choice is applied before one + dismissal callback; retired hosts/owners cannot complete a replacement menu. + `ImagePNG` copies legacy full-color bytes and lazily uses upstream icons only + when no explicit `Icon` was supplied. Unused apps do not opt into decoders. + +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 fork 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 537ff486e..74958c568 100644 --- a/content.go +++ b/content.go @@ -89,13 +89,21 @@ func (w *Window) attachContent(content Content) { 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) }) }, } @@ -110,7 +118,7 @@ func (w *Window) attachContent(content Content) { 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...)} if p.Icon != nil { it.Icon = &MenuIcon{p.Icon} } 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 b432e1242..9b3a3547d 100644 --- a/dialog.go +++ b/dialog.go @@ -1,6 +1,7 @@ package mygo import ( + "context" "strings" "github.com/egoist/mygo/internal/platform" @@ -137,35 +138,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/docs/architecture.md b/docs/architecture.md index 6b6175def..ddb6792af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2448,6 +2448,26 @@ Recent (about 290 KB). ## Native UI (`ui`) +The compatibility fork integrates upstream through +`1db4e93bd48efa7e3f5a93985c8e89a4c6fa9e3d`; [LUI-FORK.md](../LUI-FORK.md) +describes its reusable extensions and source adaptations. Upstream `TextLayout` +and `TextLine` retain layout-local geometry and UTF-16 ranges. The separate +`CommittedText.Lines()` result is `[]CommittedTextLine`, with node-relative rune +ranges and window-DIP `Bounds`, invalidated with its committed handle/frame. +Explicit annotations using the fork's old `[]TextLine` name must be renamed; +inferred callers need no change. `Handle.ScrollOffset(c)` remains +`(x, y float32, ok bool)`. + +`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 @@ -2462,6 +2482,26 @@ 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. + +`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 @@ -3332,8 +3372,12 @@ either. `TextInputClient` to the same platform services. A stable, focus-checked adapter supplies absolute UTF-16 text ranges, selection, marked text, mutation callbacks, range geometry and hit testing. It is invalidated on - replacement/disposal, and focus changes unmark the old client and reset - native composition. Revealing the element, as the focus does, scrolls + replacement/disposal. Focus loss and release cancel unfinished preedit through + mandatory typed `CancelComposition`, restoring committed text and selection. + `EndComposition` only hides marking while retaining the pending transaction; + `UnmarkText` explicitly accepts it. Native end-before-commit helpers retain + UTF-16 ranges and client identity, resetting them before mutation, and retired + adapters reject callbacks. Revealing the element, as the focus does, scrolls to the client's caret (`clientCaret`), not the element's box, which a code editor as high as its lines makes as tall as the file. GTK and IMM32 use bounded surrounding context while @@ -3975,8 +4019,15 @@ either. wait for the next frame, which the first changed, as a Down and an Enter typed quickly into a menu, the Enter choosing the item the Down highlighted. 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. 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 @@ -4676,6 +4727,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/docs/input-capture.md b/docs/input-capture.md new file mode 100644 index 000000000..bc2f1904b --- /dev/null +++ b/docs/input-capture.md @@ -0,0 +1,132 @@ +# Native ownership and cancellation + +The fork retains upstream logical/physical keys and `KeyCode` while supplying +real native glyph `Text` on normal and raw key down/up. Text is metadata; +native text-input commits remain the only character insertion route. +`CaptureInput` returning true owns the event before native interpretation. +Ordinary builtin printable-key bookkeeping does not consume that native text +route. `ObserveInput` observes without owning it, and `OnTextKey` keeps its +non-text-key interception contract. Browser raw clipboard C/X/V dispatches +capture, observers and handlers without running builtin clipboard edits; +unconsumed DOM clipboard events perform the actual operation once. + +Native capture loss cancels the original gesture, not a new hit at the current +pointer. Legacy `HandleInput` receives canceled `InputPointerUp`; +`HandleCapturedPointer` receives one explicit `InputPointerCancel` for its +owned primary gesture instead. Blur, removal and menu cancellation cannot +produce a click/drop, and a late real up cannot finish the canceled gesture. +Secondary/middle activity does not cancel an independently held primary. + +# 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. + +## Bounded list ownership + +`ListState.Requested()` reports a half-open overscan window and separate indices +retained for active input or an open popup. Pins do not enlarge the contiguous +window. A host that mounts only this window must preserve those pinned rows and +their identities until they are no longer requested. `ListState.SetKeyIndex` +accepts an authoritative key lookup for distant reorders; missing keys must +return false. Passing nil restores the nearby-key search for existing callers. + +These APIs coexist with inherited pointer policy: retained rows still inherit +`PointerEvents`, and keeping a row for keyboard focus does not opt it into +pointer hits. Keep application-owned handles for identities needed across +frames. Abandoned handle bindings can be collected; an `Element` or callback- +scoped `InputTarget` is not a persistent ownership token. + +## 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; +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 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 +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 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. + +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 +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 +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/docs/ui/migration.md b/docs/ui/migration.md index f723154a9..e37d711d5 100644 --- a/docs/ui/migration.md +++ b/docs/ui/migration.md @@ -1,5 +1,234 @@ # 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 +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` +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 +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 +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. + +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 +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 +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 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 +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 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. The internal +`platform.TextInputClient` also requires typed `CancelComposition()` so native +cancellation can roll back rather than accept the transaction; no optional +fallback or new surface method is used. + +- `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 migration, build lifetimes, persistent control identity and focus bindings. @@ -96,6 +325,29 @@ codemod; it cannot infer how to write a derived value back to your model. ## Persistent identity +### Committed text geometry + +The compatibility fork integrates upstream through +`1db4e93bd48efa7e3f5a93985c8e89a4c6fa9e3d`. Upstream's `TextLine` belongs to +`TextLayout`: its offsets count UTF-16 and its `Rect` is layout-local. The +fork's committed handle observations use the distinct `CommittedTextLine`: +node-relative rune offsets and window-DIP `Bounds`, valid only for the +committed identity and frame. + +```go +// Before: var lines []ui.TextLine // the fork's former committed result +var lines []ui.CommittedTextLine +if geometry, ok := handle.TextGeometry(c); ok { + lines, ok = geometry.Lines() + _ = lines + _ = ok +} +``` + +Inferred `lines, ok := geometry.Lines()` calls need no source change. Do not +alias either type or reinterpret its ranges/coordinates. `ScrollOffset(c)` +continues returning `(x, y float32, ok bool)`. + Store `ui.Handle` instead of an element: ```go 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/darwin/access.go b/internal/darwin/access.go index 8e8aa743b..e8aa38bee 100644 --- a/internal/darwin/access.go +++ b/internal/darwin/access.go @@ -158,6 +158,7 @@ var accessRoles = map[platform.AccessRole][2]string{ // Content removed and added, as WebKit's del and ins. platform.RoleDeletion: {"AXGroup", "AXDeleteStyleGroup"}, platform.RoleInsertion: {"AXGroup", "AXInsertStyleGroup"}, + platform.RoleTabPanel: {"AXGroup", ""}, } // orientation returns the NSAccessibilityOrientation of a node, as diff --git a/internal/darwin/dialog.go b/internal/darwin/dialog.go index 5f8a63668..937c89e68 100644 --- a/internal/darwin/dialog.go +++ b/internal/darwin/dialog.go @@ -137,7 +137,22 @@ 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) + active := true + if o.RegisterCancel != nil { + o.RegisterCancel(func() { + if active { + withPool(func() { send(panel, "cancel:", 0) }) + } + }) + } runPanel(panel, asWindow(parent), "beginSheetModalForWindow:completionHandler:", func(resp int) { + if !active { + return + } + active = false + if o.RegisterCancel != nil { + o.RegisterCancel(nil) + } if resp != nsModalResponseOK { cb(nil, nil) return 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 c9b21cdf1..eb6611042 100644 --- a/internal/darwin/menu.go +++ b/internal/darwin/menu.go @@ -169,10 +169,17 @@ func applyItemState(item id, it *platform.MenuItem) { if it.Type != platform.MenuItemSeparator { applyItemSubtitle(item, it) } - if it.Type != platform.MenuItemSeparator && menuImage != nil { + if it.Type != platform.MenuItemSeparator { var img id if it.Icon != nil { - img = menuImage(it.Icon) + if menuImage != nil { + img = menuImage(it.Icon) + } + } else if len(it.ImagePNG) > 0 { + img = autorelease(send(send(class("NSImage"), "alloc"), "initWithData:", uintptr(nsData(it.ImagePNG)))) + if img != 0 { + msgSetSize(img, sel("setSize:"), NSSize{16, 16}) + } } if send(item, "image") != img { send(item, "setImage:", uintptr(img)) @@ -271,6 +278,9 @@ func (b *Backend) UpdateMenuItem(it *platform.MenuItem) { } func (b *Backend) PopupMenu(m *platform.Menu, pw platform.Window, pos *platform.Point) { + if w, ok := pw.(*window); ok && (w == nil || w.closed) { + return + } withPool(func() { owner := newOwner() defer dropOwner(owner) diff --git a/internal/darwin/surface.go b/internal/darwin/surface.go index a8290a699..a12ef4e40 100644 --- a/internal/darwin/surface.go +++ b/internal/darwin/surface.go @@ -249,7 +249,7 @@ func (b *Backend) watchCommandKeyUps() { if s == nil { return ev } - s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Code: eventKeyCode(ev), Mods: mods}) + s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Code: eventKeyCode(ev), Mods: mods, Text: typedChars(stringOf(send(ev, "characters")))}) return 0 }) commandKeyUps = retain(send(class("NSEvent"), "addLocalMonitorForEventsMatchingMask:handler:", mask, uintptr(blk))) @@ -257,6 +257,7 @@ func (b *Backend) watchCommandKeyUps() { func (s *surface) destroy() { s.CancelDataDrag() + s.cancelTextInput() if s.link != 0 { send(s.link, "invalidate") release(s.link) @@ -755,23 +756,26 @@ func (s *surface) SetCursor(c platform.Cursor) { } func (s *surface) SetTextInput(t platform.TextInputState) { - if s.input.Client != t.Client && s.input.Active { - s.discardComposition() - } - if s.input.Active && t.Active && t.NoInputMethod && !s.input.NoInputMethod { - // Input methods turned off give up what they composed. - s.discardComposition() - } - if s.input.Active && !t.Active && s.marked != "" { - s.discardComposition() - } moved := t.Active != s.input.Active || t.Caret != s.input.Caret + if s.input.Active && (s.input.Client != t.Client || !t.Active || t.NoInputMethod && !s.input.NoInputMethod) { + s.cancelTextInput() + } s.input = t if t.Active && moved { send(send(s.view, "inputContext"), "invalidateCharacterCoordinates") } } +func (s *surface) cancelTextInput() { + client := s.input.Client + s.input.Client, s.input.Active = nil, false + s.withheld = nil + s.discardComposition() + if client != nil { + client.CancelComposition() + } +} + // surfaceKeyChanged tells the content of a window that became or ceased // to be the key window that its surface gained or lost the keyboard, as // focus events do on the other platforms: AppKit tells views only when @@ -786,6 +790,9 @@ func (w *window) surfaceKeyChanged(key bool) { kind = platform.SurfaceFocus } s.send(platform.SurfaceEvent{Kind: kind}) + if !key { + s.cancelTextInput() + } } func (s *surface) send(ev platform.SurfaceEvent) bool { @@ -1256,6 +1263,7 @@ func registerSurfaceClass() { method("resignFirstResponder", func(self id, _ objc.SEL) bool { if s := b().surfaceOf(self); s != nil { s.send(platform.SurfaceEvent{Kind: platform.SurfaceBlur}) + s.cancelTextInput() } return true }), @@ -1337,10 +1345,10 @@ func registerSurfaceClass() { // composition up, as GTK's and IMM32's filtering keeps them on // Linux and Windows, unless the input method passes it on // (doCommandBySelector:). - key := platform.SurfaceEvent{Kind: platform.KeyPressed, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Code: eventKeyCode(ev), Mods: mods, Repeat: sendBool(ev, "isARepeat")} + key := platform.SurfaceEvent{Kind: platform.KeyPressed, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Code: eventKeyCode(ev), Mods: mods, Repeat: sendBool(ev, "isARepeat"), Text: typedChars(stringOf(send(ev, "characters")))} withheld := ime && s.hasMarkedText() - if !withheld { - s.send(key) + if !withheld && s.send(key) { + return } // Input methods see the key while a text input has the focus; // they answer with insertText: or setMarkedText:. @@ -1373,7 +1381,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), Code: eventKeyCode(ev), Mods: eventMods(ev)}) + s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: eventKey(ev), PhysicalKey: physicalKey(ev), Code: eventKeyCode(ev), Mods: eventMods(ev), Text: typedChars(stringOf(send(ev, "characters")))}) } }), method("flagsChanged:", func(self id, _ objc.SEL, ev id) { diff --git a/internal/darwin/testhooks.go b/internal/darwin/testhooks.go index 00202c594..a1222db5f 100644 --- a/internal/darwin/testhooks.go +++ b/internal/darwin/testhooks.go @@ -111,6 +111,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 +} + // TestWindowOrder returns the app's windows on screen, panels too, front // to back. func TestWindowOrder() []uintptr { @@ -183,6 +233,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) } + // TestPostMouse presses (down) or releases the primary button at (x, y) // on screen, in MyGo's coordinates (top-left origin), as the user's mouse // does: through the window server, to whatever shows there, the menu 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/linux/dialog.go b/internal/linux/dialog.go index f83b719c6..7b0eaa338 100644 --- a/internal/linux/dialog.go +++ b/internal/linux/dialog.go @@ -90,10 +90,22 @@ 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) + live := true + if o.RegisterCancel != nil { + o.RegisterCancel(func() { + if live { + gtkNativeDialogHide(fc) + } + }) + } var paths []string if d.b.runDialog(fc, true) == gtkResponseAccept { paths = slistStrings(gtkFileChooserGetFilenames(fc)) } + live = false + if o.RegisterCancel != nil { + o.RegisterCancel(nil) + } gObjectUnref(fc) cb(paths, nil) } diff --git a/internal/linux/ffi.go b/internal/linux/ffi.go index 102c46f64..79286949c 100644 --- a/internal/linux/ffi.go +++ b/internal/linux/ffi.go @@ -301,6 +301,7 @@ 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 gtkBoxNew func(orientation int32, spacing int32) ptr gtkBoxPackStart func(box, child ptr, expand, fill bool, padding uint32) gtkBoxReorderChild func(box, child ptr, pos int32) @@ -742,6 +743,7 @@ 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, >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..52cb76818 --- /dev/null +++ b/internal/linux/lui_menu_access.go @@ -0,0 +1,89 @@ +//go:build linux && (amd64 || arm64) + +package linux + +import ( + "bytes" + "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) +var menuImageSetFromPixbuf func(ptr, ptr) + +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") + mustBind(libGTK, &menuImageSetFromPixbuf, "gtk_image_set_from_pixbuf") + }) +} + +// 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))) +} + +func setLegacyMenuItemImage(n *nativeItem, data []byte) { + n.imagePNG = bytes.Clone(data) + if len(data) == 0 && n.image == 0 { + return + } + loadMenuAccess() + var pix ptr + if len(data) != 0 { + pix, _ = pixbufFromPNG(data) + } + if pix != 0 { + defer gObjectUnref(pix) + } + if n.image == 0 { + if pix == 0 { + return + } + label := menuBinGetChild(n.item) + if label == 0 { + return + } + gObjectRef(label) + gtkContainerRemove(n.item, label) + row := gtkBoxNew(0, 6) + n.image = gtkImageNewFromPixbuf(0) + gtkWidgetSetNoShowAll(n.image, true) + gtkBoxPackStart(row, n.image, false, false, 0) + gtkBoxPackStart(row, label, true, true, 0) + gObjectUnref(label) + gtkContainerAdd(n.item, row) + gtkWidgetShow(row) + gtkWidgetShow(label) + n.label = label + } + menuImageSetFromPixbuf(n.image, pix) + gtkWidgetSetVisible(n.image, pix != 0) +} 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 d56565341..9cbcd1b9f 100644 --- a/internal/linux/menu.go +++ b/internal/linux/menu.go @@ -3,6 +3,7 @@ package linux import ( + "bytes" "fmt" "os" "path/filepath" @@ -32,6 +33,7 @@ type nativeItem struct { icon *platform.MenuIcon image, label ptr bar bool // an item of a menu bar, which shows no icon + imagePNG []byte } var ( @@ -257,11 +259,8 @@ func buildItem(it *platform.MenuItem, accel ptr, owner, windowID int, bar bool) } applyState(item, it, check) n := nativeItem{owner: owner, item: item, check: check, windowID: windowID, disabled: !it.Enabled, bar: bar} - if it.Icon != nil && it.Type != platform.MenuItemSeparator && !bar && setMenuItemIcon != nil { - n.icon = it.Icon - if windowID != 0 { - setMenuItemIcon(&n, it.Icon) // a tray's once its menu is exported - } + if it.Type != platform.MenuItemSeparator && !bar { + updateMenuItemImage(&n, it, true) } itemsByID[it.ID] = append(itemsByID[it.ID], n) return item @@ -435,13 +434,13 @@ func (b *Backend) UpdateMenuItem(it *platform.MenuItem) { n := &itemsByID[it.ID][i] if it.Type != platform.MenuItemSeparator { if n.label != 0 { - gtkLabelSetLabel(n.label, cs(it.LabelWithSubtitle())) + loadMenuAccess() + menuLabelSetText(n.label, cs(it.LabelWithSubtitle())) } else { gtkMenuItemSetLabel(n.item, cs(it.LabelWithSubtitle())) } - if it.Icon != n.icon && !n.bar && setMenuItemIcon != nil { - n.icon = it.Icon - setMenuItemIcon(n, it.Icon) + if !n.bar { + updateMenuItemImage(n, it, false) } } applyState(n.item, it, n.check) @@ -449,6 +448,24 @@ func (b *Backend) UpdateMenuItem(it *platform.MenuItem) { } } +func updateMenuItemImage(n *nativeItem, it *platform.MenuItem, building bool) { + if it.Icon != nil { + if (it.Icon != n.icon || len(n.imagePNG) != 0) && setMenuItemIcon != nil && (!building || n.windowID != 0) { + setMenuItemIcon(n, it.Icon) + } + n.icon, n.imagePNG = it.Icon, nil + } else if n.icon != nil || !bytes.Equal(n.imagePNG, it.ImagePNG) { + if n.icon != nil && setMenuItemIcon != nil { + setMenuItemIcon(n, nil) + } + n.icon = nil + setLegacyMenuItemImage(n, it.ImagePNG) + } + if n.label != 0 { + nameMenuItem(n.item, it.LabelWithSubtitle()) + } +} + // enableEditItems makes the items of the edit roles in the window's menu // bar and popups sensitive as the native UI it shows can act on them. // Those of a page keep the app's state: what a page would do is not known @@ -477,8 +494,10 @@ func (b *Backend) PopupMenu(m *platform.Menu, pw platform.Window, pos *platform. // GTK positions menus relative to a window: the given one, else the // active one. w, _ := pw.(*window) - if w == nil || w.closed { - w = nil + if pw != nil && (w == nil || w.closed) { + return + } + if w == nil { for _, x := range b.windows { if !x.closed && gtkWindowIsActive(x.win) { w = x @@ -529,8 +548,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.go b/internal/linux/surface.go index c3a803961..c3a0288e7 100644 --- a/internal/linux/surface.go +++ b/internal/linux/surface.go @@ -8,6 +8,7 @@ import ( "math" "os" "sync" + "unicode" "unicode/utf8" "unsafe" @@ -776,6 +777,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 { @@ -1019,9 +1029,14 @@ func (s *surface) keyEvent(event ptr) bool { } s.lastKey = gdkEventCopy(event) } + taken := false if s.rawInput() { if kind == platform.KeyPressed { - defer s.typed(event) + defer func() { + if !taken && !s.w.closed { + s.typed(event) + } + }() } } else if s.textInput && gtkIMContextFilterKeypress(s.im, event) { if kind == platform.KeyReleased { @@ -1030,7 +1045,10 @@ func (s *surface) keyEvent(event ptr) bool { // the key was held: the key goes up all the same. One it // hands back, as IBus and Fcitx do the keys they leave, // comes again, which held keeps from the content. - s.held.Lift(keyvalKey(field[uint32](event, 28)), eventPhysicalKey(event), gdkMods(field[uint32](event, 24)), s.send) + s.held.Lift(keyvalKey(field[uint32](event, 28)), eventPhysicalKey(event), gdkMods(field[uint32](event, 24)), func(ev platform.SurfaceEvent) bool { + ev.Text = keyvalText(field[uint32](event, 28)) + return s.send(ev) + }) } return true } @@ -1045,7 +1063,8 @@ func (s *surface) keyEvent(event ptr) bool { state &^= bit } s.send(platform.SurfaceEvent{Kind: platform.ModifiersChanged, Mods: state}) - return s.rawKey(kind, event, state, repeat) + taken = s.rawKey(kind, event, state, repeat) + return taken } k := keyvalKey(field[uint32](event, 28)) mods := gdkMods(field[uint32](event, 24)) @@ -1058,17 +1077,18 @@ func (s *surface) keyEvent(event ptr) bool { if k == platform.KeyUnknown { switch { case kind == platform.KeyReleased && physical != platform.KeyUnknown: - s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Code: code, Mods: mods}) - return false + taken = s.send(platform.SurfaceEvent{Kind: kind, PhysicalKey: physical, Code: code, Mods: mods, Text: keyvalText(field[uint32](event, 28))}) + return taken case !platform.KeyByPosition(k, physical, mods): - return s.rawKey(kind, event, mods, repeat) + taken = s.rawKey(kind, event, mods, repeat) + return taken } } if kind == platform.KeyPressed { s.pressing = event defer func() { s.pressing = 0 }() } - s.send(platform.SurfaceEvent{Kind: kind, Key: k, PhysicalKey: physical, Code: code, Mods: mods, Repeat: repeat}) + taken = s.send(platform.SurfaceEvent{Kind: kind, Key: k, PhysicalKey: physical, Code: code, Mods: mods, Repeat: repeat, Text: keyvalText(field[uint32](event, 28))}) return true } @@ -1084,7 +1104,7 @@ func (s *surface) rawKey(kind platform.SurfaceEventKind, event ptr, mods platfor if kind == platform.KeyReleased { raw = platform.RawKeyReleased } - return s.send(platform.SurfaceEvent{Kind: raw, PhysicalKey: eventPhysicalKey(event), Code: code, Mods: mods, Repeat: repeat}) + return s.send(platform.SurfaceEvent{Kind: raw, PhysicalKey: eventPhysicalKey(event), Code: code, Mods: mods, Repeat: repeat, Text: keyvalText(field[uint32](event, 28))}) } func (b *Backend) surfaceOf(data ptr) *surface { @@ -1363,9 +1383,11 @@ func initSurfaceCallbacks() { }) cbSurfaceFocusOut = purego.NewCallback(func(widget, event, data ptr) bool { if s := b().surfaceOf(data); s != nil { - gtkIMContextFocusOut(s.im) s.down.clear() s.send(platform.SurfaceEvent{Kind: platform.SurfaceBlur}) + if s.im != 0 { + gtkIMContextFocusOut(s.im) + } } return false }) diff --git a/internal/linux/surface_access.go b/internal/linux/surface_access.go index 29914cebb..1793f9206 100644 --- a/internal/linux/surface_access.go +++ b/internal/linux/surface_access.go @@ -352,6 +352,7 @@ func loadAccessNames() { platform.RoleGrid: {"table"}, platform.RoleTreeGrid: {"tree table", "table"}, // Content removed and added (ATK 2.34), as Chromium's del and ins. platform.RoleDeletion: {"content deletion", "panel"}, platform.RoleInsertion: {"content insertion", "panel"}, + platform.RoleTabPanel: {"panel"}, } { atkRoles[r] = role(names...) } diff --git a/internal/linux/testhooks.go b/internal/linux/testhooks.go index 8d6911772..e1b4ae87c 100644 --- a/internal/linux/testhooks.go +++ b/internal/linux/testhooks.go @@ -416,7 +416,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 a72aa8507..e58a72edf 100644 --- a/internal/platform/access.go +++ b/internal/platform/access.go @@ -235,6 +235,8 @@ const ( // ARIA's deletion and insertion, HTML's del and ins. RoleDeletion RoleInsertion + // RoleTabPanel holds the content associated with a tab. + RoleTabPanel ) // Tabular reports whether nodes of a role hold rows of cells: tables, diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 2b11be2d8..3f58a6140 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -1039,6 +1039,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 @@ -1076,6 +1078,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/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/platform/text_input.go b/internal/platform/text_input.go index 81cf22e3c..52816a33c 100644 --- a/internal/platform/text_input.go +++ b/internal/platform/text_input.go @@ -33,6 +33,8 @@ type TextInputClient interface { MarkedRange() (TextRange, bool) ReplaceText(*TextRange, string) SetMarkedText(*TextRange, string, TextRange) + EndComposition() + CancelComposition() UnmarkText() BoundsForRange(TextRange) (RectF, TextRange, bool) IndexForPoint(x, y float64) (int, bool) @@ -40,31 +42,34 @@ 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() } -// Cancel discards the marked text, for an input method that gives its -// composition up without emptying it first. The rest of the document, -// the selection included, stays as it is. +// Cancel discards unaccepted preedit, restoring the client's committed text +// and selection without accepting a replacement. func (c *InputComposition) Cancel(client TextInputClient) { - c.pending = nil - if _, ok := client.MarkedRange(); ok { - client.SetMarkedText(nil, "", TextRange{}) - client.UnmarkText() - } + c.Reset() + client.CancelComposition() } 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/internal/text/layout.go b/internal/text/layout.go index 1ce51871d..f6e85ea4e 100644 --- a/internal/text/layout.go +++ b/internal/text/layout.go @@ -1452,6 +1452,7 @@ func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetr } } size := p.Style.FontSize() + var objectAscent, objectDescent float32 x0 := float32(math.MaxFloat32) starts := *buf for _, run := range sl.runs { @@ -1503,6 +1504,8 @@ func line(p Params, text []rune, sl shapedLine, offset int, rtl bool, m lineMetr g.Object, g.Advance, g.ID = true, sp.ObjectWidth, 0 line.Ascent = max(line.Ascent, sp.ObjectHeight+shift) line.Descent = max(line.Descent, sp.ObjectDescent-shift) + objectAscent = max(objectAscent, sp.ObjectHeight+shift) + objectDescent = max(objectDescent, sp.ObjectDescent-shift) case shift != 0 && g.Font != nil: line.Ascent = max(line.Ascent, g.Font.Ascent+shift) line.Descent = max(line.Descent, g.Font.Descent-shift) @@ -1525,6 +1528,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 || objectDescent > 0 { + line.Height = max(line.Height, objectAscent+max(line.Descent, objectDescent)) + } + } 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/text/pango_linux.go b/internal/text/pango_linux.go index 8a25e2d5c..eb09aff37 100644 --- a/internal/text/pango_linux.go +++ b/internal/text/pango_linux.go @@ -63,6 +63,7 @@ const ( cairoHintMetricsOn = 2 cairoStatusSuccess = 0 fcResultMatch = 0 + fcRGBANone = 5 fcMatchPattern = 0 fcLangEqual = 0 fcSetApplication = 1 @@ -134,6 +135,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 @@ -183,6 +185,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) @@ -190,6 +193,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 @@ -212,11 +217,13 @@ 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 - fcPatternDestroy func(pattern uintptr) - fcPatternAddString func(pattern uintptr, object, s string) int32 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 fcFontSetAdd func(set *fcFontSet, pattern uintptr) int32 fcPatternGetLangSet func(pattern uintptr, object string, n int32, out *uintptr) int32 fcLangSetHasLang func(langs uintptr, lang string) int32 @@ -225,6 +232,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 @@ -257,6 +275,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) @@ -276,6 +302,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") @@ -328,6 +359,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") @@ -344,6 +377,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") bind(cairo, &l.cairoVersion, "cairo_version") if len(missing) > 0 { return fmt.Errorf("Pango or cairo lacks %s", strings.Join(missing, ", ")) @@ -359,26 +394,22 @@ 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.fcPatternDuplicate, "FcPatternDuplicate") - bind(fc, &l.fcPatternDel, "FcPatternDel") - bind(fc, &l.fcFontSetAdd, "FcFontSetAdd") - 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.fcFontSetAdd, "FcFontSetAdd") + 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") + if len(missing) > 0 { + return fmt.Errorf("Pango or cairo lacks %s", strings.Join(missing, ", ")) } return nil } @@ -496,6 +527,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) pinLCDFilter() // Until the app has the desktop's settings, GDK's defaults on X11 @@ -1110,6 +1142,7 @@ 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 != cairoAntialiasNone { // Also where the desktop's settings leave antialiasing to // fontconfig (cairo's default), which may give the font subpixel @@ -1118,8 +1151,17 @@ func (e *pangoEngine) scaledFont(f *Font, scale float32, subpixel bool) uintptr // whatever the text's color (#1426). Fonts fontconfig draws // without antialiasing keep it off. 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 244b024c3..f4b5cd1f7 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. 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. diff --git a/internal/web/surface.go b/internal/web/surface.go index b1ff15c8b..4fa9efb30 100644 --- a/internal/web/surface.go +++ b/internal/web/surface.go @@ -7,6 +7,7 @@ import ( "math" "strconv" "syscall/js" + "unicode" "unicode/utf8" "github.com/egoist/mygo/internal/gpu/gl" @@ -42,9 +43,6 @@ type surface struct { closed bool composing bool text platform.TextInputState - // copied tells that a key copying went to the content, which the copy - // event that follows does not repeat. - copied bool // held are the keys the content got pressed and not yet released, // whose key ups an input method or Command may keep (key). held platform.HeldKeys @@ -52,6 +50,7 @@ type surface struct { // downX and downY place the last press, where a context menu opens. downX, downY float64 clicks platform.ClickCounter + buttons int } func newSurface(w *window) (*surface, error) { @@ -168,8 +167,14 @@ func (s *surface) listen() { if kind, b, ok := chord(e.Get("button").Int(), e.Get("buttons").Int()); ok { ev := platform.SurfaceEvent{Kind: kind, X: x, Y: y, Button: b, Mods: s.mods(e), Pointer: pointer, Pressure: pressure} if kind == platform.PointerDown { + s.buttons |= 1 << b s.downX, s.downY = x, y ev.Clicks = s.clicks.Press(b, x, y, uint32(e.Get("timeStamp").Float()), platform.DefaultDoubleClick) + } else { + if s.buttons&(1< 0 && ds.bmHeight != 0 { + var label [512]uint16 + procGetMenuStringW.Call(menu, uintptr(index), uintptr(unsafe.Pointer(&label[0])), uintptr(len(label)), mfByPosition) + labels = append(labels, wstr(uintptr(unsafe.Pointer(&label[0])))) + } + } + } + } + return labels +} diff --git a/internal/windows/menu.go b/internal/windows/menu.go index aa88a9371..615a9f67d 100644 --- a/internal/windows/menu.go +++ b/internal/windows/menu.go @@ -105,7 +105,7 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac column, icons := false, false for _, it := range m.Items { column = column || it.Visible && (it.Type == platform.MenuItemCheckbox || it.Type == platform.MenuItemRadio || it.Checked) - icons = icons || it.Visible && it.Icon != nil && it.Type != platform.MenuItemSeparator + icons = icons || it.Visible && (it.Icon != nil || len(it.ImagePNG) != 0) && it.Type != platform.MenuItemSeparator } // The items of a menu bar show no icons; Windows 10 draws those of its // menus' as bitmaps. @@ -114,11 +114,11 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac if icons && !drawn { tint = b.menuTextColor() } - bitmap := func(it *platform.MenuItem, item uint32, byPosition bool) uintptr { - if !icons || drawn || it.Icon == nil { + bitmap := func(ic *platform.MenuIcon, item uint32, byPosition bool) uintptr { + if !icons || drawn || ic == nil { return 0 } - bmp := b.menus.addBitmap(owner, it.Icon, dpi, tint) + bmp := b.menus.addBitmap(owner, ic, dpi, tint) if bmp != 0 { setItemBitmap(h, item, byPosition, bmp) } @@ -149,17 +149,18 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac if !it.Enabled { flags |= mfGrayed } + icon := menuItemIcon(it) if it.Submenu != nil { sub := b.buildMenu(it.Submenu, false, owner, accels, dpi) procAppendMenuW.Call(h, flags|mfPopup, sub, uintptr(unsafe.Pointer(u16(menuLabel(it))))) - bitmap(it, uint32(pos), true) - look(&menuLook{label: menuLabel(it), submenu: true, icon: it.Icon}) + bitmap(icon, uint32(pos), true) + look(&menuLook{label: menuLabel(it), submenu: true, icon: icon}) continue } if it.Checked { flags |= mfChecked } - e := &menuEntry{uid: it.ID, role: it.Role, owner: owner, menu: h, radio: it.Type == platform.MenuItemRadio, disabled: !it.Enabled, icon: it.Icon, native: it.Native} + e := &menuEntry{uid: it.ID, role: it.Role, owner: owner, menu: h, radio: it.Type == platform.MenuItemRadio, disabled: !it.Enabled, icon: icon, native: it.Native} if !bar { e.dpi = dpi } @@ -180,8 +181,8 @@ 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.bmp = bitmap(it, uint32(cmd), false) - e.look = look(&menuLook{label: menuLabel(it), accel: e.accel, radio: e.radio, icon: it.Icon}) + e.bmp = bitmap(icon, uint32(cmd), false) + e.look = look(&menuLook{label: menuLabel(it), accel: e.accel, radio: e.radio, icon: icon}) if e.radio && e.look == nil { setItemInfo(h, cmd, label, it.Enabled, it.Checked, true, false) } @@ -192,6 +193,16 @@ func (b *Backend) buildMenu(m *platform.Menu, bar bool, owner int, accels map[ac return h } +func menuItemIcon(it *platform.MenuItem) *platform.MenuIcon { + if it.Icon != nil { + return it.Icon + } + if len(it.ImagePNG) != 0 { + return &platform.MenuIcon{PNG: append([]byte(nil), it.ImagePNG...)} + } + return nil +} + func setItemInfo(menu uintptr, cmd uint16, label string, enabled, checked, radio, drawn bool) { mi := menuItemInfo{Mask: miimState | miimFType | miimString, TypeData: u16(label)} mi.Size = uint32(unsafe.Sizeof(mi)) @@ -355,20 +366,21 @@ func (b *Backend) UpdateMenuItem(it *platform.MenuItem) { if e.accel != "" { label += "\t" + e.accel } + icon := menuItemIcon(it) if e.look != nil { - e.look.label, e.look.icon = menuLabel(it), it.Icon - if it.Icon != nil && !e.look.icons { + e.look.label, e.look.icon = menuLabel(it), icon + if icon != nil && !e.look.icons { b.menus.iconColumn(e.menu) } - } else if it.Icon != e.icon && e.dpi != 0 { + } else if icon != e.icon && e.dpi != 0 { b.menus.freeBitmap(e.bmp) e.bmp = 0 - if it.Icon != nil { - e.bmp = b.menus.addBitmap(e.owner, it.Icon, e.dpi, b.menuTextColor()) + if icon != nil { + e.bmp = b.menus.addBitmap(e.owner, icon, e.dpi, b.menuTextColor()) } setItemBitmap(e.menu, uint32(cmd), false, e.bmp) } - e.icon, e.disabled = it.Icon, !it.Enabled + e.icon, e.disabled = icon, !it.Enabled setItemInfo(e.menu, cmd, label, it.Enabled, it.Checked, e.radio, e.look != nil) } for _, w := range b.windows { @@ -493,10 +505,11 @@ func jsString(s string) string { func (b *Backend) PopupMenu(m *platform.Menu, pw platform.Window, pos *platform.Point) { owner := b.appHwnd w, _ := pw.(*window) - if w != nil && !w.closed { + if w != nil && w.closed { + return + } + if w != nil { owner = w.hwnd - } else { - w = nil } id := b.menus.newOwner() defer b.menus.drop(id) diff --git a/internal/windows/surface.go b/internal/windows/surface.go index 3751f5012..0f73112ed 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") @@ -906,41 +910,37 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool if modifierKey(wp) { s.send(platform.SurfaceEvent{Kind: platform.ModifiersChanged, Mods: modifiers}) } - if s.menuShortcut(wp, false) { - return 0, true - } - rawTaken := false 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}) + 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 { - rawTaken = s.rawKey(platform.RawKeyPressed, wp, lp, physical, modifiers) - s.keyTaken = rawTaken + s.keyTaken = s.rawKey(platform.RawKeyPressed, wp, lp, physical, modifiers, beforeContent) } if !s.keyTaken && s.menuShortcut(wp, true) { return 0, true } // Alt+F4, Alt+Space and F10 keep working, and Alt the menu bar, unless // the content took it as a key of its own. - return 0, m == wmKeyDown || rawTaken + return 0, m == wmKeyDown || s.keyTaken case wmKeyUp, wmSysKeyUp: modifiers := mods() if modifierKey(wp) { s.send(platform.SurfaceEvent{Kind: platform.ModifiersChanged, Mods: modifiers}) } - rawTaken := false + taken := false k, physical, ok := keyAndPosition(wp, lp, true, modifiers) if ok { - s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: k, PhysicalKey: physical, Code: keyCode(wp, lp), Mods: modifiers}) + taken = s.send(platform.SurfaceEvent{Kind: platform.KeyReleased, Key: k, PhysicalKey: physical, Code: keyCode(wp, lp), Mods: modifiers, Text: keyText(wp, lp)}) } else if wp == vkProcessKey && lp&(1<<24) == 0 { // The input method took the release of a key the content got // pressed, as it began composing while the key was held: the // key goes up all the same, by its position. s.held.Lift(platform.KeyUnknown, platform.KeyForScancode(uint16(lp>>16&0xFF)), modifiers, s.send) } else { - rawTaken = s.rawKey(platform.RawKeyReleased, wp, lp, physical, modifiers) + taken = s.rawKey(platform.RawKeyReleased, wp, lp, physical, modifiers, nil) } - return 0, m == wmKeyUp || rawTaken + return 0, m == wmKeyUp || taken case wmSysChar: if s.menuRan { return 0, true @@ -956,7 +956,7 @@ func (s *surface) message(hwnd uintptr, m uint32, wp, lp uintptr) (uintptr, bool return 0, true } case wmChar: - if s.menuRan { + if s.menuRan || s.keyTaken { return 0, true } c := uint16(wp) @@ -1060,6 +1060,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 +} + // keyCode returns the key by position of a WM_KEYDOWN or WM_KEYUP, from its // scancode: CodeUnknown for what an input method (VK_PROCESSKEY) or injected // Unicode text (VK_PACKET) keeps from the application, and for a key the @@ -1074,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}) + 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/internal/windows/surface_access.go b/internal/windows/surface_access.go index 9aedb9d5f..561d8e611 100644 --- a/internal/windows/surface_access.go +++ b/internal/windows/surface_access.go @@ -195,6 +195,7 @@ var uiaControlTypes = map[platform.AccessRole]int32{ platform.RoleSeparator: 50038, platform.RoleRowHeader: 50035, platform.RoleSearch: 50026, platform.RoleArticle: 50026, platform.RoleGrid: 50028, platform.RoleTreeGrid: 50028, // DataGrid platform.RoleDeletion: 50020, platform.RoleInsertion: 50020, + platform.RoleTabPanel: 50033, } // ariaRoles are the ARIA roles of nodes whose control type does not say 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 23e6b5a6f..b3707229e 100644 --- a/internal/windows/testhooks_surface.go +++ b/internal/windows/testhooks_surface.go @@ -42,7 +42,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 @@ -66,7 +69,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 032fb9774..204a7d3ca 100644 --- a/menu.go +++ b/menu.go @@ -118,6 +118,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 @@ -549,9 +552,12 @@ func (it *MenuItem) snapshotLocked() *platform.MenuItem { Visible: !it.Hidden, Checked: it.Checked, ToolTip: it.ToolTip, + ImagePNG: append([]byte(nil), it.imagePNG...), } if it.Icon != nil { p.Icon = it.Icon.p + } else if len(p.ImagePNG) != 0 { + p.Icon = &platform.MenuIcon{PNG: append([]byte(nil), p.ImagePNG...), Template: false} } // A web view's item does what the web view does, unless the app gave // it a Click of its own. 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..dcde6f53d --- /dev/null +++ b/testing/native/windows.go @@ -0,0 +1,46 @@ +//go:build windows && lui_native_smoke + +package native + +import "github.com/egoist/mygo/internal/windows" + +func init() { windows.LinkMenuIcons() } + +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 b4102bc85..89d5d9363 100644 --- a/ui/access.go +++ b/ui/access.go @@ -156,6 +156,8 @@ const ( // }) RoleDeletion RoleInsertion + // RoleTabPanel holds the content of the selected tab. + RoleTabPanel ) // tabular reports whether elements of a role hold rows of cells: tables, @@ -553,6 +555,8 @@ func (e *node) accessRole() (platform.AccessRole, bool) { return platform.RoleDeletion, true case RoleInsertion: return platform.RoleInsertion, true + case RoleTabPanel: + return platform.RoleTabPanel, true default: return platform.AccessRole(e.role - RoleGroup), true } @@ -588,7 +592,7 @@ func cellRole(r platform.AccessRole) bool { // sort button, can be reached. 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.RoleColumnHeader, platform.RoleRowHeader, platform.RoleTree, platform.RoleListItem, platform.RoleToolbar, platform.RoleRadioGroup, platform.RoleMenu, platform.RoleMenuBar, platform.RoleHeading, platform.RoleNavigation, platform.RoleRegion, platform.RoleLog, platform.RoleTimer, platform.RoleAlert, platform.RoleSearch, diff --git a/ui/api_gen.go b/ui/api_gen.go index a0fe8b5a8..368ecd489 100644 --- a/ui/api_gen.go +++ b/ui/api_gen.go @@ -5330,6 +5330,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..b103ab3b5 --- /dev/null +++ b/ui/captured_pointer_test.go @@ -0,0 +1,148 @@ +package ui + +import ( + "fmt" + "testing" + + "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} { + 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) { + 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 487f048d8..8146dbd80 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 @@ -77,6 +77,22 @@ 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. +// 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 { + return rt.windowFocusEpoch + } + return 0 +} + const chunkSize = 32 // alloc returns a zeroed element from the frame's arena. @@ -99,6 +115,10 @@ 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 { clear(f.members) f.members = f.members[:0] @@ -722,6 +742,7 @@ type state struct { // accessDisabled, that assistive technology reads it as unavailable // (AccessDisabled). ignoreInput, accessHidden, accessDisabled bool + pointerEvents bool // activeDescendant is the element that has the keyboard focus while // this one does (ActiveDescendant), 0 for none. activeDescendant uint64 @@ -760,9 +781,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 // pressButton is the button pressing the element: 0 the primary, 1 // the secondary, 2 the middle (pressedPrimary). pressButton int diff --git a/ui/editor/build.go b/ui/editor/build.go index a8dbbf2c1..e8ef21abe 100644 --- a/ui/editor/build.go +++ b/ui/editor/build.go @@ -71,8 +71,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) @@ -208,45 +207,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 { @@ -263,7 +263,7 @@ func (ed *editor) menu(m *ui.Menu) { continue } if m.Item(it.label).Chosen() { - ed.run(it.cmd) + run(it.cmd) } } }) @@ -271,16 +271,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 3b1cd8ce9..8611cca9b 100644 --- a/ui/editor/edit.go +++ b/ui/editor/edit.go @@ -14,6 +14,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 7accc21b7..8fb472467 100644 --- a/ui/editor/editor.go +++ b/ui/editor/editor.go @@ -54,6 +54,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) @@ -150,6 +151,12 @@ 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 autoclosed []autoclose scrollY float64 @@ -398,6 +405,8 @@ 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.markedEnded, ed.markedBefore, ed.markedSels = false, Snapshot{}, nil ed.snippet = nil ed.sels = []sel{{goal: -1, id: ed.nextID}} ed.nextID++ @@ -413,6 +422,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 ed.snippet != nil && !ed.snippet.contains(sels) { diff --git a/ui/editor/hooks_test.go b/ui/editor/hooks_test.go index eee78cf24..c7b30ff33 100644 --- a/ui/editor/hooks_test.go +++ b/ui/editor/hooks_test.go @@ -2,12 +2,85 @@ 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.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() { + 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.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) + 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 ebdedc41f..b7ced7c9f 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 @@ -615,7 +617,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 @@ -676,8 +678,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() @@ -694,6 +698,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) @@ -769,12 +783,44 @@ 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 + 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 725f5fc0b..6a43ad643 100644 --- a/ui/editor/state.go +++ b/ui/editor/state.go @@ -130,6 +130,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 @@ -233,7 +239,7 @@ func (st *State) options() options { placeholder: st.Placeholder, label: st.Label, theme: st.Theme, bindings: st.Bindings, gutterMarks: st.GutterMarks, backgrounds: st.LineBackgrounds, textBgs: st.TextBackgrounds, diffBase: st.DiffBase, onCompletion: st.OnCompletion, onHover: st.OnHover, onDefinition: st.OnDefinition, hoverDelay: st.HoverDelay, noPointerHover: st.NoPointerHover, - 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, } @@ -297,6 +303,25 @@ 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.CancelComposition() + 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) + ed.sync() + } +} + // Run runs a command as its key would, and reports whether it did // something. func (st *State) Run(cmd Command) bool { diff --git a/ui/editor_input.go b/ui/editor_input.go index fe3d0fb63..9fb00a399 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 @@ -214,11 +216,11 @@ func (w *widgetTextInput) SetMarkedText(r *TextInputRange, s string, selected Te if ed.readOnly { return } - if s == "" && r == nil && ed.compose == "" { - // A preedit that is already gone, as GTK clears it after a commit - // delivered the text: nothing to mark, and no undo step to start. + if s == "" { + w.CancelComposition() return } + w.ended = false if ed.compose == "" || r != nil { a, z := ed.selection() ranges := ed.selectedRanges() @@ -257,11 +259,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/element.go b/ui/element.go index 10afc1d03..9f8dad05e 100644 --- a/ui/element.go +++ b/ui/element.go @@ -46,8 +46,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 @@ -57,14 +58,15 @@ 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} } // bound is a minimum or a maximum size, in DIPs, by a percentage of the // parent's, or by both, as CSS's max-width: min(78%, 34rem): the larger // of two minimums applies, and the smaller of two maximums. type bound struct { px, pct float32 + offset float32 set uint8 // boundPx, boundPct } @@ -74,7 +76,7 @@ const ( ) func (b *bound) setPx(v float32) { b.px, b.set = v, b.set|boundPx } -func (b *bound) setPct(p float32) { b.pct, b.set = p, b.set|boundPct } +func (b *bound) setPct(p float32) { b.pct, b.offset, b.set = p, 0, b.set|boundPct } // lower and upper return a minimum and a maximum in DIPs against base, // and false when none applies: a percentage applies only against a known @@ -96,7 +98,7 @@ func (b bound) upper(base float32) (float32, bool) { func (b bound) resolve(base float32, larger bool) (float32, bool) { v, ok := b.px, b.set&boundPx != 0 if b.set&boundPct != 0 && base >= 0 { - p := b.pct / 100 * base + p := b.pct/100*base + b.offset switch { case !ok: v, ok = p, true @@ -117,7 +119,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 @@ -332,19 +334,25 @@ 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 { - 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 + disabledOpacity *float32 + + fitting *fittingBuilder + + epoch uint64 + key any + valueInput func(*node) + c *context + id uint64 + flags uint32 + pointerEvents int8 + committed bool + 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 @@ -552,14 +560,17 @@ type node struct { // keepScrollbars are the element's Scrollbars and KeepScrollbars, and // barsFrom the element whose they are instead, as a table's for the // list of its rows. - popover *node - list *listFrame - listRow bool - hideScrollbars bool - keepScrollbars bool - barsFrom *node - rowIndex int - rowsOf *listFrame + popover *node + popoverHandle *Handle + collisionPadding *float32 + matchAnchorWidth bool + list *listFrame + listRow bool + hideScrollbars bool + keepScrollbars bool + barsFrom *node + rowIndex int + rowsOf *listFrame // snap is how the element, a scroll container, snaps (ScrollSnap), and // snapAlign how the element lines up with the container around it // that does (SnapAlign): Start, Center or End plus one, horizontally diff --git a/ui/frame.go b/ui/frame.go index c9ce14ee4..309cf6a13 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 @@ -37,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 { diff --git a/ui/handler.go b/ui/handler.go index f39469ab8..e4944000d 100644 --- a/ui/handler.go +++ b/ui/handler.go @@ -67,6 +67,14 @@ 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 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). @@ -85,8 +93,9 @@ type InputEvent struct { Code KeyCode 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. On keys, Text is + // native glyph metadata, not insertion; InputText commits text. Caret + // is an InputCompose's rune offset. Text string Caret int // X and Y locate the pointer relative to the element's box. @@ -147,6 +156,28 @@ 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.Button == 0 && ev.Canceled && s.capturedPointer { + s.capturedPointer = false + ev.Kind = InputPointerCancel + } else if ev.Kind == InputPointerUp && ev.Button == 0 || ev.Kind == InputPointerCancel { + s.capturedPointer = false + } + handled := fn(ev) + if ev.Kind == InputPointerDown && ev.Button == 0 && handled { + s.capturedPointer = true + } + return handled + } + return e +} + // ObserveInput has fn see the input that comes to the element or to an // element inside it, without taking it, as an ancestor listening to the // keydown events that bubble up to it from the focus: keys pressed and @@ -206,7 +237,13 @@ func (rt *engine) rawKey(ev platform.SurfaceEvent) bool { if ev.Kind == platform.RawKeyReleased { kind = InputKeyUp } - input := InputEvent{Kind: kind, Key: Key(ev.Key), PhysicalKey: Key(ev.PhysicalKey), Code: KeyCode(ev.Code), Mods: Modifiers(ev.Mods), Repeat: ev.Repeat} + input := InputEvent{Kind: kind, Key: Key(ev.Key), PhysicalKey: Key(ev.PhysicalKey), Code: KeyCode(ev.Code), Mods: Modifiers(ev.Mods), Repeat: ev.Repeat, Text: ev.Text} + 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/headless.go b/ui/headless.go index ff705beb2..71bd11e6c 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" @@ -610,6 +612,9 @@ func (t *Tester) targetCenter(s string, button int) (float32, float32, error) { return 0, 0, fmt.Errorf("ui: %q is covered by a web view", s) } hit := t.rt.hitChain(cx, cy) + if !t.rt.states[n.id].pointerEvents && !t.rt.states[n.id].ignoreInput { + return x, y, nil + } if button == 0 { for _, id := range hit { st := t.rt.states[id] @@ -798,9 +803,13 @@ func (t *Tester) CloseMenu() { if t.rt.menu.drawn != nil { t.rt.menu.drawn = nil t.settle() - return } + chosen := t.h.chosen t.h.menu, t.h.chosen = nil, nil + if chosen != nil { + chosen(0) + t.settle() + } } // currentMenu returns the context menu shown, the one drawn in the window @@ -1087,3 +1096,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/input.go b/ui/input.go index 72c1e0c7c..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) + 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: @@ -122,9 +122,14 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { } delete(rt.byPosition, physical) rt.releaseKey(key, physical) - rt.observeFocus(InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Code: KeyCode(ev.Code), Mods: Modifiers(ev.Mods)}) + input := InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Code: KeyCode(ev.Code), Mods: Modifiers(ev.Mods), Text: ev.Text} + if rt.captureInput(input, rt.focused) { + taken = true + break + } + rt.observeFocus(input) if h := rt.focusHandler(); h != nil { - rt.deliver(h, InputEvent{Kind: InputKeyUp, Key: key, PhysicalKey: physical, Code: KeyCode(ev.Code), Mods: Modifiers(ev.Mods)}) + taken = rt.deliver(h, input) } case platform.TextInput: rt.editEvent(rt.replaced(editEvent{kind: editInsert, text: ev.Text}, ev)) @@ -133,11 +138,24 @@ func (rt *engine) event(ev platform.SurfaceEvent) (taken bool) { case platform.SurfaceCommand: rt.editEvent(editEvent{kind: editCommand, text: ev.Text, repeat: ev.Repeat}) 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++ + } + rt.cancelCapturePointer() + if rt.pressed != nil && rt.pressed.capturedPointer { + rt.pointerCancel(rt.pressButton) + } 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) @@ -296,6 +314,10 @@ func (rt *engine) unhoverGone() bool { } 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(s.barWidth) @@ -410,6 +432,19 @@ func (s *state) pointer(rt *engine) (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 { + 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(rt.pressButton) + } + if rt.capturePointer(InputEvent{Kind: InputPointerDown, Button: button, Clicks: max(1, clicks), Mods: mods}, x, y) { + return + } rt.downing++ defer rt.endDown() rt.observePointer(InputEvent{Kind: InputPointerDown, Button: button, Mods: mods, Clicks: max(clicks, 1)}) @@ -470,6 +505,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 { @@ -558,6 +596,7 @@ func (rt *engine) pointerCancel(button int) { rt.ups = append(rt.ups, pendingUp{button: button, canceled: true}) return } + rt.cancelCapturePointer() switch { case rt.scrollDrag.st != nil: button = 0 @@ -595,14 +634,27 @@ func (rt *engine) countPress(chain []uint64, target, h *state, n int) int { // committing what it would. func (rt *engine) pointerUp(button, clicks int, canceled bool) { if rt.downing > 0 { - rt.ups = append(rt.ups, pendingUp{button: button, clicks: clicks}) + rt.ups = append(rt.ups, pendingUp{button: button, clicks: clicks, canceled: canceled}) return } + if !canceled && rt.capturePointer(InputEvent{Kind: InputPointerUp, Button: button, Clicks: max(1, clicks), Mods: rt.mods}, rt.pointerX, rt.pointerY) { + if rt.pressed == nil || button != rt.pressButton { + return + } + canceled = true + } rt.observePointer(InputEvent{Kind: InputPointerUp, Button: button, Mods: rt.mods, Clicks: max(clicks, 1), Canceled: canceled}) - if button == rt.pressButton { + if rt.pressed != nil && button == rt.pressButton { clicks = rt.pressClicks // the engine's, which may have started over } - if s := rt.scrollDrag.st; s != nil { + if !canceled && button == 0 && button == rt.pressButton { + if s := rt.pressed; s != nil && 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) { + canceled = true + } + } + if s := rt.scrollDrag.st; s != nil && button == 0 { rt.scrollDrag.st = nil if s.snap != nil { // Let go, it rests at the nearest place. @@ -614,12 +666,26 @@ func (rt *engine) pointerUp(button, clicks int, canceled bool) { rt.requestFrame() return } - if s := rt.pressed; s != nil && button == rt.pressButton { - if d := rt.drag; canceled && d != nil && !d.native { - // Given up, as with Escape: dropped nowhere, and not missed. A - // native drag's session has the pointer, and ends it. + if button == 1 && canceled && rt.menu.release != 0 { + rt.menu.release, rt.menu.releaseText = 0, 0 + } + if canceled && rt.pressed != nil && button == rt.pressButton { + s := rt.pressed + if d := rt.drag; d != nil && !d.native { d.canceled, d.over = true, 0 + rt.dragEnd() + } + rt.selection.dragging = false + if s.editor != nil { + s.editor.release() } + s.pressPending = false + x, y := s.local(rt.pointerX, rt.pointerY) + rt.endPress(s.input, x, y) + rt.setHover(rt.hitChain(rt.pointerX, rt.pointerY)) + return + } + if s := rt.pressed; s != nil && button == rt.pressButton { if rt.dragEnd() { // A drag ended, dropping its value: no click. rt.pressed = nil @@ -631,26 +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 button == 1 && canceled && rt.menu.release != 0 { - // The menu waiting for the release opens for none. - rt.menu.release, rt.menu.releaseText = 0, 0 + 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 || button != rt.pressButton { + if s == nil { + if rt.releaseCapture != nil { + rt.releaseCapture(button) + } + return + } + 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 @@ -723,6 +796,9 @@ func (s *state) click(mods Modifiers) { } 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] // From the innermost element out, the first handling its input to @@ -945,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) 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{} @@ -964,12 +1040,23 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCod if rt.inspectKey(mods, key) { return true } - rt.observeFocus(InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Code: code, Mods: mods, Repeat: repeat}) + input := InputEvent{Kind: InputKeyDown, Key: key, PhysicalKey: physical, Code: code, Mods: mods, Repeat: repeat, Text: text} + 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) + return true + } k := keyEvent{mods, key, repeat} 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, Code: code, Mods: mods, Repeat: repeat}) { + if h := rt.focusHandler(); h != nil && !rt.claimed(k) && rt.deliver(h, input) { rt.blinkStart = rt.now() return true } @@ -992,6 +1079,9 @@ func (rt *engine) keyDownPhysical(mods Modifiers, key, physical Key, code KeyCod s.editor.queue = append(s.editor.queue, editEvent{kind: editKey, mods: mods, key: key}) rt.blinkStart = rt.now() rt.requestFrame() + if typesText(k) { + return false // Native input methods still need to deliver TextInput. + } // Taken: the menu item of the key, as Edit › Paste's Ctrl+V on // Windows, leaves it rather than pasting a second time. return true @@ -1251,6 +1341,12 @@ func (rt *engine) shortcut(id uint64, mods Modifiers, key Key) bool { // editEvent delivers text, a composition or an edit command to the focus, // and reports whether an element took it. func (rt *engine) editEvent(ev editEvent) bool { + 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 true + } + } if rt.observers > 0 { kind := InputText if ev.kind == editCompose { diff --git a/ui/input_capture.go b/ui/input_capture.go new file mode 100644 index 000000000..dae216020 --- /dev/null +++ b/ui/input_capture.go @@ -0,0 +1,214 @@ +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. 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 + if fn == nil { + rt.inputCapture, rt.captureClicks, rt.releaseCapture = nil, nil, nil + return + } + if rt.captureClicks == nil { + rt.captureClicks = &inputCaptureState{rt: rt} + rt.captureClicks.cancel = rt.captureClicks.cancelPointer + 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 + cancel func() + 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 + // 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 + } + } + case InputPointerUp: + if ev.Button == 0 { + s.released = !prevented + if prevented { + s.id = 0 + } + } + case InputClick: + s.id = 0 + } + return prevented +} + +func (rt *engine) cancelCapturePointer() { + 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 + } + 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 + } + 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) + } +} + +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++ + previous := rt.capturing + rt.capturing = rt.captureSerial + defer func() { rt.capturing = previous }() + ev.Time = rt.frameTime() + z := rt.zoomFactor() + ev.X, ev.Y = ev.X*z, ev.Y*z + if ev.Kind == InputScroll && ev.Precise { + ev.DX, ev.DY = ev.DX*z, ev.DY*z + } + 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..05483d6f9 --- /dev/null +++ b/ui/input_capture_test.go @@ -0,0 +1,184 @@ +package ui + +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 { + 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, 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"}, + {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"}, + {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 { + 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 + }) + } + TextInput(c, &value).Label("Editor").AutoFocus().Absolute().Left(110).Top(30).Size(80, 28) + 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 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) + } + }) + } +} + +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/inspector.go b/ui/inspector.go index 1141728a0..c7af252cf 100644 --- a/ui/inspector.go +++ b/ui/inspector.go @@ -264,6 +264,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 +} + // isForced reports whether the inspector forces one of the states on the // element id. func (in *inspector) isForced(id uint64, states uint8) bool { diff --git a/ui/inspector_off.go b/ui/inspector_off.go index b14fb4723..ba4de9135 100644 --- a/ui/inspector_off.go +++ b/ui/inspector_off.go @@ -38,3 +38,4 @@ func (in *inspector) paintHighlight(*engine, *Painter, float32) {} func (rt *engine) toggleInspector() {} func (rt *engine) inspectKey(Modifiers, Key) bool { return false } func (rt *engine) buildInspector(*context, float32, float32, float32) {} +func (in *inspector) ownsInput(*engine, InputEvent, uint64) bool { return false } diff --git a/ui/inspector_test.go b/ui/inspector_test.go index 22ba6d67b..49c3ff1ea 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") }) diff --git a/ui/layout.go b/ui/layout.go index afb185240..5df9e500d 100644 --- a/ui/layout.go +++ b/ui/layout.go @@ -383,6 +383,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: @@ -515,6 +518,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 @@ -594,6 +600,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 @@ -1209,6 +1218,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 && c.popover.inHidden() { // Nowhere to attach to: it hides with its target. @@ -1284,9 +1304,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) } @@ -1296,7 +1319,11 @@ func attachTo(c, e *node, pw, ph float32) { } else { h = heightAt(c, w, ph) } - t := laidOutVisualBox(c.popover) + if c.collisionPadding != nil { + edge := c.windowEdge() + w = min(w, max(0, c.c.w-2*edge)) + h = min(h, max(0, c.c.h-2*edge)) + } if c.flags&flagScrollY != 0 { _, ay, _, sy := c.attachFractions() h = min(h, roomAlong(t.Y, t.H, beside(ay, sy), c.m(0)+c.m(2), c.c.h, c.windowEdge())) @@ -1333,6 +1360,10 @@ func attachedAt(c, e *node, t Rect, pw, ph, w, h float32) (x, y float32) { edge := c.windowEdge() x, fx := alongTarget(t.X, t.W, ax, sx, w, c.m(3)-c.m(1), c.c.w, edge) y, fy := alongTarget(t.Y, t.H, ay, sy, h, c.m(0)-c.m(2), c.c.h, edge) + if c.collisionPadding != nil { + x = max(edge, min(x, c.c.w-edge-w)) + y = max(edge, min(y, c.c.h-edge-h)) + } c.placed, c.flips = c.attach.flipped(fx, fy), 0 if fx { c.flips |= flipAcross @@ -1373,6 +1404,9 @@ const windowMargin = 4 // windowEdge returns how far AttachTo keeps c from the window's edges. func (c *node) windowEdge() float32 { + if c.collisionPadding != nil { + return *c.collisionPadding + } if c.edgeMarginSet { return c.edgeMargin } diff --git a/ui/layoutcache.go b/ui/layoutcache.go index 822585bcd..3d91a652d 100644 --- a/ui/layoutcache.go +++ b/ui/layoutcache.go @@ -178,7 +178,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 f94c7a20f..c5aa43592 100644 --- a/ui/layoutcache_test.go +++ b/ui/layoutcache_test.go @@ -450,7 +450,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 hidden placed flips originAttached originX originY originSet edgeMargin edgeMarginSet" + "ownText merged spansKey collapsed rowMinW field hidden placed flips originAttached originX originY originSet edgeMargin edgeMarginSet fitting popoverHandle collisionPadding matchAnchorWidth" // Fields the layout never reads. other := "epoch key valueInput id depth track transform translatePct world content contentSet " + "pose poseAnim poseSet poseM poseFade reposed ink noCull backface bg fill grad stripes borderC borderStyle radius " + @@ -461,7 +461,7 @@ func TestLayoutInputs(t *testing.T) { "keyShortcuts accGrid accSet " + "setPos setSize choosesItems search description invalid describedBy errorMessage followX sort level expandable " + "chooseMany serial ignoreInput accessHidden accessDisabled themed lift ringOffset ringColor ringSet clipping zIndex zOrder " + - "roleDescription controls hasPopup popupSet" + "roleDescription controls hasPopup popupSet zIndexSet disabledOpacity pointerEvents committed" 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 ceb541ac1..578a47b4e 100644 --- a/ui/list.go +++ b/ui/list.go @@ -36,6 +36,10 @@ 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 + keyIndex func(any) (int, bool) + // Handle names this list independently of its per-pass node. Handle @@ -225,12 +229,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 @@ -356,6 +362,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 { @@ -528,24 +555,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) } } @@ -564,6 +586,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 { @@ -648,6 +673,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 { @@ -666,10 +695,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) } @@ -683,7 +720,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.follows(): a = n - 1 y = view - hs.height(a) @@ -697,7 +735,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. @@ -1349,6 +1387,87 @@ 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) + } + if capture := rt.captureClicks; capture != nil && capture.held != 0 { + if st := rt.states[capture.pressID]; st != nil && st.born == capture.pressBorn { + add(capture.pressID) + } + } + 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.later) + add(rt.menu.release) + add(rt.menu.chosen) + if rt.menu.pending != nil { + add(rt.menu.pending.id) + } + if rt.menu.active != nil { + add(rt.menu.active.id) + } + if rt.menu.drawn != nil { + add(rt.menu.drawn.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 @@ -1386,6 +1505,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 } @@ -1513,11 +1633,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 } @@ -1597,6 +1717,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_extensions_test.go b/ui/lui_extensions_test.go new file mode 100644 index 000000000..57bcb5565 --- /dev/null +++ b/ui/lui_extensions_test.go @@ -0,0 +1,167 @@ +package ui + +import "testing" + +type measurementContext interface { + Measure(func(*Context), float32) Rect + Fitting(bool, []func(*Context), func(*Context, int)) Element + 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 + 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 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 + 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_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/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_list_window_test.go b/ui/lui_list_window_test.go new file mode 100644 index 000000000..d1212faa1 --- /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 TestListRequestedWindowCancelsNativeCompositionBeforeBlurEviction(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("blur saved unfinished native composition instead of preserving original text: %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/lui_menu.go b/ui/lui_menu.go new file mode 100644 index 000000000..a69d9298e --- /dev/null +++ b/ui/lui_menu.go @@ -0,0 +1,115 @@ +package ui + +import ( + "bytes" + "image" + "image/color" + "image/png" + + "github.com/egoist/mygo/internal/platform" +) + +// 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() { + z := n.c.rt.zoomFactor() + n.c.rt.askMenu(n.st, x/z, y/z, 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 +// 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 +// 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...) + 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 + } + } + } + } + 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 + z := n.c.rt.zoomFactor() + present(request.x*z, request.y*z) + } + 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..1d876217c --- /dev/null +++ b/ui/lui_style.go @@ -0,0 +1,139 @@ +package ui + +import ( + "strings" + + "github.com/egoist/mygo/internal/platform" +) + +func (e *node) layeredChildren() []*node { + if !e.zOrder || e == e.c.overlay { + return nil + } + return e.paintOrder(nil) +} + +// 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.setPct(percent) + n.minW.offset = offset + } + return e +} +func (e Element) MinHeightPercentOffset(percent, offset float32) Element { + if n := e.node(); n != nil { + n.minH.setPct(percent) + n.minH.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 7f13c0778..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 @@ -567,7 +568,9 @@ 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 label, chosenKey string // later is the menu button whose menu the view asked for, as a @@ -820,6 +823,7 @@ func (rt *engine) showMenu() { return } rt.menu.pending = nil + rt.menu.active = p z := rt.zoomFactor() rt.host.popupMenu(p.menu, p.x*z, p.y*z, func(id int) { rt.menuChosen(p, id) }) } @@ -828,6 +832,11 @@ 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) { return } diff --git a/ui/menuicon_darwin.go b/ui/menuicon_darwin.go new file mode 100644 index 000000000..d17fce597 --- /dev/null +++ b/ui/menuicon_darwin.go @@ -0,0 +1,7 @@ +//go:build darwin + +package ui + +import "github.com/egoist/mygo/internal/darwin" + +func linkMenuIcons() { darwin.LinkMenuIcons() } diff --git a/ui/menuicon_linux.go b/ui/menuicon_linux.go new file mode 100644 index 000000000..6c74665d9 --- /dev/null +++ b/ui/menuicon_linux.go @@ -0,0 +1,7 @@ +//go:build linux && (amd64 || arm64) + +package ui + +import "github.com/egoist/mygo/internal/linux" + +func linkMenuIcons() { linux.LinkMenuIcons() } diff --git a/ui/menuicon_other.go b/ui/menuicon_other.go new file mode 100644 index 000000000..2080e61a4 --- /dev/null +++ b/ui/menuicon_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !(linux && (amd64 || arm64)) && !(windows && (amd64 || arm64)) + +package ui + +func linkMenuIcons() {} diff --git a/ui/menuicon_windows.go b/ui/menuicon_windows.go new file mode 100644 index 000000000..b4f81a689 --- /dev/null +++ b/ui/menuicon_windows.go @@ -0,0 +1,7 @@ +//go:build windows && (amd64 || arm64) + +package ui + +import "github.com/egoist/mygo/internal/windows" + +func linkMenuIcons() { windows.LinkMenuIcons() } diff --git a/ui/paint.go b/ui/paint.go index a22e798f6..88abd7e43 100644 --- a/ui/paint.go +++ b/ui/paint.go @@ -309,7 +309,11 @@ func (p *Painter) element(e *node) { } saved := p.opacity if e.flags&flagDisabled != 0 && !e.undimmed { - p.opacity *= 0.5 + if e.disabledOpacity != nil { + p.opacity *= *e.disabledOpacity + } else { + p.opacity *= 0.5 + } } // In its layer (paintEffect, paintGroup), the layer takes its fade. if !e.effecting || e.effect != nil { 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/ref.go b/ui/ref.go index 154f9dfed..dd8aebcb3 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 } @@ -121,10 +123,16 @@ 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") } - 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 } @@ -179,10 +187,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 } @@ -200,9 +211,24 @@ func (rt *engine) applyFocusRequests() { rt.consumed = true } } + clear(rt.refs[len(refs):]) + rt.refs = refs 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. @@ -216,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}) } 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 3f1bc81b6..90d9b0860 100644 --- a/ui/richtext/view.go +++ b/ui/richtext/view.go @@ -115,10 +115,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/runtime.go b/ui/runtime.go index 62ec3bd8b..8ab45d01f 100644 --- a/ui/runtime.go +++ b/ui/runtime.go @@ -103,6 +103,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 @@ -110,6 +112,7 @@ type engine struct { byPosition map[Key]bool owner weak.Pointer[engine] epoch uint64 + committedEpoch uint64 parts []any public *Context arena *elementOwner @@ -123,7 +126,9 @@ type engine struct { notices []*state typedInputs []*state focusFields map[any]*focusField - refs []*refState + refs []weak.Pointer[refState] + boundRefs []*refState + commitRefs func(*engine) actions []action textInputClosed bool view func(*context) @@ -246,11 +251,15 @@ 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 + // 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 + regs []shortcutReg + nextRegs []shortcutReg + delivered []shortcutReg // shortcutRepeat tells that the last key a shortcut took this frame // came as the auto-repeat of a key held down (ShortcutRepeated). shortcutRepeat bool @@ -307,6 +316,11 @@ type engine struct { frameAt time.Time pointerX, pointerY float32 + inputCapture func(InputEvent, InputTarget) bool + captureClicks *inputCaptureState + releaseCapture func(button int) + captureSerial uint64 + capturing uint64 pointerIn bool // pointerType and pressure are the device of the last pointer event // and how hard it pressed (InputEvent.Pressure), buttons the buttons @@ -370,6 +384,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 // activeRead tells that the last frame read whether the window is // drawn as active (windowActive), so that a change of it draws a // frame. @@ -600,7 +615,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} @@ -704,6 +719,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 @@ -770,6 +786,9 @@ func (rt *engine) runFrame() { layoutTree(root, appW, h) rt.measureFit(root) 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. @@ -816,7 +835,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 { t := rt.accessTree() @@ -1099,6 +1117,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() @@ -1128,6 +1149,15 @@ 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.pressID == id) { + rt.cancelCapturePointer() + } + if rt.pressed == s { + if s.capturedPointer { + rt.pointerCancel(rt.pressButton) + } + rt.pressed = nil + } if !rt.keptAlive(s) { if s.textAdapter != nil { s.textAdapter.release() @@ -1151,9 +1181,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 } } @@ -1219,6 +1248,12 @@ 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.cancelCapturePointer() + if rt.pressed != nil { + rt.pointerCancel(rt.pressButton) + } + rt.inputCapture = nil + rt.captureClicks, rt.releaseCapture = nil, nil if rt.inFrame { rt.closing = true return @@ -1226,12 +1261,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) @@ -1288,17 +1327,29 @@ func (rt *engine) dropFrame() { // 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 + if !rt.closing { + for _, callback := range callbacks { + callback() + } + } if rt.closing { rt.close() // which closes the Locals the frame dropped too } else if len(rt.dropped) > 0 { rt.closeDropped() } + if !rt.closed { + rt.updateTextInput() + } } // 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.liftHits = rt.hits[:0], rt.liftHits[:0] clear(rt.posed) rt.posed = rt.posed[:0] @@ -1320,11 +1371,21 @@ func (rt *engine) commit(root *node, w, h float32) { rt.commitElement(root, full, false) rt.liftHitsOver() rt.inkStale = false + if rt.restoreOverlayFocus != nil { + rt.restoreOverlayFocus(rt, previousModal) + } rt.arrangeFocus() rt.noteGroups() + rt.committedEpoch = rt.epoch } func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { + e.committed = true + pointerEvents := e.parent == nil || e.parent.st.pointerEvents + if e.pointerEvents != 0 { + pointerEvents = e.pointerEvents > 0 + } + e.st.pointerEvents = pointerEvents inline := e.isInline() if e.kind == kindText && e.first != nil && !inline { placeInline(e, e, 0) @@ -1396,6 +1457,9 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { s.webView = e.webView s.role, s.focusHighlights = e.role, e.focusHighlights 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(rt.pressButton) + } if s.observe = e.observeFn; s.observe != nil { rt.observers++ } @@ -1428,6 +1492,9 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { // shows, and what is Hidden, which has no box. invisible := e.flags&(flagInvisible|flagInert) != 0 || hidden || e.hidden || e.backface && e.transform.facesAway() s.invisible = invisible + if invisible && rt.pressed == s && s.capturedPointer { + rt.pointerCancel(rt.pressButton) + } if invisible { s.vw, s.vh = 0, 0 } @@ -1449,7 +1516,7 @@ func (rt *engine) commitElement(e *node, clip Rect, hidden bool) { rt.maxButton = v } switch { - case e.flags&flagPassThrough != 0 || noInput: + case e.flags&flagPassThrough != 0 || noInput || !pointerEvents: case inline: // Inline elements take the pointer over their words. for _, r := range e.frags { diff --git a/ui/scope.go b/ui/scope.go index e4f2f4104..f24791715 100644 --- a/ui/scope.go +++ b/ui/scope.go @@ -144,16 +144,109 @@ 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) { + outside := rt.outsideFocus + rt.outsideFocus = rt.outsideFocus[:0] + 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 + } + 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) { + 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) + if rt.focused == 0 { + // 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]) + // 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 @@ -220,6 +313,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 } @@ -242,6 +343,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/scroll.go b/ui/scroll.go index 7d8853e16..9bcb1bb2d 100644 --- a/ui/scroll.go +++ b/ui/scroll.go @@ -1,5 +1,21 @@ package ui +// 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 { diff --git a/ui/text_geometry.go b/ui/text_geometry.go new file mode 100644 index 000000000..57fed9dd3 --- /dev/null +++ b/ui/text_geometry.go @@ -0,0 +1,217 @@ +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 || !n.committed { + return nil, false + } + for p := n; p != nil; p = p.parent { + if p.hidden || 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 g.owner.rt.windowRect(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) + z := g.owner.rt.zoomFactor() + return Point{x * z, y * z}, 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 + } + z := g.owner.rt.zoomFactor() + x, y := inv.Point(point.X/z, point.Y/z) + 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 g.space.owner.rt.windowRect(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, g.space.owner.rt.windowRect(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 + } + z := g.space.owner.rt.zoomFactor() + x, y := inv.Point(point.X/z, point.Y/z) + i := p.tl.PositionAt(x-p.x-p.contentX(), y-p.y-p.contentY()).Index + return max(a, min(b, i)) - a, true +} + +// CommittedTextLine is a visual line's rune range and window-space box. Newline +// separators are excluded, as in the shaped layout's line table. +type CommittedTextLine struct { + Start, End int + Bounds Rect +} + +// Lines returns the visual lines intersecting this element's text range. +func (g CommittedText) Lines() ([]CommittedTextLine, bool) { + n, p, a, b, ok := g.layout() + if !ok { + return nil, false + } + var out []CommittedTextLine + 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, CommittedTextLine{lo - a, hi - a, g.space.owner.rt.windowRect(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") + } +} diff --git a/ui/text_input_client.go b/ui/text_input_client.go index 01abe06d7..bb4d80d1a 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,18 @@ func (a *textInputAdapter) UnmarkText() { a.changed() } } +func (a *textInputAdapter) EndComposition() { + if c, _ := a.client(); c != nil { + c.EndComposition() + a.changed() + } +} +func (a *textInputAdapter) CancelComposition() { + if c, _ := a.client(); c != nil { + c.CancelComposition() + 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 919ea7c76..d876d766b 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 } @@ -128,25 +153,49 @@ func TestCompositionTaken(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) } } @@ -166,8 +215,8 @@ func TestTextInputClientNativeCancel(t *testing.T) { client.SetMarkedText(nil, "にほn", TextInputRange{Start: 3, End: 3}) composition.Cancel(client) composition.End(client) - if c.mark != nil || c.text != "a" { - t.Fatal("cancelled composition was kept", c.text, c.mark) + if c.mark != nil || c.text != "abc" || c.selection.Range != (TextInputRange{Start: 1, End: 3}) { + t.Fatal("cancelled composition changed committed text or selection", c.text, c.mark, c.selection) } client.SetMarkedText(nil, "にほん", TextInputRange{Start: 3, End: 3}) composition.Replace(client, nil, "日本") diff --git a/ui/text_snapshot.go b/ui/text_snapshot.go new file mode 100644 index 000000000..e8041ce7e --- /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.inHidden() || 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") + } +} diff --git a/ui/toast.go b/ui/toast.go index 97b2a1fff..512c12f1f 100644 --- a/ui/toast.go +++ b/ui/toast.go @@ -104,6 +104,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) @@ -246,7 +247,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/webview.go b/ui/webview.go index 7b71b2416..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() } diff --git a/ui/widgets.go b/ui/widgets.go index 0dabfd01a..f1459c655 100644 --- a/ui/widgets.go +++ b/ui/widgets.go @@ -191,8 +191,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() { @@ -623,6 +626,11 @@ func (e *node) ObjectPosition(x, y float32) *node { // gives the focus back to the element that had it as it came. Built inside // the children of a Themed, they take its theme. 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