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:
2026-05-06 15:20:57 +02:00
co-authored by Claude
parent aa742eed79
commit 96c6cfc6c5
3 changed files with 159 additions and 27 deletions
+79
View File
@@ -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(