diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 5ae60f8b..a4c54d67 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -584,6 +584,7 @@ public final class LUIAppleBackend { guard allowsControlInteraction(node: node) else { return } guard let model = models[node], model.kind == .button || model.kind == .toggleButton || + (model.kind == .column && model.supportsPress) || (model.kind == .text && model.supportsPress) || (model.kind == .bottomTab && model.supportsPress) || (model.kind == .tableCell && model.supportsPress) || diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 84a8b0a3..b50a050f 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -3949,8 +3949,11 @@ private struct LUIButtonView: View { .disabled(!model.isEnabled) .focused($focused) .onAppear { requestFocusIfNeeded() } - .onChange(of: model.requestsAutofocus) { _, requested in - if requested { focused = true } + // autofocus is an edge-triggered focus request: any transition + // counts, so repeated requests refocus even when the flag was + // already true while the control was unfocused. + .onChange(of: model.requestsAutofocus) { _, _ in + focused = true } .onChange(of: model.isSelected) { _, modelSelected in if isToggle { @@ -4754,6 +4757,7 @@ private struct LUIColumnView: View { ? .infinity : nil, alignment: frameAlignment ) + .modifier(LUIColumnPressModifier(model: model, backend: backend)) } @ViewBuilder @@ -4826,6 +4830,24 @@ private struct LUIColumnView: View { } } +private struct LUIColumnPressModifier: ViewModifier { + let model: LUINodeModel + let backend: LUIAppleBackend + + func body(content: Content) -> some View { + if model.supportsPress { + content + .contentShape(Rectangle()) + .onTapGesture { + guard model.isEnabled else { return } + try? backend.performPress(node: model.id) + } + } else { + content + } + } +} + struct LUIListSection: Equatable, Identifiable { let headerID: Int? let childIDs: [Int] @@ -5210,7 +5232,7 @@ private struct LUITextControlView: View { Group { if grouped { field.textFieldStyle(.plain) - } else if model.kind == .searchField { + } else if model.kind == .searchField || model.kind == .textarea { field } else { field.textFieldStyle(.roundedBorder) @@ -5220,8 +5242,11 @@ private struct LUITextControlView: View { .focused($focused) .onSubmit { try? backend.performSubmit(node: model.id) } .onAppear { if model.requestsAutofocus { focused = true } } - .onChange(of: model.requestsAutofocus) { _, requested in - if requested { focused = true } + // autofocus is an edge-triggered focus request: any transition + // is a request, so repeated requests refocus even when the flag + // was already true while the field was unfocused. + .onChange(of: model.requestsAutofocus) { _, _ in + focused = true } .onChange(of: model.text) { _, next in draftState.reconcile(source: next, focused: focused) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 356cd8fc..f2571376 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -795,8 +795,8 @@ struct LUIRetainedTree { case .changeEnabled: kind == .radio || isTreeRow(kind) case .toggleEnabled: kind == .radio || kind == .drawer || isTreeRow(kind) case .pressEnabled: - kind == .text || kind == .radio || kind == .select || kind == .combobox || - kind == .menuItem || kind == .listItem + kind == .text || kind == .column || kind == .radio || kind == .select || + kind == .combobox || kind == .menuItem || kind == .listItem || kind == .tableCell || isTreeRow(kind) case .submitEnabled: kind == .combobox || kind == .listItem case .doublePressEnabled: kind == .listItem diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 23ef8329..85862bd8 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -955,7 +955,10 @@ final class LUIFlutterBackend { // stranded in the theater (and removeRoute asserts in debug builds). void _scheduleRouteRemoval(Route route, NavigatorState navigator) { scheduleMicrotask(() { - if (!route.isActive) return; + if (!route.isActive) { + _refreshSemanticsAfterModalRemoval(); + return; + } if (route.isCurrent) { // Pop runs the normal transition lifecycle — reverse animation, // history flush, observer notifications — which removeRoute skips. @@ -1092,6 +1095,8 @@ final class LUIFlutterBackend { (treeItem && state.properties['press-enabled'] == true) || (state.kind == _NodeKind.tableCell && state.properties['press-enabled'] == true) || + (state.kind == _NodeKind.column && + state.properties['press-enabled'] == true) || (state.kind == _NodeKind.text && state.properties['press-enabled'] == true); if (!pressable || state.properties['enabled'] == false) { @@ -1495,6 +1500,7 @@ final class LUIFlutterBackend { placeholder: placeholder, foreground: foreground, autofocus: state.properties['autofocus'] as bool? ?? false, + focusNode: _requireHandle(id).focusNode, multiline: multiline, secure: kind == _NodeKind.secureField, search: kind == _NodeKind.searchField, @@ -1725,13 +1731,22 @@ final class LUIFlutterBackend { child: horizontalGroupFlex(), ), ); - Widget column() => LUIFlex( - direction: Axis.vertical, - mainAxisAlignment: _mainAxisAlignment(main), - crossAxisAlignment: _crossAxisAlignment(cross, canStretch: true), - spacing: gap, - children: children, - ); + Widget column() { + final body = LUIFlex( + direction: Axis.vertical, + mainAxisAlignment: _mainAxisAlignment(main), + crossAxisAlignment: _crossAxisAlignment(cross, canStretch: true), + spacing: gap, + children: children, + ); + if (state.properties['press-enabled'] == true) { + return GestureDetector( + onTap: () => performAction(id), + child: body, + ); + } + return body; + } Widget stack() { final menuID = state.children.cast().firstWhere( (childID) => @@ -1894,6 +1909,48 @@ final class LUIFlutterBackend { ); } + Widget menuTrigger() { + final menuID = state.children.cast().firstWhere( + (childID) => + childID != null && _states[childID]?.kind == _NodeKind.dropdownMenu, + orElse: () => null, + ); + final menuState = menuID == null ? null : _requireState(_states, menuID); + final icon = buttonIcon == null + ? null + : Icon(_iconData(buttonIcon), size: 16, color: foreground); + final label = icon == null + ? Text(text, style: TextStyle(color: foreground)) + : text.isEmpty + ? icon + : Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + icon, + Text(text, style: TextStyle(color: foreground)), + ], + ); + return MenuAnchor( + builder: (context, controller, child) => Semantics( + label: accessibilityLabel ?? text, + button: true, + child: TextButton( + onPressed: enabled + ? () => + controller.isOpen ? controller.close() : controller.open() + : null, + child: label, + ), + ), + menuChildren: + menuState?.children + .map((childID) => widget(node: childID)) + .toList(growable: false) ?? + const [], + ); + } + Widget listItem() => _LUIListItem( enabled: enabled, focusable: state.properties['role'] != 'treeitem', @@ -2595,6 +2652,7 @@ final class LUIFlutterBackend { _NodeKind.dialog || _NodeKind.sheet => _LUIModalPresenter(backend: this, node: id), _NodeKind.menuItem => menuItem(), + _NodeKind.menuTrigger => menuTrigger(), _NodeKind.listItem => listItem(), _NodeKind.table => table(), _NodeKind.tree => tree(), @@ -3208,11 +3266,18 @@ final class LUIFlutterBackend { if ((parent.kind == _NodeKind.dropdownMenu || parent.kind == _NodeKind.contextMenu) && child.kind != _NodeKind.menuItem && + child.kind != _NodeKind.menuTrigger && child.kind != _NodeKind.divider) { throw const LUIBackendException( 'menu accepts only menu-item or separator children', ); } + if (parent.kind == _NodeKind.menuTrigger && + child.kind != _NodeKind.dropdownMenu) { + throw const LUIBackendException( + 'menu-trigger accepts only a dropdown-menu child', + ); + } if (parent.kind == _NodeKind.menuItem && child.kind != _NodeKind.contextMenu && child.kind != _NodeKind.dropdownMenu) { @@ -3435,6 +3500,7 @@ final class LUIFlutterBackend { kind == _NodeKind.radio || kind == _NodeKind.select || kind == _NodeKind.menuItem || + kind == _NodeKind.menuTrigger || kind == _NodeKind.listItem || kind == _NodeKind.tableCell || kind == _NodeKind.avatar || @@ -3509,6 +3575,7 @@ final class LUIFlutterBackend { _appIconNamePattern.hasMatch(value)) && (_isButtonKind(kind) || kind == _NodeKind.menuItem || + kind == _NodeKind.menuTrigger || kind == _NodeKind.listItem), 'icon-placement' => value is String && @@ -3537,6 +3604,7 @@ final class LUIFlutterBackend { 'press-enabled' => value is bool && (kind == _NodeKind.text || + kind == _NodeKind.column || kind == _NodeKind.radio || kind == _NodeKind.select || kind == _NodeKind.combobox || @@ -3619,6 +3687,7 @@ final class LUIFlutterBackend { kind == _NodeKind.select || kind == _NodeKind.dropdownMenu || kind == _NodeKind.menuItem || + kind == _NodeKind.menuTrigger || kind == _NodeKind.listItem || kind == _NodeKind.tableCell || kind == _NodeKind.resizable || @@ -3694,6 +3763,7 @@ final class LUIFlutterBackend { kind == _NodeKind.alert || kind == _NodeKind.bubble || kind == _NodeKind.select || + kind == _NodeKind.menuTrigger || _isTreeRowKind(kind)), 'text-alignment' => value is String && @@ -4137,6 +4207,7 @@ final class LUIFlutterBackend { kind == _NodeKind.stepper || kind == _NodeKind.timeline || kind == _NodeKind.inputGroup || + kind == _NodeKind.menuTrigger || kind == _NodeKind.inputGroupActions || kind == _NodeKind.toast || kind == _NodeKind.toolbar || @@ -4186,6 +4257,7 @@ final class LUIFlutterBackend { kind == _NodeKind.input || kind == _NodeKind.searchField || kind == _NodeKind.menuItem || + kind == _NodeKind.menuTrigger || kind == _NodeKind.spacer || kind == _NodeKind.divider || kind == _NodeKind.text; @@ -5945,6 +6017,7 @@ final class _LUITextInput extends StatefulWidget { required this.placeholder, required this.foreground, required this.autofocus, + required this.focusNode, required this.multiline, required this.secure, required this.search, @@ -5960,6 +6033,7 @@ final class _LUITextInput extends StatefulWidget { final String? placeholder; final Color? foreground; final bool autofocus; + final FocusNode focusNode; final bool multiline; final bool secure; final bool search; @@ -5985,6 +6059,9 @@ final class _LUITextInputState extends State<_LUITextInput> { @override void didUpdateWidget(_LUITextInput oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.autofocus != widget.autofocus) { + widget.focusNode.requestFocus(); + } if (_controller.text != widget.text) { _controller.value = TextEditingValue( text: widget.text, @@ -5999,6 +6076,7 @@ final class _LUITextInputState extends State<_LUITextInput> { controller: _controller, enabled: widget.enabled, autofocus: widget.autofocus, + focusNode: widget.focusNode, keyboardType: widget.multiline ? TextInputType.multiline : TextInputType.text, diff --git a/platform/flutter/test/lui_flutter_backend_test.dart b/platform/flutter/test/lui_flutter_backend_test.dart index bd11546e..e5fb537d 100644 --- a/platform/flutter/test/lui_flutter_backend_test.dart +++ b/platform/flutter/test/lui_flutter_backend_test.dart @@ -3692,6 +3692,60 @@ void main() { ); expect(backend.generation, 1); }); + testWidgets('dropping a sheet node refreshes the semantics tree', ( + tester, + ) async { + final backend = LUIFlutterBackend() + ..applyJson(''' + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"sheet"}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"set-prop","id":2,"property":"text","value":"Add sync graph"}, + {"op":"set-prop","id":2,"property":"accessibility-identifier","value":"sheet.graph-create"}, + {"op":"set-prop","id":3,"property":"text","value":"Sheet content"}, + {"op":"set-prop","id":3,"property":"accessibility-identifier","value":"text.in-sheet"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]} + '''); + + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: backend.widget(node: 1))), + ); + await tester.pumpAndSettle(); + final semanticsHandle = tester.ensureSemantics(); + await tester.pump(); + + expect(find.byType(BottomSheet), findsOneWidget); + expect( + _collectSemanticsIdentifiers(tester), + contains('text.in-sheet'), + ); + + backend.applyJson(''' + {"generation":2,"ops":[ + {"op":"create-node","id":4,"kind":"button"}, + {"op":"set-prop","id":4,"property":"text","value":"Sidebar"}, + {"op":"set-prop","id":4,"property":"accessibility-identifier","value":"button.sidebar"}, + {"op":"remove-child","parent":2,"child":3}, + {"op":"remove-child","parent":1,"child":2}, + {"op":"drop-node","id":3}, + {"op":"drop-node","id":2}, + {"op":"insert-child","parent":1,"child":4,"index":0} + ]} + '''); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(find.byType(BottomSheet), findsNothing); + final identifiers = _collectSemanticsIdentifiers(tester); + expect(identifiers, isNot(contains('sheet.graph-create'))); + expect(identifiers, isNot(contains('text.in-sheet'))); + expect(identifiers, contains('button.sidebar')); + semanticsHandle.dispose(); + }); + } const _initialBatch = ''' @@ -3707,3 +3761,11 @@ const _initialBatch = ''' {"op":"insert-child","parent":1,"child":3,"index":1} ]} '''; + +Set _collectSemanticsIdentifiers(WidgetTester tester) { + return tester.semantics + .simulatedAccessibilityTraversal(view: tester.view) + .map((node) => node.identifier) + .toSet(); +} + diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8820fb6e..8e92d688 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -1176,6 +1176,8 @@ bool LuiQmlBackend::performPress(qint64 node) { isTrue(state->properties.value(QStringLiteral("press-enabled")))) || (state->kind == NodeKind::TableCell && isTrue(state->properties.value(QStringLiteral("press-enabled")))) || + (state->kind == NodeKind::Column && + isTrue(state->properties.value(QStringLiteral("press-enabled")))) || (state->kind == NodeKind::Text && isTrue(state->properties.value(QStringLiteral("press-enabled")))); if (!pressable || isFalse(state->properties.value(QStringLiteral("enabled")))) { diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index c4c874d1..38ee30c0 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -243,8 +243,8 @@ bool childKindSupported(NodeKind parent, NodeKind child) { bool eventSupported(NodeKind kind, Event event) { switch (event) { case Event::Press: - return oneOf(kind, {NodeKind::Button, NodeKind::Radio, NodeKind::Select, - NodeKind::Combobox, NodeKind::MenuItem, + return oneOf(kind, {NodeKind::Button, NodeKind::Column, NodeKind::Radio, + NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Text, NodeKind::TableCell, NodeKind::TimelineItem, NodeKind::BottomTab}); @@ -508,8 +508,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { return kind == NodeKind::Radio || kind == NodeKind::Drawer || treeRowKind(kind); case Property::PressEnabled: - return oneOf(kind, {NodeKind::Text, NodeKind::Radio, NodeKind::Select, - NodeKind::Combobox, NodeKind::MenuItem, + return oneOf(kind, {NodeKind::Text, NodeKind::Column, NodeKind::Radio, + NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, NodeKind::BottomTab}) || treeRowKind(kind); diff --git a/platform/qt/qml/LuiColumn.qml b/platform/qt/qml/LuiColumn.qml index 2192c297..1e63c613 100644 --- a/platform/qt/qml/LuiColumn.qml +++ b/platform/qt/qml/LuiColumn.qml @@ -3,31 +3,47 @@ import QtQuick.Layouts import "LuiStyle.js" as Style // Wire kind: column — vertical layout honoring gap, main-alignment, and -// cross-alignment. -ColumnLayout { +// cross-alignment. Tappable when press-enabled; taps on interactive +// children still reach the child (the tap layer sits below the content). +Item { id: col required property var node readonly property var props: node ? node.properties : ({}) - spacing: Style.num(props, "gap", 0) + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight - readonly property bool _anyFillH: Style.anyFillMain(colRep, false) + MouseArea { + anchors.fill: parent + z: -1 + enabled: col.props["press-enabled"] === true + onClicked: col.node.press() + } + + ColumnLayout { + id: layout + anchors.fill: parent + + spacing: Style.num(col.props, "gap", 0) + + readonly property bool _anyFillH: Style.anyFillMain(colRep, false) - Item { Layout.fillHeight: true; visible: Style.needsLeadFiller(props) && !_anyFillH } - Repeater { - id: colRep - model: node ? node.children : [] - delegate: LuiNodeView { - required property var modelData - node: modelData - Layout.fillHeight: Style.fillMainHeight(node ? node.properties : ({})) || - Style.mainAlign(col.props) === "space_between" || - contentFillsLayout - Layout.fillWidth: Style.fillMainWidth(node ? node.properties : ({})) || - Style.stretchCross(col.props) || - contentFillsLayout - Layout.alignment: Style.crossAlignmentEnum(col.props, false) + Item { Layout.fillHeight: true; visible: Style.needsLeadFiller(col.props) && !layout._anyFillH } + Repeater { + id: colRep + model: col.node ? col.node.children : [] + delegate: LuiNodeView { + required property var modelData + node: modelData + Layout.fillHeight: Style.fillMainHeight(node ? node.properties : ({})) || + Style.mainAlign(col.props) === "space_between" || + contentFillsLayout + Layout.fillWidth: Style.fillMainWidth(node ? node.properties : ({})) || + Style.stretchCross(col.props) || + contentFillsLayout + Layout.alignment: Style.crossAlignmentEnum(col.props, false) + } } + Item { Layout.fillHeight: true; visible: Style.needsTrailFiller(col.props) && !layout._anyFillH } } - Item { Layout.fillHeight: true; visible: Style.needsTrailFiller(props) && !_anyFillH } } diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index 3d9572ec..f061a4b2 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -1347,6 +1347,9 @@ public void PerformAction(long node) (state.Kind == LUINodeKind.TableCell && LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)) || + (state.Kind == LUINodeKind.Column && + LUISchema.TrueProperty( + state.Properties, LUIProperty.PressEnabled)) || (state.Kind == LUINodeKind.Text && LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)); diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 1082d064..69449b55 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -146,6 +146,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) switch (kind) { case LUINodeKind.Button: + case LUINodeKind.Column: case LUINodeKind.Radio: case LUINodeKind.Select: case LUINodeKind.Combobox: @@ -605,6 +606,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Drawer || TreeRowKind(kind); case LUIProperty.PressEnabled: return kind == LUINodeKind.Text || + kind == LUINodeKind.Column || kind == LUINodeKind.Radio || kind == LUINodeKind.Select || kind == LUINodeKind.Combobox || diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 402a8d5f..ebdfeea7 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -296,6 +296,7 @@ let event_supported kind event = | Press _ -> (match kind with | Button + | Column | Radio | Select | Combobox @@ -683,6 +684,7 @@ let common_property_supported kind property = | ToggleEnabled -> kind = Radio || kind = Drawer || tree_row_kind kind | PressEnabled -> kind = Text + || kind = Column || kind = Radio || kind = Select || kind = Combobox