From aa742eed7995af271f98c0836505730361a91974 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 6 May 2026 14:17:16 +0200 Subject: [PATCH] MultitabPane: drag-to-reorder gesture wiring (T-84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tab is wrapped in a Draggable (when allowReorder is true and the entry itself is reorderable) and a DragTarget (always — the controller's barrier logic decides whether the move actually happens). Drops insert the dragged entry at the target tab's index. A 2px leading insertion indicator highlights the active drop target. The widget harness now wraps children in an Overlay so Draggable's feedback can mount without each test re-wrapping. Sized by the test view's bounds to avoid disturbing existing tests that query find.byType(SizedBox).first. Four widget tests cover the gesture path: drop reorders, pinned barrier blocks, pinned tabs aren't draggable, and allowReorder=false disables drag entirely. Co-Authored-By: Claude --- .pql/pql-plan.json | 31 +++++- CHANGELOG.md | 6 +- lib/widgets/src/multitab_pane.dart | 141 +++++++++++++++++++++++++-- test/helpers/widget_harness.dart | 14 ++- test/widgets/multitab_pane_test.dart | 91 +++++++++++++++++ 5 files changed, 266 insertions(+), 17 deletions(-) diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index c69b3030..d5df68b6 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T10:12:42Z", + "exported_at": "2026-05-06T12:17:16Z", "decisions": [ { "id": "D-1", @@ -2678,15 +2678,24 @@ "id": "T-83", "type": "task", "title": "design reusable sortable tab system for multitab panes", - "status": "in_progress", + "status": "done", "priority": "high", "created_at": "2026-05-06 09:17:39", - "updated_at": "2026-05-06 09:17:50" + "updated_at": "2026-05-06 10:12:48" + }, + { + "id": "T-84", + "type": "task", + "title": "MultitabPane: drag-to-reorder gesture wiring", + "status": "in_progress", + "priority": "medium", + "created_at": "2026-05-06 10:16:02", + "updated_at": "2026-05-06 10:17:57" } ], "ticket_deps": [ { - "blocker_id": "T-83", + "blocker_id": "T-84", "blocked_id": "T-24" } ], @@ -4033,6 +4042,20 @@ "old_value": "backlog", "new_value": "in_progress", "changed_at": "2026-05-06 09:17:50" + }, + { + "ticket_id": "T-83", + "field": "status", + "old_value": "in_progress", + "new_value": "done", + "changed_at": "2026-05-06 10:12:48" + }, + { + "ticket_id": "T-84", + "field": "status", + "old_value": "backlog", + "new_value": "in_progress", + "changed_at": "2026-05-06 10:17:57" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 366f5861..52ace974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. 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. + type, supports pinned/non-closeable tabs (primary), drag-reorder, + close × on hover, and an optional `+` add button. Used by the + Claude pane to render primary + secondaries. ### Changed diff --git a/lib/widgets/src/multitab_pane.dart b/lib/widgets/src/multitab_pane.dart index d77a50b3..e42122f4 100644 --- a/lib/widgets/src/multitab_pane.dart +++ b/lib/widgets/src/multitab_pane.dart @@ -94,20 +94,24 @@ class _TabStrip extends StatelessWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - for (final entry in entries) - _Tab( - entry: entry, - active: entry.id == activeId, - onSelect: () => controller.activate(entry.id), - onClose: entry.closeable + for (var i = 0; i < entries.length; i++) + _ReorderableTab( + entry: entries[i], + index: i, + active: entries[i].id == activeId, + allowReorder: allowReorder, + onSelect: () => controller.activate(entries[i].id), + onClose: entries[i].closeable ? () { if (onCloseRequested != null) { - onCloseRequested!(entry); + onCloseRequested!(entries[i]); } else { - controller.remove(entry.id); + controller.remove(entries[i].id); } } : null, + onReorderTo: (draggedId) => + controller.reorder(draggedId, i), tabHeight: tabHeight, ), if (onAddRequested != null) @@ -119,6 +123,127 @@ class _TabStrip extends StatelessWidget { } } +/// Wraps a [_Tab] with [Draggable] (when [allowReorder] is true and the +/// entry itself permits reorder) and [DragTarget] (always — the +/// controller's barrier logic decides whether a drop actually moves +/// the tab). Drop target inserts the dragged id at this tab's index. +class _ReorderableTab extends StatefulWidget { + const _ReorderableTab({ + required this.entry, + required this.index, + required this.active, + required this.allowReorder, + required this.onSelect, + required this.onClose, + required this.onReorderTo, + required this.tabHeight, + }); + + final MultitabEntry entry; + final int index; + final bool active; + final bool allowReorder; + final VoidCallback onSelect; + final VoidCallback? onClose; + final void Function(String draggedId) onReorderTo; + final double tabHeight; + + @override + State<_ReorderableTab> createState() => _ReorderableTabState(); +} + +class _ReorderableTabState extends State<_ReorderableTab> { + bool _isDropTarget = false; + + bool get _draggable => widget.allowReorder && widget.entry.reorderable; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + + final tabContent = _Tab( + entry: widget.entry, + active: widget.active, + onSelect: widget.onSelect, + onClose: widget.onClose, + tabHeight: widget.tabHeight, + ); + + Widget result = DragTarget( + onWillAcceptWithDetails: (d) { + if (d.data == widget.entry.id) return false; + return widget.allowReorder; + }, + onMove: (_) { + if (!_isDropTarget) setState(() => _isDropTarget = true); + }, + onLeave: (_) { + if (_isDropTarget) setState(() => _isDropTarget = false); + }, + onAcceptWithDetails: (d) { + setState(() => _isDropTarget = false); + widget.onReorderTo(d.data); + }, + builder: (context, _, __) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 2, + height: widget.tabHeight, + child: ColoredBox( + color: _isDropTarget ? tokens.panelActiveBorder : const Color(0x00000000), + ), + ), + tabContent, + ], + ), + ); + + if (_draggable) { + result = Draggable( + data: widget.entry.id, + axis: Axis.horizontal, + feedback: _DragFeedback( + title: widget.entry.title, + tabHeight: widget.tabHeight, + ), + childWhenDragging: Opacity(opacity: 0.4, child: tabContent), + child: result, + ); + } + + return result; + } +} + +class _DragFeedback extends StatelessWidget { + const _DragFeedback({required this.title, required this.tabHeight}); + final String title; + final double tabHeight; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Container( + height: tabHeight, + constraints: const BoxConstraints(minWidth: 96, maxWidth: 200), + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: tokens.panelHeader, + border: Border.all(color: tokens.panelActiveBorder), + ), + child: ClideText( + title, + fontSize: 12, + color: tokens.tabActiveForeground, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + } +} + class _Tab extends StatefulWidget { const _Tab({ required this.entry, diff --git a/test/helpers/widget_harness.dart b/test/helpers/widget_harness.dart index 326cb2b4..090df2a0 100644 --- a/test/helpers/widget_harness.dart +++ b/test/helpers/widget_harness.dart @@ -4,7 +4,13 @@ import 'package:flutter/widgets.dart'; import 'kernel_fixture.dart'; /// Wraps a widget in the minimum tree a primitive needs to resolve -/// theme + i18n: `Directionality → ClideKernel → ClideTheme → child`. +/// theme + i18n + Overlay (for Draggable feedback / Tooltip / etc.): +/// `Directionality → ClideKernel → ClideTheme → MediaQuery → +/// Overlay → child`. +/// +/// The Overlay is sized by the test view's bounds via the surrounding +/// MediaQuery; no extra SizedBox is added so existing tests that +/// query `find.byType(SizedBox).first` still find their target. Widget harness(KernelFixture fixture, Widget child) { return Directionality( textDirection: TextDirection.ltr, @@ -14,7 +20,11 @@ Widget harness(KernelFixture fixture, Widget child) { controller: fixture.services.theme, child: MediaQuery( data: const MediaQueryData(), - child: child, + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (_) => child), + ], + ), ), ), ), diff --git a/test/widgets/multitab_pane_test.dart b/test/widgets/multitab_pane_test.dart index 230cc8c1..fb3adfee 100644 --- a/test/widgets/multitab_pane_test.dart +++ b/test/widgets/multitab_pane_test.dart @@ -151,5 +151,96 @@ void main() { ); expect(find.byKey(const ValueKey('body-a')), findsNothing); }); + + testWidgets('drag a tab onto another to reorder', (tester) async { + final c = MultitabController(initial: [entry('a'), entry('b'), entry('c')]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + + // Drag tab 'a' to where tab 'c' sits. + final from = tester.getCenter(find.text('a')); + final to = tester.getCenter(find.text('c')); + final gesture = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(c.entries.map((e) => e.id), ['b', 'c', 'a']); + }); + + testWidgets('drag respects pinned barrier', (tester) async { + final c = MultitabController(initial: [ + entry('p', reorderable: false), + entry('a'), + entry('b'), + ]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + + // Try to drag 'a' before pinned 'p' — controller's barrier + // logic should reject and the order stays. + final from = tester.getCenter(find.text('a')); + final to = tester.getCenter(find.text('p')); + final gesture = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(c.entries.map((e) => e.id), ['p', 'a', 'b']); + }); + + testWidgets('pinned tabs are not draggable', (tester) async { + final c = MultitabController(initial: [ + entry('p', reorderable: false), + entry('a'), + ]); + await tester.pumpWidget( + harness(f, MultitabPane(controller: c, bodyBuilder: body)), + ); + + // Attempt to drag pinned 'p' to position of 'a'. + final from = tester.getCenter(find.text('p')); + final to = tester.getCenter(find.text('a')); + final gesture = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.up(); + await tester.pumpAndSettle(); + + // Order unchanged; pinned tab refused to be dragged. + expect(c.entries.map((e) => e.id), ['p', 'a']); + }); + + testWidgets('allowReorder=false disables drag entirely', (tester) async { + final c = MultitabController(initial: [entry('a'), entry('b')]); + await tester.pumpWidget( + harness( + f, + MultitabPane( + controller: c, + bodyBuilder: body, + allowReorder: false, + ), + ), + ); + + final from = tester.getCenter(find.text('a')); + final to = tester.getCenter(find.text('b')); + final gesture = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(c.entries.map((e) => e.id), ['a', 'b']); + }); }); }