From 1efd71032bf1cdd30ba4149b1c6b96f7e76c83bc Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 20:23:22 -0800 Subject: [PATCH 1/2] Add opt-in container focus observation --- docs/api-migrations.md | 45 +++++++++++ examples/gallery/view.ml | 1 + .../src/main/kotlin/dev/lui/LuiKindRules.kt | 1 + .../src/main/kotlin/dev/lui/LuiWireSchema.kt | 6 +- .../LUIAppleBackend/LUIWireProtocol.swift | 4 + .../LUIAppleBackend/LUIWireSchema.swift | 6 +- platform/bridge/lui_ocaml_bridge.c | 9 +++ platform/mygo/SUPPORT.md | 8 +- platform/mygo/backend.go | 16 ++++ platform/mygo/capabilities.json | 14 +++- platform/mygo/controls.go | 4 +- platform/mygo/events.go | 7 ++ platform/mygo/events_test.go | 59 +++++++++++++++ platform/mygo/layout.go | 2 +- platform/mygo/menus.go | 2 +- platform/mygo/render.go | 34 ++++++++- platform/mygo/runtime.go | 10 +++ platform/mygo/runtime_events.go | 2 +- platform/mygo/schema_generated.go | 16 ++-- platform/mygo/style.go | 13 +++- platform/web/melange/core/lui_web_types.ml | 1 + platform/web/melange/events/lui_web_events.ml | 74 +++++++++++++++++++ platform/web/melange/render/lui_web_props.ml | 2 + platform/web/melange/shell/lui_web.ml | 1 + platform/web/melange/shell/lui_web_apply.ml | 16 +++- platform/web/test/link-actions.test.mjs | 1 + schema/components.json | 21 +++++- schema/mygo-manifest.json | 30 ++++---- src/lui_elements.ml | 15 +++- src/lui_elements.mli | 3 + src/lui_json_view.ml | 1 + src/lui_native_bridge.ml | 2 + src/lui_protocol.ml | 7 ++ src/lui_protocol.mli | 2 + src/lui_runtime.ml | 5 ++ src/lui_wire_schema.ml | 20 ++--- tooling/export_mygo_contract.ml | 1 + tooling/typed_view_schema.mjs | 6 ++ 38 files changed, 411 insertions(+), 56 deletions(-) diff --git a/docs/api-migrations.md b/docs/api-migrations.md index e2b2da4b..e1459e54 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -1,5 +1,50 @@ # LUI API migration guide +## Unreleased: opt-in container focus-within observation (Web and MyGo) + +`row`, `column` and `box` accept `?on_focus_within:(bool -> unit)` on +Web/Melange and MyGo. Installing the callback enables the new boolean +`focus-within-enabled` property and receives `FocusWithinChanged (node, within)`. +Without the callback/property, observation is off and existing views are unchanged. +The callback does not make the container focusable or change keyboard traversal. + +After mount/enable settles, the observer publishes one current snapshot, then +only distinct transitions. True means focus is on the container or inside its +rendered subtree. Moving between descendants does not emit an exit/reentry pair. +Web coalesces local `focusin`/`focusout` checks at the microtask boundary, also +checking after retained child removal. MyGo shares its existing `FocusWithin` +read with appearance styling and delivers through the committed event queue. +Neither backend treats a popup/portal outside the subtree as inside merely +because it is attached to a trigger. Window activation is not a second condition +on retained focus membership; this is not an application-activation callback. + +Disabled/inert ancestors normalize membership to false, including a terminal +false for a previously focused subtree. Re-enabling observes current membership +again. The last value is recorded before dispatch, so reducer patches do not +cause repeated feedback. Same-node reconciliation retains the observation; +opt-out or node retirement discards it. A new incarnation receives a fresh +snapshot. Unmount cancels delayed checks without dispatching to a dead node. + +SwiftUI, Kotlin and Generic host profiles explicitly reject this property, +including explicit false; Apple and Android raw-wire hosts reject it too. +Cross-platform applications must omit the callback on those paths, not expect +silent no-event support. No Apple/Android focus framework or focusable group is +introduced. Their existing focus behavior and appearance properties are unchanged. +Rebuild the shared OCaml application and Web/MyGo host together for the added +event constructor and native `lui_ocaml_focus_within_changed` bool callback. +Older native libraries remain usable without this opt-in; attempting to deliver +the new event to a library without its callback fails explicitly. + +For example, replace per-control focus handlers with one row-owned signal: + +```ocaml +row ~on_focus_within:(fun within -> Signal.set toolbar_focused within) + [comment_body; reactions; toolbar] +``` + +Combine that signal with the existing hover state to drive typed toolbar opacity. +No new focus-within opacity property or data migration is needed. + ## Unreleased: MyGo links in text flow Text-content `link` nodes inside a native text flow now use MyGo's inline Link diff --git a/examples/gallery/view.ml b/examples/gallery/view.ml index 2bc94975..2e058317 100644 --- a/examples/gallery/view.ml +++ b/examples/gallery/view.ml @@ -1902,6 +1902,7 @@ let event_payload = function | VirtualWindow (_, generation, first, last, _) -> Printf.sprintf "virtual-window:%d:%d:%d" generation first last | ExtensionEvent (_, identifier, name, _) -> "extension:" ^ identifier ^ ":" ^ name + | FocusWithinChanged (_, within) -> Printf.sprintf "focus-within:%b" within | PointerMove _ -> "pointer-move" | PointerCancel _ -> "pointer-cancel" | ToggleChangedDetail (_, checked, modifiers) -> diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt index 4b1d84f6..423a6963 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiKindRules.kt @@ -315,6 +315,7 @@ internal object LuiKindRules { /** Ported from the Flutter backend's `_supports`: whether a `set-prop` * op is admissible for the node's kind. */ fun supports(kind: LuiNodeKind, property: String, value: LuiWireValue): Boolean { + if (property == "focus-within-enabled") return false when (property) { "virtual-total-count", "virtual-generation", "virtual-indices", "virtual-scroll-index" -> return kind == LuiNodeKind.virtualList && schemaValueSupported(property, value) diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt index d0cb2750..90b0fcee 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiWireSchema.kt @@ -295,6 +295,7 @@ enum class LuiProperty(val wireName: String) { pointerCapture("pointer-capture"), indeterminate("indeterminate"), toggleDetailEnabled("toggle-detail-enabled"), + focusWithinEnabled("focus-within-enabled"), ; companion object { @@ -337,12 +338,13 @@ object LuiSchemaMatrix { LuiNodeKind.checkbox to setOf(LuiProperty.gap, LuiProperty.labelOpacity, LuiProperty.indicatorBorderColor, LuiProperty.checkedIndicatorBorderColor), LuiNodeKind.radio to setOf(LuiProperty.gap, LuiProperty.labelOpacity), LuiNodeKind.grid to setOf(LuiProperty.main, LuiProperty.cross), - LuiNodeKind.row to setOf(LuiProperty.orientation), + LuiNodeKind.row to setOf(LuiProperty.orientation, LuiProperty.focusWithinEnabled), + LuiNodeKind.column to setOf(LuiProperty.focusWithinEnabled), LuiNodeKind.listItem to setOf(LuiProperty.display), LuiNodeKind.dialog to setOf(LuiProperty.description, LuiProperty.background, LuiProperty.foreground, LuiProperty.borderColor, LuiProperty.borderWidth, LuiProperty.cornerRadius, LuiProperty.gap, LuiProperty.transition), LuiNodeKind.drawer to setOf(LuiProperty.transition), LuiNodeKind.sheet to setOf(LuiProperty.transition), - LuiNodeKind.box to setOf(LuiProperty.inert), + LuiNodeKind.box to setOf(LuiProperty.inert, LuiProperty.focusWithinEnabled), LuiNodeKind.popover to setOf(LuiProperty.collisionPadding), LuiNodeKind.dropdownMenu to setOf(LuiProperty.collisionPadding), LuiNodeKind.tooltip to setOf(LuiProperty.collisionPadding), diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 5c0cbf3a..a28fa469 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -145,6 +145,8 @@ enum LUIWireValue: Decodable, Equatable { func matches(_ property: LUIProperty, on kind: LUINodeKind) -> Bool { switch property { + case .focusWithinEnabled: + return false case .virtualTotalCount: return intValue.map { $0 >= 0 } ?? false case .virtualGeneration: @@ -1086,6 +1088,7 @@ struct LUIRetainedTree { } private static func supports(_ property: LUIProperty, on kind: LUINodeKind) -> Bool { + if property == .focusWithinEnabled { return false } switch property { case .virtualTotalCount, .virtualGeneration, .virtualIndices, .virtualScrollIndex: return kind == .virtualList @@ -1106,6 +1109,7 @@ struct LUIRetainedTree { if kind == .root { return property == .theme || property == .themeMode } if kind == .contextMenu { return false } return switch property { + case .focusWithinEnabled: false case .virtualTotalCount, .virtualGeneration, .virtualIndices, .virtualScrollIndex: kind == .virtualList case .readOnly: isTextEntry(kind) case .overscan, .itemExtent: kind == .virtualList diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 5e0d087c..be5da83d 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -288,6 +288,7 @@ enum LUIProperty: String, Decodable, Hashable { case pointerCapture = "pointer-capture" case indeterminate = "indeterminate" case toggleDetailEnabled = "toggle-detail-enabled" + case focusWithinEnabled = "focus-within-enabled" } enum LUISchemaMatrix { @@ -317,12 +318,13 @@ enum LUISchemaMatrix { .checkbox: [.gap, .labelOpacity, .indicatorBorderColor, .checkedIndicatorBorderColor], .radio: [.gap, .labelOpacity], .grid: [.main, .cross], - .row: [.orientation], + .row: [.orientation, .focusWithinEnabled], + .column: [.focusWithinEnabled], .listItem: [.display], .dialog: [.description, .background, .foreground, .borderColor, .borderWidth, .cornerRadius, .gap, .transition], .drawer: [.transition], .sheet: [.transition], - .box: [.inert], + .box: [.inert, .focusWithinEnabled], .popover: [.collisionPadding], .dropdownMenu: [.collisionPadding], .tooltip: [.collisionPadding], diff --git a/platform/bridge/lui_ocaml_bridge.c b/platform/bridge/lui_ocaml_bridge.c index 012cfac2..58e29621 100644 --- a/platform/bridge/lui_ocaml_bridge.c +++ b/platform/bridge/lui_ocaml_bridge.c @@ -273,6 +273,15 @@ LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) { return lui_ocaml_picked_utf8(node, payload, (int32_t)strlen(payload)); } +LUI_EXPORT int32_t lui_ocaml_focus_within_changed(int64_t node, int32_t within) { + int32_t accepted; + caml_leave_blocking_section(); + accepted = lui_dispatch_node_bool("lui_ocaml_focus_within_changed", node, within, + deliver); + caml_enter_blocking_section(); + return accepted; +} + LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { int32_t accepted; caml_leave_blocking_section(); diff --git a/platform/mygo/SUPPORT.md b/platform/mygo/SUPPORT.md index 044aefdd..f6847482 100644 --- a/platform/mygo/SUPPORT.md +++ b/platform/mygo/SUPPORT.md @@ -21,8 +21,8 @@ Contract counts and hashes come from the canonical OCaml export in [the manifest | Kind | Built-in route | Properties | Allowed child kinds | Events | Behavior evidence | | --- | --- | --- | --- | --- | --- | | `root` | render.go; theme.go | 3 | 86 | 0 | [TestRenderAndEvents](backend_test.go), [TestThemeScopeAndMode](extensions_test.go) | -| `row` | render.go; style.go | 95 | 81 | 11 | [TestLayoutGeometry](extensions_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | -| `column` | render.go; style.go | 92 | 81 | 8 | [TestLayoutGeometry](extensions_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | +| `row` | render.go; style.go | 96 | 81 | 12 | [TestLayoutGeometry](extensions_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | +| `column` | render.go; style.go | 93 | 81 | 9 | [TestLayoutGeometry](extensions_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | | `grid` | render.go; style.go | 82 | 81 | 3 | [TestLayoutGeometry](extensions_test.go), [TestLayoutGridSpansAndPropertyRemovalChangeGeometry](layout_test.go) | | `stack` | layout.go; style.go | 79 | 81 | 3 | [TestLayoutStackUsesLargestIntrinsicChildAndRetainsOrder](layout_test.go), [TestLayoutStyleZOrderChangesPaintAndPressWithoutChangingLayout](style_test.go) | | `edge-inset` | layout.go; style.go | 81 | 81 | 3 | [TestLayoutEdgeInsetIncludesEveryPinnedChildAndVisibility](layout_test.go), [TestLayoutEdgeInsetSurfaceBelongsOnlyToPinnedRegion](layout_test.go) | @@ -32,7 +32,7 @@ Contract counts and hashes come from the canonical OCaml export in [the manifest | `card` | render.go; style.go | 89 | 81 | 3 | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go) | | `alert` | render.go; style.go | 83 | 81 | 3 | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go) | | `bubble` | render.go; style.go | 84 | 81 | 3 | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go) | -| `box` | render.go; style.go | 94 | 81 | 11 | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go) | +| `box` | render.go; style.go | 95 | 81 | 12 | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go) | | `text` | render.go; style.go | 86 | 86 | 9 | [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go), [TestLayoutSharedTextWrappingAndRemoval](layout_test.go), [TestLayoutStyleLogicalAndUnitlessLineHeightAndDecoration](style_test.go) | | `heading` | render.go; style.go | 85 | 81 | 3 | [TestLayoutStyleAccessibleHeadingAndNavigationSemantics](style_test.go) | | `paragraph` | render.go; style.go | 85 | 81 | 3 | [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go), [TestLayoutSharedTextWrappingAndRemoval](layout_test.go), [TestLayoutStyleLogicalAndUnitlessLineHeightAndDecoration](style_test.go) | @@ -112,6 +112,7 @@ Contract counts and hashes come from the canonical OCaml export in [the manifest | Property | Implementation | Exception | Behavior evidence | | --- | --- | --- | --- | +| `focus-within-enabled` | render.go; events.go | — | [TestEventsFocusWithinTransitions](events_test.go) | | `text` | collections.go; composites.go; controls.go; layout.go; menus.go; overlays.go; resources.go | — | [TestCollectionsTableRichCellsRenderRetainedControlsAndReorderIdentity](collections_test.go), [TestCollectionsVirtualListBuildsOnlyViewportAndReportsTrueRange](collections_test.go), [TestCompositesStepperShowsSequenceAndUpdatesActiveStep](composites_test.go), [TestCompositesToolbarRovingFocusSkipsDisabledAndPreservesInputKeys](composites_test.go), [TestControlledEditorsAcknowledgeReducerWithoutLosingCaretOrComposition](controlled_test.go), [TestControlsTabsRovingFocusPreservesModelSelection](controls_test.go), [TestControlsTextInputNormalizationDisabledAndPropertyRemoval](controls_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go), [TestLayoutViewThatFitsMeasuresLocalAxisWithoutCandidateEffects](layout_test.go), [TestMenusDropdownRetainedItemsAndDismissal](menus_test.go), [TestMenusTooltipDelayUpdateEscapeAndAnchorRemoval](menus_test.go), [TestOverlaysDialogTrapsAndRestoresFocus](overlays_test.go), [TestOverlaysPopoverResolvesForwardAndBackwardAnchors](overlays_test.go), [TestResourcesDetachCancelsRequestWithoutAnotherView](resources_test.go), [TestResourcesRegisteredImageCropReplacementRemovalAndOwnership](resources_test.go) | | `enabled` | style.go; layout.go | — | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go), [TestLayoutStyleInertPreservesPixelsAndBlocksSubtreeInteraction](style_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | | `gap` | style.go; layout.go | — | [TestLayoutStyleViewportFractionsAndContainerFrameRemoval](style_test.go), [TestLayoutStyleShadowOpacityStatesAndRemoval](style_test.go), [TestLayoutStyleInertPreservesPixelsAndBlocksSubtreeInteraction](style_test.go), [TestLayoutSharedTypographyAndSpacingRestoreInheritedValues](layout_test.go) | @@ -315,6 +316,7 @@ All event families round-trip through the real OCaml Components application. Pay | Event | Route | Behavior evidence | | --- | --- | --- | +| `focus-within-changed` | render.go; events.go; runtime_events.go; runtime.go | [TestEventsFocusWithinTransitions](events_test.go) | | `appear` | events.go; runtime_events.go; runtime.go | [TestOCamlComponentsEventRoundTrip](runtime_components_test.go) | | `change` | events.go; runtime_events.go; runtime.go | [TestOCamlComponentsEventRoundTrip](runtime_components_test.go) | | `context-menu-press` | events.go; runtime_events.go; runtime.go | [TestOCamlComponentsEventRoundTrip](runtime_components_test.go) | diff --git a/platform/mygo/backend.go b/platform/mygo/backend.go index b6a7d84b..7b013ffb 100644 --- a/platform/mygo/backend.go +++ b/platform/mygo/backend.go @@ -5,6 +5,7 @@ package luimygo import ( "bytes" + "encoding/json" "fmt" "io" "maps" @@ -61,6 +62,7 @@ type Event struct { Node int64 `json:"node"` Text string `json:"text,omitempty"` Checked bool `json:"checked,omitempty"` + Within bool `json:"within,omitempty"` Value float64 `json:"value,omitempty"` Modifiers int32 `json:"modifiers,omitempty"` Identifier string `json:"identifier,omitempty"` @@ -84,6 +86,18 @@ type Event struct { incarnation uint64 } +func (e Event) MarshalJSON() ([]byte, error) { + type wireEvent Event + var within *bool + if e.Kind == "focus-within-changed" { + within = &e.Within + } + return json.Marshal(struct { + wireEvent + Within *bool `json:"within,omitempty"` + }{wireEvent(e), within}) +} + // Renderer builds a custom control, calling children within its container. // emit queues an event for delivery after construction; never retain Context. type Renderer func(*ui.Context, Node, func(), func(Event)) @@ -121,6 +135,8 @@ type controlState struct { checked bool value float64 appeared, focused bool + focusWithinKnown bool + focusWithinLast bool identity uint64 handle ui.Handle resource nodeResourceState diff --git a/platform/mygo/capabilities.json b/platform/mygo/capabilities.json index 56cd9fb1..0a14323a 100644 --- a/platform/mygo/capabilities.json +++ b/platform/mygo/capabilities.json @@ -30,7 +30,7 @@ "extensions_test.go:TestLayoutGeometry", "layout_test.go:TestLayoutSharedTypographyAndSpacingRestoreInheritedValues" ], - "contractHash": "8607888dd0b74341aaf93b9a2007d4400d8654129b0a6bca393b82eb27b53b13" + "contractHash": "5b37ca2312b59c73d5ca14e59389ed49a11b1dc3710bdee1409ce9675f9d8c50" }, "column": { "route": "render.go; style.go", @@ -38,7 +38,7 @@ "extensions_test.go:TestLayoutGeometry", "layout_test.go:TestLayoutSharedTypographyAndSpacingRestoreInheritedValues" ], - "contractHash": "d1625463275eac1a39011cec61b6bf07997f116c26977792579c7fdb6f2a1507" + "contractHash": "6f35e51043ab8ae815796b2dd3a147f0a5e01b0fe15b9bab93f588710264f720" }, "grid": { "route": "render.go; style.go", @@ -117,7 +117,7 @@ "style_test.go:TestLayoutStyleViewportFractionsAndContainerFrameRemoval", "style_test.go:TestLayoutStyleShadowOpacityStatesAndRemoval" ], - "contractHash": "53c3f165be3a92e51235e68b433156fc4b1594d4ee6845cc2ab1d4dc1a270b64" + "contractHash": "028237d9cea015c951093e45aa35dc7c3493f8a22f27106e43dfc506da741866" }, "text": { "route": "render.go; style.go", @@ -692,6 +692,10 @@ } }, "properties": { + "focus-within-enabled": { + "route": "render.go; events.go", + "evidence": ["events_test.go:TestEventsFocusWithinTransitions"] + }, "text": { "route": "collections.go; composites.go; controls.go; layout.go; menus.go; overlays.go; resources.go", "evidence": [ @@ -2337,6 +2341,10 @@ } }, "events": { + "focus-within-changed": { + "route": "render.go; events.go; runtime_events.go; runtime.go", + "evidence": ["events_test.go:TestEventsFocusWithinTransitions"] + }, "appear": { "route": "events.go; runtime_events.go; runtime.go", "evidence": [ diff --git a/platform/mygo/controls.go b/platform/mygo/controls.go index 8b6141f0..4fcf63f6 100644 --- a/platform/mygo/controls.go +++ b/platform/mygo/controls.go @@ -9,7 +9,7 @@ import ( "github.com/egoist/mygo/ui" ) -func (b *Backend) switchControl(c *ui.Context, n Node, checked *bool) ui.Element { +func (b *Backend) switchControl(c *ui.Context, n Node, checked *bool, focusWithin func(ui.Element) bool) ui.Element { authored := hasSpacing(n, "padding") for _, name := range []string{"width", "height", "min-width", "max-width", "min-height", "max-height", "width-viewport", "min-width-viewport", "max-width-viewport", "height-viewport", "min-height-viewport", "max-height-viewport", @@ -37,7 +37,7 @@ func (b *Backend) switchControl(c *ui.Context, n Node, checked *bool) ui.Element active bool prefix string }{ - {n.Bool("selected"), "selected"}, {e.FocusWithin(), "focus-within"}, + {n.Bool("selected"), "selected"}, {n.String("focus-within-foreground") != "" && focusWithin(e), "focus-within"}, {e.Hovered(), "hover"}, {hasPressedStyle(n) && e.Pressed(), "pressed"}, } { if value := n.String(state.prefix + "-foreground"); state.active && value != "" { diff --git a/platform/mygo/events.go b/platform/mygo/events.go index 18312d46..a0c1c0ec 100644 --- a/platform/mygo/events.go +++ b/platform/mygo/events.go @@ -36,6 +36,10 @@ func (b *Backend) interactionBlocked(n Node, e Event) bool { // is inert or disabled. Interactive events use the current retained tree, // including ancestors updated by earlier events in the same frame. switch e.Kind { + case "focus-within-changed": + if !e.Within { + return false + } case "appear", "load", "scroll-completed", "visible-range", "scroll-edges", "virtual-window": return false } @@ -258,6 +262,9 @@ func eventAllowed(n Node, e Event) bool { if n.Kind == "extension" { return e.Kind == "extension-event" && e.Identifier == n.Identifier } + if e.Kind == "focus-within-changed" { + return n.Bool("focus-within-enabled") && eventSupported(n.Kind, e.Kind) + } if e.Kind == "appear" { return n.Bool("appear-enabled") } diff --git a/platform/mygo/events_test.go b/platform/mygo/events_test.go index 6f9d8527..fb007b2a 100644 --- a/platform/mygo/events_test.go +++ b/platform/mygo/events_test.go @@ -183,6 +183,65 @@ func TestEventsCapturedPointerCancel(t *testing.T) { } } +func TestEventsFocusWithinTransitions(t *testing.T) { + var received []bool + var b *Backend + generation := int64(1) + patch := func(ops ...Operation) { + generation++ + apply(t, b, generation, ops...) + } + b = mounted(t, func(event Event) error { + if event.Kind == "focus-within-changed" { + received = append(received, event.Within) + if event.Within { + patch(prop(6, "background", "#112233")) + } + } + return nil + }) + patch(create(6, "box"), insert(2, 6, 0), + create(3, "text-field"), prop(3, "placeholder", "First"), insert(6, 3, 0), + create(4, "text-field"), prop(4, "placeholder", "Second"), insert(6, 4, 1), + create(5, "text-field"), prop(5, "placeholder", "Outside"), insert(2, 5, 1)) + if err := b.RequestFocus(5); err != nil { + t.Fatal(err) + } + tester := ui.NewTester(b.View, 360, 240) + settle := func() { tester.Frame(); tester.Frame() } + settle() + if len(received) != 0 { + t.Fatal("unsubscribed container emitted focus", received) + } + patch(prop(6, "focus-within-enabled", true)) + settle() + focus := func(node int64) { + if err := b.RequestFocus(node); err != nil { + t.Fatal(err) + } + settle() + } + focus(3) + focus(4) + patch(prop(6, "inert", true)) + settle() + patch(prop(6, "inert", false)) + focus(3) + patch(Operation{Op: "detach-subtree", ID: 3}) + settle() + patch(Operation{Op: "detach-subtree", ID: 6}) + settle() + want := []bool{false, true, false, true, false} + if len(received) != len(want) { + t.Fatalf("initial, distinct and terminal focus snapshots: got %v want %v", received, want) + } + for i := range want { + if received[i] != want[i] { + t.Fatalf("focus transitions: got %v want %v", received, want) + } + } +} + func TestEventsNavigationSubscriptionAndDisabled(t *testing.T) { var events []Event b := mounted(t, func(e Event) error { events = append(events, e); return nil }) diff --git a/platform/mygo/layout.go b/platform/mygo/layout.go index 3eff6cbb..262e8210 100644 --- a/platform/mygo/layout.go +++ b/platform/mygo/layout.go @@ -109,7 +109,7 @@ func (b *Backend) renderLayout(c *ui.Context, n Node, s *controlState, appearanc delete(surface.Props, name) } } - b.style(c, surface, region) + b.style(c, surface, region, ui.Element.FocusWithin) } content := func() { if len(n.Children) > 0 { diff --git a/platform/mygo/menus.go b/platform/mygo/menus.go index 5cdc459a..16874dd0 100644 --- a/platform/mygo/menus.go +++ b/platform/mygo/menus.go @@ -204,7 +204,7 @@ func (b *Backend) renderMenus(c *ui.Context, n Node, children func(), emit func( } parts := ui.ToastBase(c, toast) parts.Root.Padding(12).Radius(c.Theme().Radius).Background(c.Theme().Surface).Border(1, c.Theme().Border).MinWidth(220) - b.style(c, current, parts.Root) + b.style(c, current, parts.Root, ui.Element.FocusWithin) parts.Root.Children(func() { for _, child := range current.Children { b.renderWidgetChild(c, child) diff --git a/platform/mygo/render.go b/platform/mygo/render.go index 7a624056..ca35a507 100644 --- a/platform/mygo/render.go +++ b/platform/mygo/render.go @@ -53,6 +53,19 @@ func (b *Backend) emit(e Event) { return } defer b.reconcileControl(e) + if e.Kind == "focus-within-changed" { + state := b.states[e.Node] + if state == nil || !eventAllowed(n, e) { + return + } + if b.interactionBlocked(n, e) { + e.Within = false + } + if state.focusWithinKnown && state.focusWithinLast == e.Within { + return + } + state.focusWithinKnown, state.focusWithinLast = true, e.Within + } if b.interactionBlocked(n, e) { return } @@ -122,6 +135,13 @@ func (b *Backend) render(c *ui.Context, id int64, appearances *[]Event) { restoreTheme := b.scopeTheme(c, n) defer restoreTheme() s := b.state(n) + focusRead, within := false, false + focusWithin := func(element ui.Element) bool { + if !focusRead { + within, focusRead = element.FocusWithin(), true + } + return within + } children := func() { for _, child := range n.Children { b.render(c, child, appearances) @@ -214,7 +234,7 @@ func (b *Backend) render(c *ui.Context, id int64, appearances *[]Event) { } }) case "switch": - e = b.switchControl(c, n, &s.checked) + e = b.switchControl(c, n, &s.checked, focusWithin) e.OnChange(func() { defer b.reconcileControl(Event{Node: id, Kind: "toggle-changed", incarnation: s.identity}) if n.Bool("toggle-enabled") { @@ -320,11 +340,21 @@ func (b *Backend) render(c *ui.Context, id int64, appearances *[]Event) { if n.String("theme") != "" || n.String("theme-mode") != "" { e.TextColor(c.Theme().Text) } - b.style(c, n, e) + b.style(c, n, e, focusWithin) if !b.probing && n.Bool("autofocus") && !s.focused { e.Focus() s.focused = true } + if !b.probing { + if n.Bool("focus-within-enabled") { + within := !b.interactionBlocked(n, Event{Kind: "focus-within-changed", Within: true}) && focusWithin(e) + if !s.focusWithinKnown || s.focusWithinLast != within { + emit(Event{Kind: "focus-within-changed", Within: within}) + } + } else { + s.focusWithinKnown = false + } + } b.bindEvents(c, n, s, e, emit) if text := n.String("tooltip"); text != "" { if keys := n.String("tooltip-keys"); keys != "" { diff --git a/platform/mygo/runtime.go b/platform/mygo/runtime.go index cdbd0859..ef10d719 100644 --- a/platform/mygo/runtime.go +++ b/platform/mygo/runtime.go @@ -23,6 +23,7 @@ type nativeFunctions struct { start func(uintptr, int32, int32) int32 stop, resync func() int32 text func(int64, string, int32) int32 + focusWithin func(int64, int32) int32 toggle func(int64, int32) int32 toggleDetail func(int64, int32, int32) int32 value func(int64, float64) int32 @@ -182,6 +183,9 @@ func (w *runtimeWorker) load(path string) error { if ptr, lookupErr := librarySymbol(handle, "lui_ocaml_virtual_window"); lookupErr == nil { purego.RegisterFunc(&f.virtualWindow, ptr) } + if ptr, lookupErr := librarySymbol(handle, "lui_ocaml_focus_within_changed"); lookupErr == nil { + purego.RegisterFunc(&f.focusWithin, ptr) + } if goruntime.GOOS == "windows" { // Windows requires a pointer-sized callback result. The C void // callback ignores it; CDecl preserves the bridge's calling convention. @@ -275,6 +279,12 @@ func (r *Runtime) Dispatch(e Event) error { return f.pressEx(e.Node, e.Modifiers) case "text-changed": return f.text(e.Node, e.Text, int32(len(e.Text))) + case "focus-within-changed": + if f.focusWithin == nil { + unknown = true + return 0 + } + return f.focusWithin(e.Node, nativeBool(e.Within)) case "toggle-changed": checked := int32(0) if e.Checked { diff --git a/platform/mygo/runtime_events.go b/platform/mygo/runtime_events.go index 5c42f878..265c6ca8 100644 --- a/platform/mygo/runtime_events.go +++ b/platform/mygo/runtime_events.go @@ -21,7 +21,7 @@ func validateEvent(e Event) error { } } switch e.Kind { - case "press", "long-press", "double-press", "submit", "dismiss", "appear", "change", "pointer-enter", "pointer-leave", "load", "navigate-previous", "navigate-next", "toggle-changed", "text-changed", "picked", "scroll-edges": + case "press", "long-press", "double-press", "submit", "dismiss", "appear", "change", "pointer-enter", "pointer-leave", "load", "navigate-previous", "navigate-next", "toggle-changed", "text-changed", "picked", "scroll-edges", "focus-within-changed": case "press-modifiers": if e.Modifiers < 0 || e.Modifiers&^15 != 0 { return invalid("unknown modifier bits") diff --git a/platform/mygo/schema_generated.go b/platform/mygo/schema_generated.go index ecd8e1d8..5c38f424 100644 --- a/platform/mygo/schema_generated.go +++ b/platform/mygo/schema_generated.go @@ -290,12 +290,13 @@ var wireProperties = map[string]uint8{ "pointer-capture": scalarBool, "indeterminate": scalarBool, "toggle-detail-enabled": scalarBool, + "focus-within-enabled": scalarBool, } var contractKindProperties = map[string]map[string]bool{ "root": {"accessibility-identifier": true, "theme": true, "theme-mode": true}, - "row": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "orientation": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "navigate-enabled": true, "grid-column-span": true, "pointer-capture": true}, - "column": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, + "row": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "orientation": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "navigate-enabled": true, "grid-column-span": true, "pointer-capture": true, "focus-within-enabled": true}, + "column": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true, "focus-within-enabled": true}, "grid": {"gap": true, "main": true, "cross": true, "grow": true, "columns": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "appear-enabled": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, "stack": {"grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "appear-enabled": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, "edge-inset": {"gap": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "appear-enabled": true, "theme": true, "theme-mode": true, "edge": true, "visible": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, @@ -305,7 +306,7 @@ var contractKindProperties = map[string]map[string]bool{ "card": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, "alert": {"text": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "variant": true, "appear-enabled": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "white-space": true, "text-overflow": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, "bubble": {"text": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "variant": true, "appear-enabled": true, "text-alignment": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "white-space": true, "text-overflow": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true}, - "box": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "inert": true, "navigate-enabled": true, "grid-column-span": true, "pointer-capture": true}, + "box": {"gap": true, "main": true, "cross": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-label": true, "accessibility-identifier": true, "style-class": true, "selected": true, "change-enabled": true, "toggle-enabled": true, "press-enabled": true, "appear-enabled": true, "role": true, "tree-level": true, "expanded": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "flex-wrap": true, "hover-foreground": true, "hover-border": true, "transition": true, "inert": true, "navigate-enabled": true, "grid-column-span": true, "pointer-capture": true, "focus-within-enabled": true}, "text": {"text": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "press-enabled": true, "appear-enabled": true, "text-alignment": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "white-space": true, "text-overflow": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true, "line-clamp": true}, "heading": {"text": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "heading-level": true, "appear-enabled": true, "text-alignment": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "white-space": true, "text-overflow": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true, "line-clamp": true}, "paragraph": {"text": true, "grow": true, "padding": true, "padding-horizontal": true, "padding-vertical": true, "padding-top": true, "padding-right": true, "padding-bottom": true, "padding-left": true, "margin": true, "margin-horizontal": true, "margin-vertical": true, "margin-top": true, "margin-right": true, "margin-bottom": true, "margin-left": true, "background": true, "foreground": true, "border-color": true, "border-width": true, "corner-radius": true, "width": true, "height": true, "min-width": true, "max-width": true, "min-height": true, "max-height": true, "container-relative-frame": true, "container-relative-frame-inset": true, "accessibility-identifier": true, "style-class": true, "appear-enabled": true, "text-alignment": true, "theme": true, "theme-mode": true, "alignment": true, "pointer-enabled": true, "data-attrs": true, "as": true, "opacity": true, "display": true, "font-size": true, "font-weight": true, "font-style": true, "line-height": true, "letter-spacing": true, "position": true, "inset": true, "inset-top": true, "inset-right": true, "inset-bottom": true, "inset-left": true, "z-index": true, "white-space": true, "text-overflow": true, "overflow": true, "user-select": true, "cursor": true, "shadow": true, "hover-background": true, "hover-opacity": true, "hover-shadow": true, "pressed-background": true, "pressed-opacity": true, "pressed-shadow": true, "focus-shadow": true, "focus-within-background": true, "focus-within-border": true, "selected-background": true, "selected-shadow": true, "selected-hover-shadow": true, "disabled-opacity": true, "width-viewport": true, "height-viewport": true, "min-width-viewport": true, "max-width-viewport": true, "min-height-viewport": true, "max-height-viewport": true, "font-family": true, "text-decoration": true, "hover-foreground": true, "hover-border": true, "transition": true, "grid-column-span": true, "line-clamp": true}, @@ -474,8 +475,8 @@ var contractKindChildren = map[string]map[string]bool{ var contractKindEvents = map[string]map[string]bool{ "root": {}, - "row": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-move": true, "pointer-cancel": true, "pointer-enter": true, "pointer-leave": true, "navigation-key": true}, - "column": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-enter": true, "pointer-leave": true}, + "row": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-move": true, "pointer-cancel": true, "pointer-enter": true, "pointer-leave": true, "focus-within-changed": true, "navigation-key": true}, + "column": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-enter": true, "pointer-leave": true, "focus-within-changed": true}, "grid": {"appear": true, "pointer-enter": true, "pointer-leave": true}, "stack": {"appear": true, "pointer-enter": true, "pointer-leave": true}, "edge-inset": {"appear": true, "pointer-enter": true, "pointer-leave": true}, @@ -485,7 +486,7 @@ var contractKindEvents = map[string]map[string]bool{ "card": {"appear": true, "pointer-enter": true, "pointer-leave": true}, "alert": {"appear": true, "pointer-enter": true, "pointer-leave": true}, "bubble": {"appear": true, "pointer-enter": true, "pointer-leave": true}, - "box": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-move": true, "pointer-cancel": true, "pointer-enter": true, "pointer-leave": true, "navigation-key": true}, + "box": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-move": true, "pointer-cancel": true, "pointer-enter": true, "pointer-leave": true, "focus-within-changed": true, "navigation-key": true}, "text": {"press": true, "press-modifiers": true, "appear": true, "press-detail": true, "pointer-down": true, "pointer-up": true, "pointer-enter": true, "pointer-leave": true, "context-menu-press": true}, "heading": {"appear": true, "pointer-enter": true, "pointer-leave": true}, "paragraph": {"appear": true, "pointer-enter": true, "pointer-leave": true}, @@ -1111,6 +1112,9 @@ func contract_property_value_supported(property string, value contractWireValue) if contractMatch10 == "appear-enabled" && (contractMatch11.kind == scalarBool && (true)) { return true } + if contractMatch10 == "focus-within-enabled" && (contractMatch11.kind == scalarBool && (true)) { + return true + } if contractMatch10 == "pointer-enabled" && (contractMatch11.kind == scalarBool && (true)) { return true } diff --git a/platform/mygo/style.go b/platform/mygo/style.go index 15d31395..0c0fd000 100644 --- a/platform/mygo/style.go +++ b/platform/mygo/style.go @@ -8,7 +8,16 @@ import ( "strings" ) -func (b *Backend) style(c *ui.Context, n Node, e ui.Element) { +func hasFocusWithinStyle(n Node) bool { + for _, property := range []string{"focus-within-background", "focus-within-foreground", "focus-within-border", "focus-within-opacity"} { + if _, exists := n.Props[property]; exists { + return true + } + } + return false +} + +func (b *Backend) style(c *ui.Context, n Node, e ui.Element, focusWithin func(ui.Element) bool) { pointerPolicy(n, e) if n.Kind == "edge-inset" { n = n.clone() @@ -199,7 +208,7 @@ func (b *Backend) style(c *ui.Context, n Node, e ui.Element) { if n.Bool("selected") { applyState("selected") } - if e.FocusWithin() { + if hasFocusWithinStyle(n) && focusWithin(e) { applyState("focus-within") } if e.Hovered() { diff --git a/platform/web/melange/core/lui_web_types.ml b/platform/web/melange/core/lui_web_types.ml index 26df0928..9a415ace 100644 --- a/platform/web/melange/core/lui_web_types.ml +++ b/platform/web/melange/core/lui_web_types.ml @@ -115,6 +115,7 @@ type web_renderer = { web_lists : (int, bool -> unit) Hashtbl.t; web_mounted_roots : (int, web_node) Hashtbl.t; web_pointer_cleanups : (int, bool * (unit -> unit)) Hashtbl.t; + web_focus_within_observers : (int, (unit -> unit) * (unit -> unit)) Hashtbl.t; web_layers : Lui_web_layers.t; web_modal_return_focus : web_node option ref; web_modal_focus_returns : (int, web_node) Hashtbl.t; diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index 74f1a4b2..aa076a6e 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -782,7 +782,81 @@ let rec sync_pointer_events renderer node kind dom_node = let attach_pointer_events renderer node kind dom_node = sync_pointer_events renderer node kind dom_node +external queue_focus_check : (unit -> unit) -> unit = "queueMicrotask" +external focus_node_connected : Dom.element -> bool = "isConnected" [@@mel.get] + +let release_focus_within renderer node = + match Hashtbl.find_opt renderer.web_focus_within_observers node with + | None -> () + | Some (_, dispose) -> + Hashtbl.remove renderer.web_focus_within_observers node; + dispose () + +let refresh_focus_within renderer node = + Option.iter (fun (refresh, _) -> refresh ()) + (Hashtbl.find_opt renderer.web_focus_within_observers node) + +let refresh_focus_within_subtree renderer node = + let rec refresh node = + refresh_focus_within renderer node; + match Store.node renderer.web_store node with + | None -> () + | Some current -> Lui_sequence.iter refresh current.retained_children + in + if Hashtbl.length renderer.web_focus_within_observers > 0 then refresh node + +let sync_focus_within renderer node dom_node = + if not (Store.true_property renderer node FocusWithinEnabled) then + release_focus_within renderer node + else if not (Hashtbl.mem renderer.web_focus_within_observers node) then begin + let alive = ref true and pending = ref false and last = ref None in + let rec interactive node = + match Store.node renderer.web_store node with + | None -> false + | Some current -> + enabled_node renderer node + && not (Store.true_property renderer node Inert) + && (match current.retained_parent with + | None -> true + | Some parent -> interactive parent) + in + let check () = + pending := false; + if !alive && Store.true_property renderer node FocusWithinEnabled then + match Store.node renderer.web_store node with + | Some current when Lazy.is_val current.platform_node + && Lazy.force current.platform_node == dom_node -> + let document = W.Document.unsafeAsHtmlDocument renderer.web_document in + let within = focus_node_connected dom_node && interactive node + && (match W.HtmlDocument.activeElement document with + | None -> false + | Some focused -> W.Element.contains (W.Element.asNode focused) dom_node) + in + if !last <> Some within then begin + last := Some within; + emit renderer (FocusWithinChanged (node, within)) + end + | _ -> () + in + let refresh () = + if !alive && not !pending then begin + pending := true; + queue_focus_check check + end + in + let listener _event = refresh () in + W.Element.addEventListener "focusin" listener dom_node; + W.Element.addEventListener "focusout" listener dom_node; + Hashtbl.add renderer.web_focus_within_observers node + (refresh, (fun () -> + alive := false; + W.Element.removeEventListener "focusin" listener dom_node; + W.Element.removeEventListener "focusout" listener dom_node)); + refresh () + end + let attach_events renderer node kind dom_node = + sync_focus_within renderer node dom_node; (* The context-menu host bundle is only worth wiring when the node actually has a ContextMenu child; later inserts re-check via apply_dom_op's structural ops. *) diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index cd9ba6df..55437662 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -977,6 +977,7 @@ and apply_secondary_property renderer queue_split node kind dom_node Util.set_state_attribute dom_node "data-toggle-enabled" enabled | ToggleDetailEnabled, BoolValue enabled -> Util.set_state_attribute dom_node "data-toggle-detail-enabled" enabled + | FocusWithinEnabled, BoolValue _ -> () | PressEnabled, BoolValue enabled -> apply_press_enabled kind dom_node enabled | PointerEnabled, BoolValue enabled -> @@ -1261,6 +1262,7 @@ let remove_property renderer queue_split node kind dom_node property = | NavigateEnabled | SubmitOnBlur | ScrollTarget | ScrollAnchor | ScrollToken | ScrollAnimated | TrackVisibleRange -> () | ToggleDetailEnabled -> W.Element.removeAttribute "data-toggle-detail-enabled" dom_node + | FocusWithinEnabled -> () | PointerCapture -> W.Element.removeAttribute "data-pointer-capture" dom_node | TextValue -> apply_text_value renderer node kind dom_node "" diff --git a/platform/web/melange/shell/lui_web.ml b/platform/web/melange/shell/lui_web.ml index 06817aa1..05139a61 100644 --- a/platform/web/melange/shell/lui_web.ml +++ b/platform/web/melange/shell/lui_web.ml @@ -48,6 +48,7 @@ let create_with_extensions host app_icons registry adapters = web_lists = Hashtbl.create 4; web_mounted_roots = Hashtbl.create 4; web_pointer_cleanups = Hashtbl.create 8; + web_focus_within_observers = Hashtbl.create 4; web_layers = Lui_web_layers.create (); web_modal_return_focus = ref None; web_modal_focus_returns = Hashtbl.create 4; diff --git a/platform/web/melange/shell/lui_web_apply.ml b/platform/web/melange/shell/lui_web_apply.ml index 83666249..9417c35f 100644 --- a/platform/web/melange/shell/lui_web_apply.ml +++ b/platform/web/melange/shell/lui_web_apply.ml @@ -71,6 +71,7 @@ let cleanup_node renderer node = | None -> ()); Lui_web_menu.release_context_host_events renderer node; Lui_web_events.release_pointer_events renderer node; + Lui_web_events.release_focus_within renderer node; match Hashtbl.find_opt renderer.web_cleanups node with | Some cleanup -> cleanup (); @@ -561,7 +562,11 @@ let apply_set_prop renderer effects node property value = if property = PointerEnabled || property = PointerCapture || property = Enabled then Lui_web_events.sync_pointer_events renderer node kind (Lazy.force current.platform_node); - refresh_parent_for_prop renderer effects node property + refresh_parent_for_prop renderer effects node property; + if property = FocusWithinEnabled then + Lui_web_events.sync_focus_within renderer node (Lazy.force current.platform_node); + if property = Enabled || property = Inert then + Lui_web_events.refresh_focus_within_subtree renderer node | None -> invalid_arg "standard property targets extension node") | None -> () @@ -584,7 +589,11 @@ let apply_remove_prop renderer effects node property = if property = PointerEnabled || property = PointerCapture || property = Enabled then Lui_web_events.sync_pointer_events renderer node kind (Lazy.force current.platform_node); - refresh_parent_for_prop renderer effects node property + refresh_parent_for_prop renderer effects node property; + if property = FocusWithinEnabled then + Lui_web_events.sync_focus_within renderer node (Lazy.force current.platform_node); + if property = Enabled || property = Inert then + Lui_web_events.refresh_focus_within_subtree renderer node | None -> invalid_arg "standard property targets extension node") | None -> ()) @@ -1032,6 +1041,8 @@ let apply_dom_batch renderer previous_nodes batch = mark_ancestors focus_touched id | _ -> ()) batch.ops; Hashtbl.iter (fun id () -> climb id) touched; + if Hashtbl.length renderer.web_focus_within_observers > 0 then + Hashtbl.iter (fun id () -> Lui_web_events.refresh_focus_within renderer id) focus_touched; Hashtbl.iter (fun id () -> match Store.node renderer.web_store id with | Some current when Store.standard_kind_is current VirtualList @@ -1080,6 +1091,7 @@ let recover_dom renderer = end) resources; Hashtbl.clear renderer.web_cleanups; Hashtbl.clear renderer.web_pointer_cleanups; + Hashtbl.clear renderer.web_focus_within_observers; Hashtbl.clear renderer.web_context_hosts; Hashtbl.clear renderer.web_menu_highlights; Hashtbl.clear renderer.web_virtual_lists; diff --git a/platform/web/test/link-actions.test.mjs b/platform/web/test/link-actions.test.mjs index 30e595ae..f1802868 100644 --- a/platform/web/test/link-actions.test.mjs +++ b/platform/web/test/link-actions.test.mjs @@ -26,6 +26,7 @@ function fixture(){ const element={setAttribute(){},addEventListener(name,callback){listeners.set(name,callback)}} const renderer={web_store:store,web_event_handler:{contents:event=>{events.push(event);return true}}, web_context_hosts:Tables.create(undefined,4),web_pointer_cleanups:Tables.create(undefined,4), + web_focus_within_observers:Tables.create(undefined,4), web_document:{},web_appear_pending:Tables.create(undefined,4),web_appear_seen:Tables.create(undefined,4), web_appear_timer:{contents:undefined}} let generation=0 diff --git a/schema/components.json b/schema/components.json index 1c9f62c4..65d0af7e 100644 --- a/schema/components.json +++ b/schema/components.json @@ -1851,6 +1851,11 @@ "lg": "ToggleDetailEnabled", "wire": "toggle-detail-enabled", "swift": "toggleDetailEnabled" + }, + { + "lg": "FocusWithinEnabled", + "wire": "focus-within-enabled", + "swift": "focusWithinEnabled" } ], "events": [ @@ -2042,6 +2047,13 @@ { "lg": "ToggleChangedDetail", "fields": [":int", ":bool", ":int"] + }, + { + "lg": "FocusWithinChanged", + "fields": [ + ":int", + ":bool" + ] } ], "kindProperties": { @@ -2263,7 +2275,11 @@ "cross" ], "row": [ - "orientation" + "orientation", + "focus-within-enabled" + ], + "column": [ + "focus-within-enabled" ], "list-item": [ "display" @@ -2285,7 +2301,8 @@ "transition" ], "box": [ - "inert" + "inert", + "focus-within-enabled" ], "popover": [ "collision-padding" diff --git a/schema/mygo-manifest.json b/schema/mygo-manifest.json index 8a5ab38a..6db72d99 100644 --- a/schema/mygo-manifest.json +++ b/schema/mygo-manifest.json @@ -1,13 +1,13 @@ { "version": 1, "sourceHashes": { - "schema/components.json": "0e660e5b1c2f7e2f02b6d6655cff72e9ca0e083fd67bb73f78f929ba16d063b9", - "src/lui_protocol.ml": "b7a3b65dded1374016db030b793a7525f450918be6d3a36bd9ceaa5c65fe22e3", - "src/lui_protocol.mli": "ee29ca26d1d39dae08699aefa525acdaa9c7047ada6804ca72d7761bd3608242", - "src/lui_wire_schema.ml": "b7664408ffdcfc03e06bd68fbd62e0a58df567695a1f6acbfd9fecdaa5c7821e", - "tooling/export_mygo_contract.ml": "a055e72c226d0825664551aa073f3cf709a55ae648fcbab62b5c42eac23882ba" + "schema/components.json": "b89403cbbba99f8559aab6b2392301d87682c090df87b4508825d891759860fd", + "src/lui_protocol.ml": "9550ac6d4f88c764758db1ae5909e2915cdae1b455b9e96fd32d417f030eec21", + "src/lui_protocol.mli": "30ee5de82811ffff64dae818dd84597689bc1beae413f12ba4da010ca82b9aa6", + "src/lui_wire_schema.ml": "2f451922adfe58e144ca29b3f0ad21b9b195bb0ccf269322a6a975baaa527a3c", + "tooling/export_mygo_contract.ml": "246ade7e088225ad68d25ce5ebc96a6a850fec3e66e1aa9d9debbd720e04bab1" }, - "generatedGoHash": "16c014884921c72ecc75c13d40009abb9607bc8fee525c3da74273c09661cc4a", + "generatedGoHash": "805e4cdaa0d83c19046193ac53ecdbb6464156ec301031f15825f4a73c50a002", "kinds": { "root": { "contractHash": "1cda829a4bd05ddb79ca21f04ed1d7a51dae8bf3d388bb7eecdf8d5e2f3ec1eb", @@ -17,17 +17,17 @@ "baseline": {} }, "row": { - "contractHash": "8607888dd0b74341aaf93b9a2007d4400d8654129b0a6bca393b82eb27b53b13", - "propertyCount": 95, + "contractHash": "5b37ca2312b59c73d5ca14e59389ed49a11b1dc3710bdee1409ce9675f9d8c50", + "propertyCount": 96, "childCount": 81, - "eventCount": 11, + "eventCount": 12, "baseline": {} }, "column": { - "contractHash": "d1625463275eac1a39011cec61b6bf07997f116c26977792579c7fdb6f2a1507", - "propertyCount": 92, + "contractHash": "6f35e51043ab8ae815796b2dd3a147f0a5e01b0fe15b9bab93f588710264f720", + "propertyCount": 93, "childCount": 81, - "eventCount": 8, + "eventCount": 9, "baseline": {} }, "grid": { @@ -99,10 +99,10 @@ "baseline": {} }, "box": { - "contractHash": "53c3f165be3a92e51235e68b433156fc4b1594d4ee6845cc2ab1d4dc1a270b64", - "propertyCount": 94, + "contractHash": "028237d9cea015c951093e45aa35dc7c3493f8a22f27106e43dfc506da741866", + "propertyCount": 95, "childCount": 81, - "eventCount": 11, + "eventCount": 12, "baseline": {} }, "text": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index 944dea77..2cda3752 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -675,6 +675,12 @@ let register_pointer_cancel context node handler = require_event_supported context node (PointerCancel (node, pointer_detail_probe)) "pointer-cancel"; ignore (on_event context node is_pointer_cancel handler) +let register_focus_within context node handler = + Lui_ui.bool_property context node FocusWithinEnabled true; + Lui_ui.on_event context node (function + | FocusWithinChanged (_, within) -> handler within + | _ -> ()) + let register_pointer_enter context node handler = enable context node PointerEnabled; ignore (on_event context node is_pointer_enter handler) @@ -748,7 +754,7 @@ let apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding Option.iter (register_pointer_leave context node) on_pointer_leave; Option.iter (Lui_ui.string_property context node Transition) transition -let row ?as_ ?key ?gap ?main ?cross ?grow ?columns ?orientation ?orientation_signal ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?pointer_capture ?on_pointer_down ?on_pointer_move ?on_pointer_up ?on_pointer_cancel ?on_navigation_key ?opacity ?opacity_signal ?display ?flex_wrap ?transition (children : t list) : t = +let row ?as_ ?key ?gap ?main ?cross ?grow ?columns ?orientation ?orientation_signal ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_focus_within ?on_appear ?on_pointer_enter ?on_pointer_leave ?pointer_capture ?on_pointer_down ?on_pointer_move ?on_pointer_up ?on_pointer_cancel ?on_navigation_key ?opacity ?opacity_signal ?display ?flex_wrap ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.row context in Option.iter (fun tag -> Lui_ui.string_property context node As (element_tag_value tag)) as_; @@ -765,11 +771,12 @@ let row ?as_ ?key ?gap ?main ?cross ?grow ?columns ?orientation ?orientation_sig Option.iter (register_pointer_up context node) on_pointer_up; Option.iter (register_pointer_cancel context node) on_pointer_cancel; Option.iter (fun handler -> ignore (register_navigate context node handler)) on_navigation_key; + Option.iter (register_focus_within context node) on_focus_within; attach context parent node; mount_children context node children; node -let column ?as_ ?key ?gap ?main ?cross ?grow ?grow_signal ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?min_width_signal ?max_width ?max_width_signal ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?on_press_detail ?on_pointer_down ?on_pointer_up ?opacity ?opacity_signal ?display ?display_signal ?flex_wrap ?transition (children : t list) : t = +let column ?as_ ?key ?gap ?main ?cross ?grow ?grow_signal ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?min_width_signal ?max_width ?max_width_signal ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_focus_within ?on_appear ?on_pointer_enter ?on_pointer_leave ?on_press_detail ?on_pointer_down ?on_pointer_up ?opacity ?opacity_signal ?display ?display_signal ?flex_wrap ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.column context in Option.iter (fun tag -> Lui_ui.string_property context node As (element_tag_value tag)) as_; @@ -783,6 +790,7 @@ let column ?as_ ?key ?gap ?main ?cross ?grow ?grow_signal ?columns ?padding ?pad Option.iter (fun signal_ -> Lui_ui.string_property_signal context node DisplayValue (owned_map context display_value signal_)) display_signal; Option.iter (Lui_ui.string_property context node FlexWrap) (Option.map flex_wrap_value flex_wrap); apply_pointer_events context node ?on_press_detail ?on_pointer_down ?on_pointer_up (); + Option.iter (register_focus_within context node) on_focus_within; attach context parent node; mount_children context node children; node @@ -899,7 +907,7 @@ let bubble ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node -let box ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_appear ?on_pointer_enter ?on_pointer_leave ?pointer_capture ?on_pointer_down ?on_pointer_move ?on_pointer_up ?on_pointer_cancel ?on_navigation_key ?opacity ?opacity_signal ?display ?flex_wrap ?inert ?inert_signal ?transition (children : t list) : t = +let box ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?margin ?margin_horizontal ?margin_vertical ?margin_top ?margin_right ?margin_bottom ?margin_left ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?data_attrs ?data_attrs_signal ?on_focus_within ?on_appear ?on_pointer_enter ?on_pointer_leave ?pointer_capture ?on_pointer_down ?on_pointer_move ?on_pointer_up ?on_pointer_cancel ?on_navigation_key ?opacity ?opacity_signal ?display ?flex_wrap ?inert ?inert_signal ?transition (children : t list) : t = fun context parent -> let node = Lui_ui.box context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~margin ~margin_horizontal ~margin_vertical ~margin_top ~margin_right ~margin_bottom ~margin_left ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~data_attrs ~data_attrs_signal ~on_appear ~on_pointer_enter ~on_pointer_leave ~transition; @@ -915,6 +923,7 @@ let box ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padd Option.iter (register_pointer_up context node) on_pointer_up; Option.iter (register_pointer_cancel context node) on_pointer_cancel; Option.iter (fun handler -> ignore (register_navigate context node handler)) on_navigation_key; + Option.iter (register_focus_within context node) on_focus_within; attach context parent node; mount_children context node children; node diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 632bbfaf..e9f974f3 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -407,6 +407,7 @@ val row : ?style_class:string -> ?data_attrs:(string * string) list -> ?data_attrs_signal:(string * string) list Signal.signal -> + ?on_focus_within:(bool -> unit) -> ?on_appear:(Lui_protocol.event -> unit) -> ?on_pointer_enter:(Lui_protocol.event -> unit) -> ?on_pointer_leave:(Lui_protocol.event -> unit) -> @@ -462,6 +463,7 @@ val column : ?style_class:string -> ?data_attrs:(string * string) list -> ?data_attrs_signal:(string * string) list Signal.signal -> + ?on_focus_within:(bool -> unit) -> ?on_appear:(Lui_protocol.event -> unit) -> ?on_pointer_enter:(Lui_protocol.event -> unit) -> ?on_pointer_leave:(Lui_protocol.event -> unit) -> @@ -933,6 +935,7 @@ val box : ?style_class:string -> ?data_attrs:(string * string) list -> ?data_attrs_signal:(string * string) list Signal.signal -> + ?on_focus_within:(bool -> unit) -> ?on_appear:(Lui_protocol.event -> unit) -> ?on_pointer_enter:(Lui_protocol.event -> unit) -> ?on_pointer_leave:(Lui_protocol.event -> unit) -> diff --git a/src/lui_json_view.ml b/src/lui_json_view.ml index e3ddcc74..a7e11aeb 100644 --- a/src/lui_json_view.ml +++ b/src/lui_json_view.ml @@ -304,6 +304,7 @@ let ev name (emit : event_sink) props = let fields = match e with | P.ToggleChanged (_, checked) -> [ ("checked", Bool checked) ] + | P.FocusWithinChanged (_, within) -> [ ("within", Bool within) ] | P.NavigatePrevious _ -> [ ("direction", Str "previous") ] | P.NavigateNext _ -> [ ("direction", Str "next") ] | P.NavigationKey (_, key, shift) -> [ ("key", Str key); ("shift", Bool shift) ] diff --git a/src/lui_native_bridge.ml b/src/lui_native_bridge.ml index 84fc1ef2..682291b1 100644 --- a/src/lui_native_bridge.ml +++ b/src/lui_native_bridge.ml @@ -148,6 +148,8 @@ let register ~(register_named : 'a. string -> 'a -> unit) prefix bridge = register "picked" (fun id payload -> dispatch_event bridge (Picked (id, payload))); register "double_press" (node (fun id -> DoublePress id)); + register "focus_within_changed" (fun id within -> + dispatch_event bridge (FocusWithinChanged (id, within))); register "toggle_changed" (fun id checked -> dispatch_event bridge (ToggleChanged (id, checked))); register "toggle_changed_detail" (fun id checked modifiers -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 17e06a92..2f4b83ab 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -310,6 +310,7 @@ type property = | PointerCapture | Indeterminate | ToggleDetailEnabled + | FocusWithinEnabled module Property_map = Map.Make @@ -372,6 +373,7 @@ type event = | PointerMove of int * pointer_detail | PointerCancel of int * pointer_detail | ToggleChangedDetail of int * bool * int + | FocusWithinChanged of int * bool | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = @@ -408,6 +410,7 @@ let event_node event = | LongPress node | TextChanged (node, _) | Submit node + | FocusWithinChanged (node, _) | ToggleChanged (node, _) | Change node | ValueChanged (node, _) @@ -598,6 +601,7 @@ let event_supported kind event = | BottomTab | SwipeAction -> true | _ -> false) + | FocusWithinChanged _ -> List.mem kind [Row; Column; Box] | PointerEnter _ | PointerLeave _ -> kind <> Root | ContextMenuPress _ -> context_menu_host_kind kind | Load _ -> kind = Image @@ -619,6 +623,7 @@ let event_supported_for_lookup kind lookup event = match lookup property with Some (BoolValue true) -> true | _ -> false in match event with + | FocusWithinChanged _ -> enabled FocusWithinEnabled && event_supported kind event | (Press _ | PressModifiers _) when kind = Link -> enabled PressEnabled | NavigatePrevious _ | NavigateNext _ | NavigationKey _ -> enabled NavigateEnabled @@ -1139,6 +1144,7 @@ let common_property_supported kind property = | SubmitEnabled -> event_supported kind (Submit 0) | DoublePressEnabled -> kind = ListItem | AppearEnabled -> kind <> Root + | FocusWithinEnabled -> List.mem kind [Row; Column; Box] | PointerEnabled -> kind <> Root | ImageIdValue | SourceX | SourceY | SourceWidth | SourceHeight -> kind = Avatar || kind = Image @@ -1675,6 +1681,7 @@ let property_value_supported property value = | SubmitEnabled, BoolValue _ -> true | DoublePressEnabled, BoolValue _ -> true | AppearEnabled, BoolValue _ -> true + | FocusWithinEnabled, BoolValue _ -> true | PointerEnabled, BoolValue _ -> true | ImageIdValue, IntValue value -> value >= 0 | SurfaceIdValue, IntValue value -> value >= 0 diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index c6559f34..6a804d27 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -308,6 +308,7 @@ type property = | PointerCapture | Indeterminate | ToggleDetailEnabled + | FocusWithinEnabled module Property_map : Map.S with type key = property @@ -363,6 +364,7 @@ type event = | PointerMove of int * pointer_detail | PointerCancel of int * pointer_detail | ToggleChangedDetail of int * bool * int + | FocusWithinChanged of int * bool | ExtensionEvent of int * string * string * wire_value String_map.t type patch_op = diff --git a/src/lui_runtime.ml b/src/lui_runtime.ml index 4ef10a01..9339929a 100644 --- a/src/lui_runtime.ml +++ b/src/lui_runtime.ml @@ -1849,6 +1849,11 @@ let notify_segment_property application node property value = !(segment.dynamic_segment_on_property)) segments let set_prop application node property value = + if property = FocusWithinEnabled then + (match application.runtime_backend.backend_profile.profile_host with + | WebHost | MyGoHost -> () + | SwiftUIHost | KotlinHost | GenericHost -> + invalid_arg "focus-within observation is unsupported by backend profile"); let node = canonical_node application node in let kind = require_standard_node_kind application node in if not (property_supported kind property) then diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index bf905d35..a4d06b4b 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -382,6 +382,7 @@ let property_name property = | PointerCapture -> "pointer-capture" | Indeterminate -> "indeterminate" | ToggleDetailEnabled -> "toggle-detail-enabled" + | FocusWithinEnabled -> "focus-within-enabled" let kind_property_matrix kind = match kind with @@ -411,12 +412,13 @@ let kind_extra_properties kind = | Checkbox -> [ Gap; LabelOpacity; IndicatorBorderColor; CheckedIndicatorBorderColor ] | Radio -> [ Gap; LabelOpacity ] | Grid -> [ MainAlignment; CrossAlignment ] - | Row -> [ OrientationValue ] + | Row -> [ OrientationValue; FocusWithinEnabled ] + | Column -> [ FocusWithinEnabled ] | ListItem -> [ DisplayValue ] | Dialog -> [ DescriptionValue; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; Gap; Transition ] | Drawer -> [ Transition ] | Sheet -> [ Transition ] - | Box -> [ Inert ] + | Box -> [ Inert; FocusWithinEnabled ] | Popover -> [ CollisionPadding ] | DropdownMenu -> [ CollisionPadding ] | Tooltip -> [ CollisionPadding ] @@ -432,7 +434,7 @@ let kind_extra_properties kind = let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; ListSection; ListSectionHeader; ListSectionFooter; SwipeActions; SwipeAction; FilePicker; Kbd; Link; FileImage; FilePreview; Popover ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; SelectionPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorTarget; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; FocusWithinBackground; FocusWithinBorder; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition; Inert; NavigateEnabled; FirstPaneWidth; GridColumnSpan; LabelOpacity; SubmitOnBlur; AnchorAlignmentOffset; AnchorRect; ResizeHandleWidth; ResizeHandleOffset; IndicatorBorderColor; CheckedIndicatorBorderColor; CollisionPadding; LineClamp; ReadOnly; VirtualOverscan; VirtualItemExtent; VirtualTotalCount; VirtualGeneration; VirtualIndices; VirtualScrollIndex; PointerCapture; Indeterminate; ToggleDetailEnabled ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; PaddingTop; PaddingRight; PaddingBottom; PaddingLeft; MarginValue; MarginHorizontal; MarginVertical; MarginTop; MarginRight; MarginBottom; MarginLeft; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; SelectionPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorTarget; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; KeyValue; SeparatorValue; StyleValue; ScrollTarget; ScrollAnchor; ScrollToken; ScrollAnimated; TrackVisibleRange; EdgeValue; PickerRequest; PickerTypes; PickerMultiple; PickerSource; PickerCompletion; MinValue; MaxValue; StepValue; Detents; Sizing; PathValue; UrlValue; MaxPixelSize; ImageFitValue; Visible; AlignmentValue; PointerEnabled; DataAttrs; As; AltValue; LoadingValue; ReferrerPolicy; TargetValue; Opacity; DisplayValue; TooltipText; TooltipKeys; InputType; PickerAccept; PickerDirectory; PopupX; PopupY; AvailableHeight; FontSize; FontWeight; FontStyle; LineHeight; LetterSpacing; Position; Inset; InsetTop; InsetRight; InsetBottom; InsetLeft; ZIndex; WhiteSpace; TextOverflow; Overflow; UserSelect; Cursor; Shadow; HoverBackground; HoverOpacity; HoverShadow; PressedBackground; PressedOpacity; PressedShadow; FocusShadow; FocusWithinBackground; FocusWithinBorder; SelectedBackground; SelectedShadow; SelectedHoverShadow; DisabledOpacity; WidthViewport; HeightViewport; MinWidthViewport; MaxWidthViewport; MinHeightViewport; MaxHeightViewport; FontFamily; TextDecoration; ListStyle; FlexWrap; HoverForeground; HoverBorder; Transition; Inert; NavigateEnabled; FirstPaneWidth; GridColumnSpan; LabelOpacity; SubmitOnBlur; AnchorAlignmentOffset; AnchorRect; ResizeHandleWidth; ResizeHandleOffset; IndicatorBorderColor; CheckedIndicatorBorderColor; CollisionPadding; LineClamp; ReadOnly; VirtualOverscan; VirtualItemExtent; VirtualTotalCount; VirtualGeneration; VirtualIndices; VirtualScrollIndex; PointerCapture; Indeterminate; ToggleDetailEnabled; FocusWithinEnabled ] type view_value = @@ -488,15 +490,15 @@ let view_contract = function | Row -> Some { view_result = "t"; view_children = ViewList "t"; - view_properties = [(As, view_element_tag, false); (KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (OrientationValue, view_orientation, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (PointerCapture, ViewBool, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; - view_events = ["appear"; "pointer-enter"; "pointer-leave"; "pointer-down"; "pointer-move"; "pointer-up"; "pointer-cancel"; "navigation-key"]; + view_properties = [(As, view_element_tag, false); (KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (OrientationValue, view_orientation, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (FocusWithinEnabled, ViewBool, false); (PointerCapture, ViewBool, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; + view_events = ["focus-within-changed"; "appear"; "pointer-enter"; "pointer-leave"; "pointer-down"; "pointer-move"; "pointer-up"; "pointer-cancel"; "navigation-key"]; view_slots = []; } | Column -> Some { view_result = "t"; view_children = ViewList "t"; - view_properties = [(As, view_element_tag, false); (KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; - view_events = ["appear"; "pointer-enter"; "pointer-leave"; "press-detail"; "pointer-down"; "pointer-up"]; + view_properties = [(As, view_element_tag, false); (KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (FocusWithinEnabled, ViewBool, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; + view_events = ["focus-within-changed"; "appear"; "pointer-enter"; "pointer-leave"; "press-detail"; "pointer-down"; "pointer-up"]; view_slots = []; } | Grid -> Some { @@ -565,8 +567,8 @@ let view_contract = function | Box -> Some { view_result = "t"; view_children = ViewList "t"; - view_properties = [(KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (PointerCapture, ViewBool, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Inert, ViewBool, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; - view_events = ["appear"; "pointer-enter"; "pointer-leave"; "pointer-down"; "pointer-move"; "pointer-up"; "pointer-cancel"; "navigation-key"]; + view_properties = [(KeyValue, ViewString, false); (Gap, ViewInt, false); (MainAlignment, view_main_alignment, false); (CrossAlignment, view_cross_alignment, false); (GrowValue, ViewFloat, false); (GridColumns, ViewInt, false); (PaddingValue, ViewInt, false); (PaddingHorizontal, ViewInt, false); (PaddingVertical, ViewInt, false); (MarginValue, ViewInt, false); (MarginHorizontal, ViewInt, false); (MarginVertical, ViewInt, false); (MarginTop, ViewInt, false); (MarginRight, ViewInt, false); (MarginBottom, ViewInt, false); (MarginLeft, ViewInt, false); (BackgroundValue, ViewString, false); (ForegroundValue, ViewString, false); (BorderColorValue, ViewString, false); (BorderWidth, ViewInt, false); (CornerRadius, ViewInt, false); (WidthValue, ViewInt, false); (HeightValue, ViewInt, false); (MinWidth, ViewInt, false); (MaxWidth, ViewInt, false); (MinHeight, ViewInt, false); (MaxHeight, ViewInt, false); (ContainerRelativeFrameValue, view_frame_axes, false); (ContainerRelativeFrameInset, ViewInt, false); (AccessibilityIdentifier, ViewString, false); (StyleClass, ViewString, false); (DataAttrs, ViewAttributes, false); (FocusWithinEnabled, ViewBool, false); (PointerCapture, ViewBool, false); (Opacity, ViewFloat, false); (DisplayValue, view_display, false); (FlexWrap, view_flex_wrap, false); (Inert, ViewBool, false); (Transition, ViewString, false); (AlignmentValue, view_alignment, false)]; + view_events = ["focus-within-changed"; "appear"; "pointer-enter"; "pointer-leave"; "pointer-down"; "pointer-move"; "pointer-up"; "pointer-cancel"; "navigation-key"]; view_slots = []; } | Text -> Some { diff --git a/tooling/export_mygo_contract.ml b/tooling/export_mygo_contract.ml index 4c54fab6..49f9e604 100644 --- a/tooling/export_mygo_contract.ml +++ b/tooling/export_mygo_contract.ml @@ -98,6 +98,7 @@ let events = [ "pointer-move", PointerMove (0, pointer); "pointer-cancel", PointerCancel (0, pointer); "toggle-changed-detail", ToggleChangedDetail (0, false, 0); "pointer-enter", PointerEnter 0; "pointer-leave", PointerLeave 0; + "focus-within-changed", FocusWithinChanged (0, false); "context-menu-press", ContextMenuPress (0, pointer); "load", Load 0; "navigate-previous", NavigatePrevious 0; "navigate-next", NavigateNext 0; "navigation-key", NavigationKey (0, "ArrowDown", false); diff --git a/tooling/typed_view_schema.mjs b/tooling/typed_view_schema.mjs index aa640035..f2a9ac13 100644 --- a/tooling/typed_view_schema.mjs +++ b/tooling/typed_view_schema.mjs @@ -149,6 +149,12 @@ export function typedViewSchema(schema, signature, implementation, {extension = } continue; } + if (!extension && label === 'on_focus_within') { + if (type !== '(bool -> unit)' || !['row', 'column', 'box'].includes(wire)) throw new Error(`unknown focus observer: ${wire}`); + props.set(properties.get('focus-within-enabled'), {value: 'ViewBool', required: false}); + events.add('focus-within-changed'); + continue; + } if (label.startsWith('on_')) { if (!(extension ? /^\(\w+ -> unit\)$/.test(type) : type === '(Lui_protocol.event -> unit)')) throw new Error(`unknown event type: ${wire}.${label}`); const event = label.slice(3); From 572432997b06a76a25f666ee6d8073b17dea349c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sat, 7 Nov 2026 20:36:54 -0800 Subject: [PATCH 2/2] Fix committed focus snapshots and transparent box admission --- docs/api-migrations.md | 6 +++++ platform/mygo/backend.go | 2 ++ platform/mygo/backend_test.go | 17 +++++++++++++++ platform/mygo/contract.go | 3 +++ platform/mygo/events_test.go | 18 +++++++++++++++ platform/mygo/render.go | 41 ++++++++++++++++++++++------------- src/lui_runtime.ml | 9 ++++++++ 7 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs/api-migrations.md b/docs/api-migrations.md index e1459e54..de90318c 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -18,6 +18,12 @@ Neither backend treats a popup/portal outside the subtree as inside merely because it is attached to a trigger. Window activation is not a second condition on retained focus membership; this is not an application-activation callback. +MyGo rejects `~on_focus_within` on ``box ~display:`contents``: a transparent +box has no native element to observe. The same restriction applies to raw wire +nodes with `display="contents"` and `focus-within-enabled=true`. Observe a real +row, column or non-transparent box instead. Web can observe this combination +through its retained DOM subtree. + Disabled/inert ancestors normalize membership to false, including a terminal false for a previously focused subtree. Re-enabling observes current membership again. The last value is recorded before dispatch, so reducer patches do not diff --git a/platform/mygo/backend.go b/platform/mygo/backend.go index 7b013ffb..18f1d8fe 100644 --- a/platform/mygo/backend.go +++ b/platform/mygo/backend.go @@ -84,6 +84,8 @@ type Event struct { Shift bool `json:"shift,omitempty"` Payload string `json:"payload,omitempty"` incarnation uint64 + + focusContext *ui.Context } func (e Event) MarshalJSON() ([]byte, error) { diff --git a/platform/mygo/backend_test.go b/platform/mygo/backend_test.go index d9c317d5..9e713d6b 100644 --- a/platform/mygo/backend_test.go +++ b/platform/mygo/backend_test.go @@ -155,6 +155,23 @@ func TestRemovedLineBreakKind(t *testing.T) { } } +func TestFocusWithinRejectsTransparentBox(t *testing.T) { + for _, properties := range [][]Operation{ + {prop(3, "display", "contents"), prop(3, "focus-within-enabled", true)}, + {prop(3, "focus-within-enabled", true), prop(3, "display", "contents")}, + } { + b := mounted(t, nil) + ops := append([]Operation{create(3, "box"), insert(2, 3, 0)}, properties...) + err := b.ApplyBatch(Batch{Generation: 2, Ops: ops}) + if err == nil || !strings.Contains(err.Error(), "focus-within") { + t.Fatalf("transparent box with focus observer must be rejected: %v", err) + } + if _, ok := b.Node(3); ok || b.Generation() != 1 { + t.Fatal("rejected batch changed the retained tree") + } + } +} + func TestMalformedWire(t *testing.T) { for _, s := range []string{ `{}`, `null`, `[]`, `{"generation":1}`, `{"generation":1,"ops":null}`, `{"generation":1.5,"ops":[]}`, diff --git a/platform/mygo/contract.go b/platform/mygo/contract.go index 9dab9e4a..f07f5636 100644 --- a/platform/mygo/contract.go +++ b/platform/mygo/contract.go @@ -93,6 +93,9 @@ func validateNode(n Node) error { return fmt.Errorf("lui: invalid %s property %q on node %d", n.Kind, name, n.ID) } } + if n.Kind == "box" && n.String("display") == "contents" && n.Bool("focus-within-enabled") { + return fmt.Errorf("lui: focus-within observation is unsupported on box display=contents (node %d)", n.ID) + } if !contract_node_properties_supported(n.Kind, n.Props) { return fmt.Errorf("lui: invalid final properties for %s node %d", n.Kind, n.ID) } diff --git a/platform/mygo/events_test.go b/platform/mygo/events_test.go index fb007b2a..ecf7b6bb 100644 --- a/platform/mygo/events_test.go +++ b/platform/mygo/events_test.go @@ -183,6 +183,24 @@ func TestEventsCapturedPointerCancel(t *testing.T) { } } +func TestEventsFocusWithinInitialAutofocus(t *testing.T) { + var received []bool + b := mounted(t, func(event Event) error { + if event.Kind == "focus-within-changed" { + received = append(received, event.Within) + } + return nil + }) + apply(t, b, 2, create(6, "row"), prop(6, "focus-within-enabled", true), insert(2, 6, 0), + create(3, "text-field"), prop(3, "placeholder", "Initial focus"), prop(3, "autofocus", true), insert(6, 3, 0)) + tester := ui.NewTester(b.View, 360, 240) + tester.Frame() + tester.Frame() + if len(received) != 1 || !received[0] { + t.Fatalf("initial snapshot must reflect committed child focus: %v", received) + } +} + func TestEventsFocusWithinTransitions(t *testing.T) { var received []bool var b *Backend diff --git a/platform/mygo/render.go b/platform/mygo/render.go index ca35a507..05fe8cf1 100644 --- a/platform/mygo/render.go +++ b/platform/mygo/render.go @@ -35,9 +35,9 @@ func (b *Backend) state(n Node) *controlState { } return s } -func (b *Backend) emit(e Event) { +func (b *Backend) emit(e Event) bool { if b.probing || b.closed { - return + return false } if e.incarnation == 0 { if s := b.states[e.Node]; s != nil { @@ -46,37 +46,45 @@ func (b *Backend) emit(e Event) { } if b.building { b.queued = append(b.queued, e) - return + return false } n, ok := b.nodes[e.Node] if !ok || (e.incarnation != 0 && (b.states[e.Node] == nil || b.states[e.Node].identity != e.incarnation)) { - return + return false } defer b.reconcileControl(e) if e.Kind == "focus-within-changed" { state := b.states[e.Node] - if state == nil || !eventAllowed(n, e) { - return + if state == nil { + return false + } + if e.focusContext != nil { + e.Within = !b.interactionBlocked(n, Event{Kind: e.Kind, Within: true}) && state.handle.FocusWithin(e.focusContext) + e.focusContext = nil + } + if !eventAllowed(n, e) { + return false } if b.interactionBlocked(n, e) { e.Within = false } if state.focusWithinKnown && state.focusWithinLast == e.Within { - return + return false } state.focusWithinKnown, state.focusWithinLast = true, e.Within } if b.interactionBlocked(n, e) { - return + return false } if !eventAllowed(n, e) { - return + return false } if b.config.Dispatch != nil { if err := b.config.Dispatch(e); err != nil && b.err == nil { b.err = err } } + return e.Kind == "focus-within-changed" } func (b *Backend) View(c *ui.Context) { b.viewGeneration = b.generation @@ -104,6 +112,7 @@ func (b *Backend) View(c *ui.Context) { c.AfterFrame(func() { pending := append(appearances, b.queued...) b.queued = nil + invalidate := false for _, event := range pending { if event.Kind == "appear" { state := b.states[event.Node] @@ -112,9 +121,14 @@ func (b *Backend) View(c *ui.Context) { } state.appeared = true } - b.emit(event) + if event.Kind == "focus-within-changed" { + invalidate = b.emit(event) || invalidate + } else { + b.emit(event) + invalidate = true + } } - if len(pending) > 0 { + if invalidate { c.Invalidate() } for key, owner := range b.menuOwners { @@ -347,10 +361,7 @@ func (b *Backend) render(c *ui.Context, id int64, appearances *[]Event) { } if !b.probing { if n.Bool("focus-within-enabled") { - within := !b.interactionBlocked(n, Event{Kind: "focus-within-changed", Within: true}) && focusWithin(e) - if !s.focusWithinKnown || s.focusWithinLast != within { - emit(Event{Kind: "focus-within-changed", Within: within}) - } + b.emit(Event{Kind: "focus-within-changed", Node: id, incarnation: s.identity, focusContext: c}) } else { s.focusWithinKnown = false } diff --git a/src/lui_runtime.ml b/src/lui_runtime.ml index 9339929a..3b4d77f7 100644 --- a/src/lui_runtime.ml +++ b/src/lui_runtime.ml @@ -1876,6 +1876,15 @@ let set_prop application node property value = | Some values -> values | None -> Property_map.empty in + if application.runtime_backend.backend_profile.profile_host = MyGoHost && + kind = Box && + (match property, value with + | FocusWithinEnabled, BoolValue true -> + Property_map.find_opt DisplayValue current = Some (StringValue "contents") + | DisplayValue, StringValue "contents" -> + Property_map.find_opt FocusWithinEnabled current = Some (BoolValue true) + | _ -> false) + then invalid_arg "focus-within observation is unsupported on MyGo box display=contents"; if Property_map.find_opt property current <> Some value then begin notify_segment_property application node property (Some value); (* A segment callback may have updated another property on this node. *)