implement MultitabPane widget + controller (T-83)
MultitabController<T> is a Flutter-free ChangeNotifier owning the tab list, active selection, and reorder/close invariants: - pinned (non-reorderable) entries form barriers that other tabs cannot cross - non-closeable entries silently no-op on remove() so hosts don't need to gate the call site - closing the active tab falls right, then left, then to null - duplicate ids are rejected MultitabPane<T> is the widget shell: a horizontal tab strip followed by the active entry's body. Active tab gets the panelHeader background and a panelActiveBorder top accent; inactive tabs blend into the tab bar. Close × is hidden until hover. Add button only renders when onAddRequested is wired. Hosts route the user's add/close intent through callbacks so the widget stays domain-free — for the Claude pane, add will spawn a new tmux session and close will kill one. Drag-to-reorder is controller-side only for now (the gesture wiring lands with T-24). 19 controller tests + 9 widget tests. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"exported_at": "2026-05-06T10:04:42Z",
|
||||
"exported_at": "2026-05-06T10:12:42Z",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "D-1",
|
||||
|
||||
@@ -23,6 +23,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
history naturally.
|
||||
- Welcome screen Tips card — six common keybindings shown below the
|
||||
START / RECENT row when the viewport is tall enough.
|
||||
- `MultitabPane` widget + `MultitabController` for panes that host
|
||||
N runtime tab instances of the same kind. Generic over a payload
|
||||
type, supports pinned/non-closeable tabs (primary), drag-reorder
|
||||
(planned), close × on hover, and an optional `+` add button. Used
|
||||
by the Claude pane to render primary + secondaries.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// One tab inside a [MultitabPane]. The [payload] is the host-owned
|
||||
/// domain object the body builder renders (e.g. a Claude session ref,
|
||||
/// an editor buffer ref).
|
||||
@immutable
|
||||
class MultitabEntry<T> {
|
||||
const MultitabEntry({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.payload,
|
||||
this.closeable = true,
|
||||
this.reorderable = true,
|
||||
});
|
||||
|
||||
/// Stable id for this tab. Must be unique within the controller.
|
||||
final String id;
|
||||
|
||||
/// Display label shown in the tab strip.
|
||||
final String title;
|
||||
|
||||
/// Domain object the host's body builder consumes.
|
||||
final T payload;
|
||||
|
||||
/// Whether the user can close this tab. Set to `false` for tabs
|
||||
/// the host considers permanent (e.g. the primary Claude pane).
|
||||
final bool closeable;
|
||||
|
||||
/// Whether the user can drag this tab to a new position. Pinned
|
||||
/// tabs (e.g. the primary) keep their position and form a barrier:
|
||||
/// reorderable tabs cannot move past a pinned tab on either side.
|
||||
final bool reorderable;
|
||||
|
||||
MultitabEntry<T> copyWith({
|
||||
String? title,
|
||||
T? payload,
|
||||
bool? closeable,
|
||||
bool? reorderable,
|
||||
}) {
|
||||
return MultitabEntry<T>(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
payload: payload ?? this.payload,
|
||||
closeable: closeable ?? this.closeable,
|
||||
reorderable: reorderable ?? this.reorderable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the entries and active selection for a [MultitabPane].
|
||||
/// Hosts seed the controller and route the user's add/close/reorder
|
||||
/// gestures back through it.
|
||||
class MultitabController<T> extends ChangeNotifier {
|
||||
MultitabController({List<MultitabEntry<T>> initial = const []}) {
|
||||
for (final e in initial) {
|
||||
_checkUniqueId(e.id);
|
||||
_entries.add(e);
|
||||
}
|
||||
if (_entries.isNotEmpty) _activeId = _entries.first.id;
|
||||
}
|
||||
|
||||
final List<MultitabEntry<T>> _entries = [];
|
||||
String? _activeId;
|
||||
|
||||
/// Read-only view of current entries in display order.
|
||||
List<MultitabEntry<T>> get entries => List.unmodifiable(_entries);
|
||||
|
||||
/// Currently-active entry, or null when there are no entries.
|
||||
MultitabEntry<T>? get active {
|
||||
final id = _activeId;
|
||||
if (id == null) return null;
|
||||
for (final e in _entries) {
|
||||
if (e.id == id) return e;
|
||||
}
|
||||
return _entries.isEmpty ? null : _entries.first;
|
||||
}
|
||||
|
||||
String? get activeId => active?.id;
|
||||
|
||||
int get length => _entries.length;
|
||||
bool get isEmpty => _entries.isEmpty;
|
||||
bool get isNotEmpty => _entries.isNotEmpty;
|
||||
|
||||
/// Insert [entry] at the end. If [activate] is true (default), make
|
||||
/// it the active tab.
|
||||
void add(MultitabEntry<T> entry, {bool activate = true}) {
|
||||
_checkUniqueId(entry.id);
|
||||
_entries.add(entry);
|
||||
if (activate || _activeId == null) _activeId = entry.id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Insert [entry] at [index] (clamped into range).
|
||||
void insert(int index, MultitabEntry<T> entry, {bool activate = true}) {
|
||||
_checkUniqueId(entry.id);
|
||||
final at = index.clamp(0, _entries.length);
|
||||
_entries.insert(at, entry);
|
||||
if (activate || _activeId == null) _activeId = entry.id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove the entry with [id]. If it was active, activation falls
|
||||
/// to the entry immediately to its right, then to its left, then
|
||||
/// to null (empty controller).
|
||||
///
|
||||
/// Silently no-ops if the id isn't found or the entry is not
|
||||
/// closeable. Hosts that want to bypass closeable should remove
|
||||
/// the entry by replacing it via [replace] first.
|
||||
void remove(String id) {
|
||||
final index = _entries.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
if (!_entries[index].closeable) return;
|
||||
final wasActive = _activeId == id;
|
||||
_entries.removeAt(index);
|
||||
if (wasActive) {
|
||||
if (_entries.isEmpty) {
|
||||
_activeId = null;
|
||||
} else {
|
||||
final fallback = index < _entries.length ? index : _entries.length - 1;
|
||||
_activeId = _entries[fallback].id;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Replace the entry with [id] in place. Used when the host needs
|
||||
/// to update title or payload without disturbing position or
|
||||
/// active selection.
|
||||
void replace(String id, MultitabEntry<T> next) {
|
||||
final index = _entries.indexWhere((e) => e.id == id);
|
||||
if (index < 0) return;
|
||||
if (next.id != id) {
|
||||
throw StateError('replace() must keep the same id (got "${next.id}", expected "$id")');
|
||||
}
|
||||
_entries[index] = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Make [id] the active tab. No-op if the id isn't present.
|
||||
void activate(String id) {
|
||||
if (_activeId == id) return;
|
||||
if (!_entries.any((e) => e.id == id)) return;
|
||||
_activeId = id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Move the entry with [id] to [newIndex]. Pinned (`reorderable:
|
||||
/// false`) entries form barriers that cannot be crossed: a
|
||||
/// reorderable tab cannot be dropped before a pinned tab that
|
||||
/// currently sits to its left, and a pinned tab itself cannot move.
|
||||
void reorder(String id, int newIndex) {
|
||||
final from = _entries.indexWhere((e) => e.id == id);
|
||||
if (from < 0) return;
|
||||
if (!_entries[from].reorderable) return;
|
||||
|
||||
var to = newIndex.clamp(0, _entries.length - 1);
|
||||
if (to == from) return;
|
||||
|
||||
// Enforce pinned barriers. The lowest legal index is one past
|
||||
// the last pinned entry to the left; the highest is one before
|
||||
// the first pinned entry to the right.
|
||||
var minIndex = 0;
|
||||
for (var i = 0; i < _entries.length; i++) {
|
||||
if (i == from) continue;
|
||||
if (!_entries[i].reorderable && i < from) minIndex = i + 1;
|
||||
}
|
||||
var maxIndex = _entries.length - 1;
|
||||
for (var i = _entries.length - 1; i >= 0; i--) {
|
||||
if (i == from) continue;
|
||||
if (!_entries[i].reorderable && i > from) maxIndex = i - 1;
|
||||
}
|
||||
to = to.clamp(minIndex, maxIndex);
|
||||
if (to == from) return;
|
||||
|
||||
final entry = _entries.removeAt(from);
|
||||
_entries.insert(to, entry);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Activate the next entry to the right of the active one,
|
||||
/// wrapping at the end. No-op if fewer than 2 entries.
|
||||
void activateNext() => _step(1);
|
||||
|
||||
/// Activate the previous entry, wrapping at the start.
|
||||
void activatePrev() => _step(-1);
|
||||
|
||||
void _step(int delta) {
|
||||
if (_entries.length < 2) return;
|
||||
final id = _activeId;
|
||||
final from = id == null ? 0 : _entries.indexWhere((e) => e.id == id);
|
||||
final next = (from + delta) % _entries.length;
|
||||
final wrapped = next < 0 ? next + _entries.length : next;
|
||||
_activeId = _entries[wrapped].id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _checkUniqueId(String id) {
|
||||
if (_entries.any((e) => e.id == id)) {
|
||||
throw StateError('MultitabController already contains entry id "$id"');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/multitab_controller.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef MultitabBuilder<T> = Widget Function(BuildContext context, MultitabEntry<T> entry);
|
||||
typedef MultitabEntryCallback<T> = void Function(MultitabEntry<T> entry);
|
||||
|
||||
/// A pane shell that hosts N runtime tabs of the same kind. Routes
|
||||
/// the user's add / close / reorder / activate gestures back to the
|
||||
/// host via the [controller] and the optional callbacks.
|
||||
///
|
||||
/// The widget is generic and domain-free: it never knows what's
|
||||
/// inside a tab. Hosts pick `T` and decide what add / close mean
|
||||
/// (e.g. spawning or killing a tmux session for the Claude pane).
|
||||
///
|
||||
/// See `docs/design/multitab-pane.md` for the design rationale.
|
||||
class MultitabPane<T> extends StatelessWidget {
|
||||
const MultitabPane({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.bodyBuilder,
|
||||
this.onCloseRequested,
|
||||
this.onAddRequested,
|
||||
this.allowReorder = true,
|
||||
this.tabHeight = 28,
|
||||
});
|
||||
|
||||
final MultitabController<T> controller;
|
||||
final MultitabBuilder<T> bodyBuilder;
|
||||
final MultitabEntryCallback<T>? onCloseRequested;
|
||||
final VoidCallback? onAddRequested;
|
||||
final bool allowReorder;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final active = controller.active;
|
||||
return Column(
|
||||
children: [
|
||||
_TabStrip<T>(
|
||||
controller: controller,
|
||||
onCloseRequested: onCloseRequested,
|
||||
onAddRequested: onAddRequested,
|
||||
allowReorder: allowReorder,
|
||||
tabHeight: tabHeight,
|
||||
),
|
||||
Expanded(
|
||||
child: active == null
|
||||
? const SizedBox.expand()
|
||||
: KeyedSubtree(
|
||||
// Key by id so swapping the active tab rebuilds
|
||||
// body state cleanly instead of mutating in place.
|
||||
key: ValueKey('multitab-body-${active.id}'),
|
||||
child: bodyBuilder(context, active),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabStrip<T> extends StatelessWidget {
|
||||
const _TabStrip({
|
||||
required this.controller,
|
||||
required this.onCloseRequested,
|
||||
required this.onAddRequested,
|
||||
required this.allowReorder,
|
||||
required this.tabHeight,
|
||||
});
|
||||
|
||||
final MultitabController<T> controller;
|
||||
final MultitabEntryCallback<T>? onCloseRequested;
|
||||
final VoidCallback? onAddRequested;
|
||||
final bool allowReorder;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final entries = controller.entries;
|
||||
final activeId = controller.activeId;
|
||||
|
||||
return Container(
|
||||
height: tabHeight,
|
||||
color: tokens.tabBarBackground,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final entry in entries)
|
||||
_Tab<T>(
|
||||
entry: entry,
|
||||
active: entry.id == activeId,
|
||||
onSelect: () => controller.activate(entry.id),
|
||||
onClose: entry.closeable
|
||||
? () {
|
||||
if (onCloseRequested != null) {
|
||||
onCloseRequested!(entry);
|
||||
} else {
|
||||
controller.remove(entry.id);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
tabHeight: tabHeight,
|
||||
),
|
||||
if (onAddRequested != null)
|
||||
_AddButton(onTap: onAddRequested!, tabHeight: tabHeight),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab<T> extends StatefulWidget {
|
||||
const _Tab({
|
||||
required this.entry,
|
||||
required this.active,
|
||||
required this.onSelect,
|
||||
required this.onClose,
|
||||
required this.tabHeight,
|
||||
});
|
||||
|
||||
final MultitabEntry<T> entry;
|
||||
final bool active;
|
||||
final VoidCallback onSelect;
|
||||
final VoidCallback? onClose;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
State<_Tab<T>> createState() => _TabState<T>();
|
||||
}
|
||||
|
||||
class _TabState<T> extends State<_Tab<T>> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final fg = widget.active ? tokens.tabActiveForeground : tokens.tabInactiveForeground;
|
||||
// Active tabs sit on the elevated chrome surface (panelHeader);
|
||||
// inactive tabs blend into the tab bar.
|
||||
final bg = widget.active ? tokens.panelHeader : tokens.tabBarBackground;
|
||||
final border = widget.active ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: widget.active,
|
||||
label: widget.entry.title,
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
onTap: widget.onSelect,
|
||||
builder: (context, _, __) => Container(
|
||||
constraints: BoxConstraints(minWidth: 96, maxWidth: 200),
|
||||
height: widget.tabHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(
|
||||
top: BorderSide(color: border, width: widget.active ? 1.5 : 0),
|
||||
left: BorderSide(color: tokens.panelBorder),
|
||||
right: BorderSide(color: tokens.panelBorder),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
widget.entry.title,
|
||||
fontSize: 12,
|
||||
color: fg,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (widget.onClose != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Opacity(
|
||||
opacity: _hovered || widget.active ? 1.0 : 0.0,
|
||||
child: ClideTappable(
|
||||
onTap: widget.onClose,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: ClideText('×',
|
||||
fontSize: 14, color: tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddButton extends StatelessWidget {
|
||||
const _AddButton({required this.onTap, required this.tabHeight});
|
||||
final VoidCallback onTap;
|
||||
final double tabHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'New tab',
|
||||
excludeSemantics: true,
|
||||
child: ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 28,
|
||||
height: tabHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
),
|
||||
child: ClideText('+',
|
||||
fontSize: 14,
|
||||
color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export 'src/clide_scrollbar.dart';
|
||||
export 'src/clide_spine.dart';
|
||||
export 'src/clide_surface.dart';
|
||||
export 'src/clide_tab_bar.dart';
|
||||
export 'src/multitab_controller.dart';
|
||||
export 'src/multitab_pane.dart';
|
||||
export 'src/clide_tappable.dart';
|
||||
export 'src/clide_text.dart';
|
||||
export 'src/clide_tooltip.dart';
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:clide/widgets/src/multitab_controller.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
|
||||
return MultitabEntry<String>(
|
||||
id: id,
|
||||
title: id,
|
||||
payload: id,
|
||||
closeable: closeable,
|
||||
reorderable: reorderable,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('MultitabController', () {
|
||||
test('starts empty when no initial entries', () {
|
||||
final c = MultitabController<String>();
|
||||
expect(c.entries, isEmpty);
|
||||
expect(c.active, isNull);
|
||||
});
|
||||
|
||||
test('seeds from initial and activates the first', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
expect(c.entries.map((e) => e.id), ['a', 'b']);
|
||||
expect(c.activeId, 'a');
|
||||
});
|
||||
|
||||
test('add appends and activates by default', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
c.add(entry('b'));
|
||||
expect(c.entries.map((e) => e.id), ['a', 'b']);
|
||||
expect(c.activeId, 'b');
|
||||
});
|
||||
|
||||
test('add with activate:false keeps prior selection', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
c.add(entry('b'), activate: false);
|
||||
expect(c.activeId, 'a');
|
||||
});
|
||||
|
||||
test('insert places at index and clamps out-of-range', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('c')]);
|
||||
c.insert(1, entry('b'));
|
||||
expect(c.entries.map((e) => e.id), ['a', 'b', 'c']);
|
||||
c.insert(99, entry('d'));
|
||||
expect(c.entries.last.id, 'd');
|
||||
});
|
||||
|
||||
test('duplicate ids are rejected', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
expect(() => c.add(entry('a')), throwsStateError);
|
||||
});
|
||||
|
||||
test('remove activates the right neighbour, then left', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('c')]);
|
||||
c.activate('b');
|
||||
c.remove('b');
|
||||
expect(c.entries.map((e) => e.id), ['a', 'c']);
|
||||
expect(c.activeId, 'c');
|
||||
|
||||
c.remove('c');
|
||||
expect(c.activeId, 'a');
|
||||
});
|
||||
|
||||
test('remove leaves activeId null when emptied', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
c.remove('a');
|
||||
expect(c.entries, isEmpty);
|
||||
expect(c.activeId, isNull);
|
||||
});
|
||||
|
||||
test('remove no-ops on non-closeable entries', () {
|
||||
final c = MultitabController<String>(initial: [entry('p', closeable: false), entry('s')]);
|
||||
c.remove('p');
|
||||
expect(c.entries.map((e) => e.id), ['p', 's']);
|
||||
});
|
||||
|
||||
test('activate switches selection', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
c.activate('b');
|
||||
expect(c.activeId, 'b');
|
||||
});
|
||||
|
||||
test('activate ignores unknown ids', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
c.activate('zzz');
|
||||
expect(c.activeId, 'a');
|
||||
});
|
||||
|
||||
test('reorder moves a tab to a new index', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('c')]);
|
||||
c.reorder('a', 2);
|
||||
expect(c.entries.map((e) => e.id), ['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
test('reorder no-ops on non-reorderable entries', () {
|
||||
final c = MultitabController<String>(initial: [
|
||||
entry('p', reorderable: false),
|
||||
entry('a'),
|
||||
]);
|
||||
c.reorder('p', 1);
|
||||
expect(c.entries.map((e) => e.id), ['p', 'a']);
|
||||
});
|
||||
|
||||
test('reorder cannot move a tab past a pinned barrier', () {
|
||||
final c = MultitabController<String>(initial: [
|
||||
entry('p', reorderable: false),
|
||||
entry('a'),
|
||||
entry('b'),
|
||||
]);
|
||||
// 'a' tries to land at index 0 — blocked by pinned 'p'.
|
||||
c.reorder('a', 0);
|
||||
expect(c.entries.map((e) => e.id), ['p', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('reorder respects barriers on the right', () {
|
||||
final c = MultitabController<String>(initial: [
|
||||
entry('a'),
|
||||
entry('b'),
|
||||
entry('p', reorderable: false),
|
||||
]);
|
||||
// 'a' tries to land past pinned 'p' — clamped to before it.
|
||||
c.reorder('a', 2);
|
||||
expect(c.entries.map((e) => e.id), ['b', 'a', 'p']);
|
||||
});
|
||||
|
||||
test('activateNext / activatePrev wrap', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('c')]);
|
||||
c.activateNext();
|
||||
expect(c.activeId, 'b');
|
||||
c.activateNext();
|
||||
expect(c.activeId, 'c');
|
||||
c.activateNext();
|
||||
expect(c.activeId, 'a');
|
||||
c.activatePrev();
|
||||
expect(c.activeId, 'c');
|
||||
});
|
||||
|
||||
test('replace swaps entry without disturbing position or active', () {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
c.activate('b');
|
||||
c.replace('a', MultitabEntry<String>(id: 'a', title: 'A!', payload: 'A!'));
|
||||
expect(c.entries.first.title, 'A!');
|
||||
expect(c.activeId, 'b');
|
||||
});
|
||||
|
||||
test('replace rejects id changes', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
expect(
|
||||
() => c.replace('a', MultitabEntry<String>(id: 'b', title: 'b', payload: 'b')),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('notifies listeners on every mutation', () {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
var calls = 0;
|
||||
c.addListener(() => calls++);
|
||||
c.add(entry('b'));
|
||||
c.activate('a');
|
||||
c.reorder('b', 0);
|
||||
c.remove('b');
|
||||
expect(calls, 4);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/kernel_fixture.dart';
|
||||
import '../helpers/widget_harness.dart';
|
||||
|
||||
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
|
||||
return MultitabEntry<String>(
|
||||
id: id,
|
||||
title: id,
|
||||
payload: id,
|
||||
closeable: closeable,
|
||||
reorderable: reorderable,
|
||||
);
|
||||
}
|
||||
|
||||
Widget body(BuildContext _, MultitabEntry<String> e) =>
|
||||
SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
|
||||
|
||||
void main() {
|
||||
group('MultitabPane', () {
|
||||
late KernelFixture f;
|
||||
setUp(() async => f = await KernelFixture.create());
|
||||
tearDown(() => f.dispose());
|
||||
|
||||
testWidgets('renders one tab per entry and the active body', (tester) async {
|
||||
final c = MultitabController<String>(
|
||||
initial: [entry('primary', closeable: false), entry('s1'), entry('s2')],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
|
||||
expect(find.text('primary'), findsOneWidget);
|
||||
expect(find.text('s1'), findsOneWidget);
|
||||
expect(find.text('s2'), findsOneWidget);
|
||||
// Body of the active (first) tab is visible.
|
||||
expect(find.byKey(const ValueKey('body-primary')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('body-s1')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping a tab activates it and swaps the body', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('body-a')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('b'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(c.activeId, 'b');
|
||||
expect(find.byKey(const ValueKey('body-b')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('body-a')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('add button calls onAddRequested when set', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
var added = 0;
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
MultitabPane<String>(
|
||||
controller: c,
|
||||
bodyBuilder: body,
|
||||
onAddRequested: () => added++,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('New tab'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(added, 1);
|
||||
});
|
||||
|
||||
testWidgets('add button is absent when onAddRequested is null', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
expect(find.bySemanticsLabel('New tab'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('non-closeable tabs do not render a close glyph', (tester) async {
|
||||
final c = MultitabController<String>(
|
||||
initial: [entry('p', closeable: false), entry('s')],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
// A pinned tab has no close target; a closeable one does (it's
|
||||
// hidden via Opacity until hover, but still in the tree).
|
||||
// Two tabs total, one × glyph for 's'.
|
||||
expect(find.text('×'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('default close behavior removes the entry', (tester) async {
|
||||
final c = MultitabController<String>(
|
||||
initial: [entry('p', closeable: false), entry('s')],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('×'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(c.entries.map((e) => e.id), ['p']);
|
||||
});
|
||||
|
||||
testWidgets('onCloseRequested overrides default removal', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
|
||||
MultitabEntry<String>? closed;
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
f,
|
||||
MultitabPane<String>(
|
||||
controller: c,
|
||||
bodyBuilder: body,
|
||||
onCloseRequested: (e) => closed = e,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Both tabs are closeable; tap the first × encountered.
|
||||
await tester.tap(find.text('×').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(closed, isNotNull);
|
||||
// Host decided not to remove yet — entries are unchanged.
|
||||
expect(c.entries.length, 2);
|
||||
});
|
||||
|
||||
testWidgets('rebuilds when the controller notifies', (tester) async {
|
||||
final c = MultitabController<String>(initial: [entry('a')]);
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
expect(find.bySemanticsLabel('b'), findsNothing);
|
||||
|
||||
c.add(entry('b'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.bySemanticsLabel('b'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty controller renders no body', (tester) async {
|
||||
final c = MultitabController<String>();
|
||||
await tester.pumpWidget(
|
||||
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('body-a')), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user