Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
35 changes: 30 additions & 5 deletions platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -4754,6 +4757,7 @@ private struct LUIColumnView: View {
? .infinity : nil,
alignment: frameAlignment
)
.modifier(LUIColumnPressModifier(model: model, backend: backend))
}

@ViewBuilder
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 86 additions & 8 deletions platform/flutter/lib/lui_flutter_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,10 @@ final class LUIFlutterBackend {
// stranded in the theater (and removeRoute asserts in debug builds).
void _scheduleRouteRemoval(Route<void> 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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<int?>().firstWhere(
(childID) =>
Expand Down Expand Up @@ -1894,6 +1909,48 @@ final class LUIFlutterBackend {
);
}

Widget menuTrigger() {
final menuID = state.children.cast<int?>().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>[],
);
}

Widget listItem() => _LUIListItem(
enabled: enabled,
focusable: state.properties['role'] != 'treeitem',
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions platform/flutter/test/lui_flutter_backend_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '''
Expand All @@ -3707,3 +3761,11 @@ const _initialBatch = '''
{"op":"insert-child","parent":1,"child":3,"index":1}
]}
''';

Set<String?> _collectSemanticsIdentifiers(WidgetTester tester) {
return tester.semantics
.simulatedAccessibilityTraversal(view: tester.view)
.map((node) => node.identifier)
.toSet();
}

2 changes: 2 additions & 0 deletions platform/qt/lib/lui_qml_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")))) {
Expand Down
Loading
Loading