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
66 changes: 54 additions & 12 deletions lib/core/motion/querya_switching_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class QueryaSwitchingBody extends StatelessWidget {
}
}

class _SwitchingLayer extends StatelessWidget {
class _SwitchingLayer extends StatefulWidget {
const _SwitchingLayer({
required this.active,
required this.duration,
Expand All @@ -68,36 +68,78 @@ class _SwitchingLayer extends StatelessWidget {
final Offset slide;
final Widget child;

@override
State<_SwitchingLayer> createState() => _SwitchingLayerState();
}

class _SwitchingLayerState extends State<_SwitchingLayer> {
late bool _offstage;

@override
void initState() {
super.initState();
_offstage = !widget.active;
}

@override
void didUpdateWidget(covariant _SwitchingLayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active) {
_offstage = false;
} else if (oldWidget.active && !widget.active) {
if (widget.duration == Duration.zero) {
_offstage = true;
}
}
}

void _handleOpacityEnd() {
if (!mounted) return;
if (!widget.active && !_offstage) {
setState(() {
_offstage = true;
});
}
}

@override
Widget build(BuildContext context) {
final curve = active ? inCurve : outCurve;
final curve = widget.active ? widget.inCurve : widget.outCurve;
// Isolate paint; pause child tickers when inactive (opacity anim still runs).
// Inactive child is offstaged once the exit transition completes to avoid
// redundant layout passes during desktop window resizing (#901).
final content = TickerMode(
enabled: active,
child: RepaintBoundary(child: child),
enabled: widget.active,
child: RepaintBoundary(
child: Offstage(
offstage: _offstage,
child: widget.child,
),
),
);
Widget layer = AnimatedOpacity(
opacity: active ? 1 : 0,
duration: duration,
opacity: widget.active ? 1 : 0,
duration: widget.duration,
curve: curve,
onEnd: _handleOpacityEnd,
child: content,
);

if (slide != Offset.zero) {
if (widget.slide != Offset.zero) {
layer = AnimatedSlide(
offset: active ? Offset.zero : slide,
duration: duration,
offset: widget.active ? Offset.zero : widget.slide,
duration: widget.duration,
curve: curve,
child: layer,
);
}

return IgnorePointer(
ignoring: !active,
ignoring: !widget.active,
child: ExcludeFocus(
excluding: !active,
excluding: !widget.active,
child: ExcludeSemantics(
excluding: !active,
excluding: !widget.active,
child: layer,
),
),
Expand Down
196 changes: 195 additions & 1 deletion test/core/motion/querya_switching_body_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ void main() {
);

expect(find.byKey(const Key('a')), findsOneWidget);
expect(find.byKey(const Key('b')), findsOneWidget);
// Inactive child b is kept mounted (skipOffstage: false) but offstaged (skipOffstage: true).
expect(find.byKey(const Key('b'), skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('b')), findsNothing);

focusB.requestFocus();
await tester.pump();
Expand All @@ -55,6 +57,10 @@ void main() {
);
await tester.pumpAndSettle();

expect(find.byKey(const Key('b')), findsOneWidget);
expect(find.byKey(const Key('a'), skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('a')), findsNothing);

focusB.requestFocus();
await tester.pump();
expect(focusB.hasFocus, isTrue);
Expand Down Expand Up @@ -310,6 +316,194 @@ void main() {
expect(excludes.first.excluding, isFalse);
expect(excludes.last.excluding, isTrue);
});

testWidgets(
'inactive layer remains onstage during exit transition and offstages on completion',
(tester) async {
await tester.pumpWidget(
wrap(
const QueryaSwitchingBody(
index: 0,
children: [
Text('pane-0', key: Key('pane-0')),
Text('pane-1', key: Key('pane-1')),
],
),
),
);
await tester.pumpAndSettle();

// Initially pane-0 is onstage, pane-1 is offstage
expect(find.byKey(const Key('pane-0')), findsOneWidget);
expect(find.byKey(const Key('pane-1')), findsNothing);
expect(find.byKey(const Key('pane-1'), skipOffstage: false), findsOneWidget);

// Switch to index 1
await tester.pumpWidget(
wrap(
const QueryaSwitchingBody(
index: 1,
children: [
Text('pane-0', key: Key('pane-0')),
Text('pane-1', key: Key('pane-1')),
],
),
),
);

// Advance halfway through exit animation (e.g. 50ms)
await tester.pump(const Duration(milliseconds: 50));

// Both panes must be onstage during transition
expect(find.byKey(const Key('pane-0')), findsOneWidget);
expect(find.byKey(const Key('pane-1')), findsOneWidget);

// Settle transition
await tester.pumpAndSettle();

// Now pane-0 is offstaged, pane-1 is active
expect(find.byKey(const Key('pane-0')), findsNothing);
expect(find.byKey(const Key('pane-0'), skipOffstage: false), findsOneWidget);
expect(find.byKey(const Key('pane-1')), findsOneWidget);
});

testWidgets(
'inactive layer configures Offstage(offstage: true) once exit transition completes',
(tester) async {
await tester.pumpWidget(
wrap(
const QueryaSwitchingBody(
index: 0,
children: [
Text('A', key: Key('child-a')),
Text('B', key: Key('child-b')),
],
),
),
);
await tester.pumpAndSettle();

final offstageFinder = find.descendant(
of: find.byType(QueryaSwitchingBody),
matching: find.byType(Offstage, skipOffstage: false),
);

final offstagesInitial = tester.widgetList<Offstage>(offstageFinder).toList();
expect(offstagesInitial.length, 2);
// Active child A is onstage
expect(offstagesInitial[0].offstage, isFalse);
// Inactive child B is offstaged
expect(offstagesInitial[1].offstage, isTrue);

// Switch to index 1
await tester.pumpWidget(
wrap(
const QueryaSwitchingBody(
index: 1,
children: [
Text('A', key: Key('child-a')),
Text('B', key: Key('child-b')),
],
),
),
);

// While animating, both are onstage
await tester.pump(const Duration(milliseconds: 50));
final offstagesAnimating =
tester.widgetList<Offstage>(offstageFinder).toList();
expect(offstagesAnimating[0].offstage, isFalse);
expect(offstagesAnimating[1].offstage, isFalse);

// Once settled, child A is offstaged, child B is onstage
await tester.pumpAndSettle();
final offstagesSettled =
tester.widgetList<Offstage>(offstageFinder).toList();
expect(offstagesSettled[0].offstage, isTrue);
expect(offstagesSettled[1].offstage, isFalse);
});

testWidgets('preserves ScrollController offset across index switches',
(tester) async {
final scrollController = ScrollController();
addTearDown(scrollController.dispose);

Widget buildApp(int index) {
return wrap(
QueryaSwitchingBody(
index: index,
children: [
ListView.builder(
controller: scrollController,
itemCount: 100,
itemBuilder: (context, i) => SizedBox(
height: 50,
child: Text('Item $i'),
),
),
const Center(child: Text('Other Pane')),
],
),
);
}

await tester.pumpWidget(buildApp(0));
await tester.pumpAndSettle();

scrollController.jumpTo(250.0);
await tester.pump();
expect(scrollController.offset, 250.0);

// Switch to index 1
await tester.pumpWidget(buildApp(1));
await tester.pumpAndSettle();
expect(find.text('Other Pane'), findsOneWidget);

// Switch back to index 0
await tester.pumpWidget(buildApp(0));
await tester.pumpAndSettle();

expect(scrollController.offset, 250.0);
});

testWidgets(
'preserves TextEditingController text and selection across index switches',
(tester) async {
final textController = TextEditingController(text: 'Initial Draft');
addTearDown(textController.dispose);

Widget buildApp(int index) {
return wrap(
QueryaSwitchingBody(
index: index,
children: [
TextField(
controller: textController,
),
const Center(child: Text('Other Pane')),
],
),
);
}

await tester.pumpWidget(buildApp(0));
await tester.pumpAndSettle();

textController.text = 'Modified query buffer';
textController.selection = const TextSelection.collapsed(offset: 8);
await tester.pump();

// Switch to index 1
await tester.pumpWidget(buildApp(1));
await tester.pumpAndSettle();

// Switch back to index 0
await tester.pumpWidget(buildApp(0));
await tester.pumpAndSettle();

expect(textController.text, 'Modified query buffer');
expect(textController.selection.baseOffset, 8);
});
}

class _CounterPane extends StatefulWidget {
Expand Down
4 changes: 2 additions & 2 deletions test/features/main_screen/workspace_panel_layout_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ void main() {
expect(find.byType(QueryaSwitchingBody), findsNWidgets(2));
expect(find.byType(QueryaFadeSlide), findsWidgets);
expect(find.byType(RedisExplorerView), findsOneWidget);
// Home stays keep-alive under SwitchingBody.
expect(find.byType(RedisView), findsOneWidget);
// Home stays keep-alive under SwitchingBody (offstaged once exit completes).
expect(find.byType(RedisView, skipOffstage: false), findsOneWidget);

await pumpWidgetWithSurfaceSize(
tester,
Expand Down
Loading