MultitabPane: keepAlive mode and tab strip polish
Adds keepAlive: when true, all entry bodies stay mounted via IndexedStack so switching tabs doesn't tear down their state. Hosts that own PTY-backed sessions or any long-lived widget state opt in; callers that want fresh state on each switch use the default single-body mode. Polishes the tab strip itself for production use: - bottom divider so the strip visually anchors to the body below - Column.crossAxisAlignment.stretch so the strip fills the pane width instead of sizing to its content - close button: replace the text × glyph with the CloseIcon painter (clean cross strokes, font-independent) - two-column tab layout — Expanded title on the left, fixed 16x16 close button on the right; uniform 12px left padding, 6px right padding to match the 6px top/bottom breathing room around the close button Two new widget tests cover keepAlive (state preserved across switches) and default mode (inactive bodies disposed). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,33 @@ MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable
|
||||
Widget body(BuildContext _, MultitabEntry<String> e) =>
|
||||
SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
|
||||
|
||||
/// Stateful tap-counter body. Preserves a per-id count across rebuilds
|
||||
/// in a static map so the test can assert state survival across tab
|
||||
/// switches.
|
||||
class _CountingBody extends StatefulWidget {
|
||||
const _CountingBody({required this.id});
|
||||
final String id;
|
||||
|
||||
@override
|
||||
State<_CountingBody> createState() => _CountingBodyState();
|
||||
}
|
||||
|
||||
class _CountingBodyState extends State<_CountingBody> {
|
||||
static final Map<String, int> counts = {};
|
||||
|
||||
void _bump() => setState(() => counts[widget.id] = (counts[widget.id] ?? 0) + 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: ValueKey('counting-${widget.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _bump,
|
||||
child: const SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MultitabPane', () {
|
||||
late KernelFixture f;
|
||||
@@ -218,6 +245,58 @@ void main() {
|
||||
expect(c.entries.map((e) => e.id), ['p', 'a']);
|
||||
});
|
||||
|
||||
testWidgets('keepAlive: inactive bodies stay mounted (state preserved)', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
MultitabPane<String>(
|
||||
controller: c,
|
||||
bodyBuilder: (ctx, e) => _CountingBody(id: e.id),
|
||||
keepAlive: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Both bodies are in the tree (b is offstage in IndexedStack).
|
||||
expect(find.byKey(const ValueKey('counting-a')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('counting-b'), skipOffstage: false), findsOneWidget);
|
||||
|
||||
// Increment counter on body 'a' (it's the active one, visible).
|
||||
await tester.tap(find.byKey(const ValueKey('counting-a')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_CountingBodyState.counts['a'], 1);
|
||||
|
||||
// Switch to b. Body 'a' stays mounted; switch back and the
|
||||
// counter is preserved (would be 0 if 'a' had been rebuilt).
|
||||
await tester.tap(find.text('b'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('a'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_CountingBodyState.counts['a'], 1);
|
||||
});
|
||||
|
||||
testWidgets('default mode disposes inactive bodies', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
MultitabPane<String>(
|
||||
controller: c,
|
||||
bodyBuilder: (ctx, e) => _CountingBody(id: 'd-${e.id}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('counting-d-a')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('counting-d-b')), findsNothing);
|
||||
|
||||
await tester.tap(find.text('b'));
|
||||
await tester.pumpAndSettle();
|
||||
// a's body is gone from the tree; b's is in.
|
||||
expect(find.byKey(const ValueKey('counting-d-a')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('counting-d-b')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('allowReorder=false disables drag entirely', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
await tester.pumpWidget(
|
||||
|
||||
Reference in New Issue
Block a user