tab switcher reorder

This commit is contained in:
Fabian Freund
2026-05-26 10:57:33 +02:00
parent d976964387
commit a1e2680c48
3 changed files with 373 additions and 200 deletions
@@ -39,15 +39,20 @@ import 'package:weblibre/features/geckoview/features/browser/features/contextual
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_button_id.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_bar_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_context_menu_draggable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_item.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
@@ -506,8 +511,8 @@ class QuickTabSwitcher extends HookConsumerWidget {
(settings) => settings.effectiveUiQuickTabSwitcherMode(), (settings) => settings.effectiveUiQuickTabSwitcherMode(),
), ),
); );
final pinnedTabIds = ref.watch( final tabBarDirection = ref.watch(
watchPinnedTabIdsProvider.select((value) => value.value), generalSettingsWithDefaultsProvider.select((s) => s.tabBarDirection),
); );
final tabStates = ref.watch( final tabStates = ref.watch(
quickTabSwitcherTabStatesProvider(quickTabSwitcherMode), quickTabSwitcherTabStatesProvider(quickTabSwitcherMode),
@@ -518,14 +523,27 @@ class QuickTabSwitcher extends HookConsumerWidget {
.value; .value;
final sandboxCaptureMap = final sandboxCaptureMap =
ref.watch(sandboxCaptureMapProvider).value ?? const {}; ref.watch(sandboxCaptureMapProvider).value ?? const {};
final availableItems = tabStates.value // Reorder is only meaningful when the bar renders the user's actual tab
.map<QuickTabSwitcherItem>((state) { // order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency,
// so dragging would just snap back on the next tab switch.
final canManualReorder = ref.watch(canManualTabReorderProvider);
final sortPinnedFirst = ref.watch(
tabViewFilterControllerProvider.select((v) => v.sortPinnedFirst),
);
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
final reorderEnabled =
effectiveMode == QuickTabSwitcherMode.containerTabs &&
canManualReorder;
final tabItems = tabStates.value.map<QuickTabSwitcherItem>((state) {
final sandboxSourceUri = parseSandboxSource( final sandboxSourceUri = parseSandboxSource(
sandboxCaptureMap[state.$1.id], sandboxCaptureMap[state.$1.id],
); );
final displayUrl = sandboxSourceUri ?? state.$1.url; final displayUrl = sandboxSourceUri ?? state.$1.url;
final displayTitle = final displayTitle = sandboxSourceUri != null && state.$1.title.isEmpty
sandboxSourceUri != null && state.$1.title.isEmpty
? sandboxSourceUri.authority ? sandboxSourceUri.authority
: state.$1.titleOrAuthority; : state.$1.titleOrAuthority;
return QuickTabSwitcherItem( return QuickTabSwitcherItem(
@@ -535,16 +553,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
title: displayTitle, title: displayTitle,
tabMode: state.$1.tabMode, tabMode: state.$1.tabMode,
isHistory: false, isHistory: false,
isPinned: pinnedTabIds?.contains(state.$1.id) ?? false, isPinned: pinnedTabIds.contains(state.$1.id),
isSandbox: sandboxSourceUri != null, isSandbox: sandboxSourceUri != null,
url: displayUrl, url: displayUrl,
avatar: TabIcon(tabState: state.$1, iconSize: 20), avatar: TabIcon(tabState: state.$1, iconSize: 20),
); );
}) }).toList();
.followedBy( final historyItems = (historySuggestions ?? [])
(historySuggestions ?? []).map<QuickTabSwitcherItem>((state) { .map<QuickTabSwitcherItem>((state) {
final url = Uri.parse(state.url); final url = Uri.parse(state.url);
return QuickTabSwitcherItem( return QuickTabSwitcherItem(
color: null, color: null,
id: state.url, id: state.url,
@@ -556,11 +573,13 @@ class QuickTabSwitcher extends HookConsumerWidget {
url: url, url: url,
avatar: UrlIcon([url], iconSize: 20), avatar: UrlIcon([url], iconSize: 20),
); );
}), })
)
.toList(); .toList();
final availableItems = [...tabItems, ...historyItems];
final activeItem = availableItems.firstWhere( final activeItem = availableItems.isEmpty
? null
: availableItems.firstWhere(
(item) => item.isActive, (item) => item.isActive,
orElse: () => availableItems.first, orElse: () => availableItems.first,
); );
@@ -640,11 +659,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
}, },
child: QuickTabSwitcherView( child: QuickTabSwitcherView(
availableItems: availableItems, availableItems: availableItems,
activeItem: activeItem.isActive ? activeItem : null, reorderableItemCount: reorderEnabled ? tabItems.length : 0,
activeItem: (activeItem?.isActive ?? false) ? activeItem : null,
scrollController: chipScrollController, scrollController: chipScrollController,
activeItemKey: activeItemKey.value, activeItemKey: activeItemKey.value,
showTitles: showTitles, showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi, showIsolatedTabUi: showIsolatedTabUi,
enablePinTabInMenu:
effectiveMode == QuickTabSwitcherMode.containerTabs,
onSelected: (item) async { onSelected: (item) async {
if (!item.isHistory && item.isActive) { if (!item.isHistory && item.isActive) {
return; return;
@@ -661,33 +683,36 @@ class QuickTabSwitcher extends HookConsumerWidget {
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id); await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
} }
}, },
itemWrapBuilder: (child, item) { onReorderItem: !reorderEnabled
if (item.isHistory) { ? null
return child; : (oldIndex, newIndex) async {
if (oldIndex >= tabItems.length ||
newIndex > tabItems.length) {
return;
} }
final visibleItems = [
return TabMenu( for (final item in tabItems)
selectedTabId: item.id, TabViewItem.standalone(tabId: item.id),
enableFindInPage: false, ];
enableFetchFeeds: false, final result = buildTabViewReorderResult(
enableDesktopMode: false, visibleItems: visibleItems,
enableReaderMode: false, treeRows: const [],
enableReloadButton: false, collapsedGroups: const {},
enableNavigationButtons: false, pinnedTabIds: pinnedTabIds,
enableAddToHomeScreen: false, oldIndex: oldIndex,
enablePinTab: effectiveMode == QuickTabSwitcherMode.containerTabs, newIndex: newIndex,
builder: (context, controller, _) { tabListDirection: tabBarDirection,
return InkWell( hierarchical: false,
onLongPress: () { sortPinnedFirst: sortPinnedFirst,
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
); );
}, if (result == null) return;
await ref
.read(tabDataRepositoryProvider.notifier)
.reorderTabs(
movingTabIds: result.movingTabIds,
previousTabId: result.previousTabId,
nextTabId: result.nextTabId,
parentChange: result.parentChange,
); );
}, },
), ),
@@ -704,8 +729,10 @@ class QuickTabSwitcherView extends StatelessWidget {
this.activeItemKey, this.activeItemKey,
required this.showTitles, required this.showTitles,
required this.showIsolatedTabUi, required this.showIsolatedTabUi,
required this.enablePinTabInMenu,
required this.onSelected, required this.onSelected,
required this.itemWrapBuilder, this.onReorderItem,
this.reorderableItemCount = 0,
}); });
final List<QuickTabSwitcherItem> availableItems; final List<QuickTabSwitcherItem> availableItems;
@@ -714,13 +741,23 @@ class QuickTabSwitcherView extends StatelessWidget {
final GlobalKey? activeItemKey; final GlobalKey? activeItemKey;
final bool showTitles; final bool showTitles;
final bool showIsolatedTabUi; final bool showIsolatedTabUi;
final bool enablePinTabInMenu;
final Future<void> Function(QuickTabSwitcherItem item) onSelected; final Future<void> Function(QuickTabSwitcherItem item) onSelected;
final Widget Function(Widget child, QuickTabSwitcherItem item)
itemWrapBuilder; /// When non-null, the first [reorderableItemCount] items are rendered as a
/// horizontal `ReorderableListView` driven by this callback. Otherwise the
/// view falls back to the non-reorderable `SelectableChips` layout.
final void Function(int oldIndex, int newIndex)? onReorderItem;
/// Items at indices `< reorderableItemCount` are reorderable; items at
/// or after are appended as a static trailing row (e.g. history hints).
final int reorderableItemCount;
bool get _reorderEnabled =>
onReorderItem != null && reorderableItemCount > 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appColors = AppColors.of(context);
if (availableItems.isEmpty) { if (availableItems.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -730,8 +767,15 @@ class QuickTabSwitcherView extends StatelessWidget {
child: SizedBox( child: SizedBox(
height: 48, height: 48,
width: double.maxFinite, width: double.maxFinite,
child: child: _reorderEnabled
SelectableChips<QuickTabSwitcherItem, QuickTabSwitcherItem, String>( ? _buildReorderableList(context)
: _buildSelectableChips(context),
),
);
}
Widget _buildSelectableChips(BuildContext context) {
return SelectableChips<QuickTabSwitcherItem, QuickTabSwitcherItem, String>(
enableDelete: false, enableDelete: false,
sortSelectedFirst: false, sortSelectedFirst: false,
maxCount: null, maxCount: null,
@@ -741,27 +785,105 @@ class QuickTabSwitcherView extends StatelessWidget {
itemId: (item) => item.id, itemId: (item) => item.id,
selectedItem: activeItem, selectedItem: activeItem,
selectedBorderColor: Theme.of(context).colorScheme.primary, selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: SelectableChipDecoration( decoration: _chipDecoration(context),
itemLabel: (item) =>
_chipLabel(context, item, activeItem?.id == item.id),
itemAvatar: (item) => item.avatar,
onSelected: onSelected,
itemWrap: (child, item) => item.isHistory
? child
: _wrapWithMenu(itemId: item.id, child: child),
availableItems: availableItems,
);
}
Widget _buildReorderableList(BuildContext context) {
// History suggestions only appear when there are no tab items
// (see quickTabSwitcherHistorySuggestionsProvider), so reorder mode
// is mutually exclusive with the history trailing row in practice.
// Defensively cap the reorderable range anyway.
final reorderableCount = reorderableItemCount.clamp(
0,
availableItems.length,
);
return ReorderableListView.builder(
scrollController: scrollController,
scrollDirection: Axis.horizontal,
buildDefaultDragHandles: true,
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
itemCount: reorderableCount,
itemBuilder: (context, index) {
final item = availableItems[index];
final isSelected = activeItem?.id == item.id;
final chip = _ReorderableSwitcherChip(
item: item,
isSelected: isSelected,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: _chipDecoration(context),
label: _chipLabel(context, item, isSelected),
onTap: () => onSelected(item),
);
final keyedForActive = isSelected && activeItemKey != null
? KeyedSubtree(key: activeItemKey, child: chip)
: chip;
return KeyedSubtree(
key: ValueKey(item.id),
child: TabContextMenuDraggable(
tabId: item.id,
externalDrag: true,
enableCloseTab: true,
feedbackSize: Size.zero,
child: keyedForActive,
),
);
},
onReorderItem: onReorderItem,
);
}
Widget _wrapWithMenu({required String itemId, required Widget child}) {
return TabMenu(
selectedTabId: itemId,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
enableReloadButton: false,
enableNavigationButtons: false,
enableAddToHomeScreen: false,
enablePinTab: enablePinTabInMenu,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
);
},
);
}
SelectableChipDecoration<QuickTabSwitcherItem> _chipDecoration(
BuildContext context,
) {
return SelectableChipDecoration(
color: (item, isSelected) => switch (item.color) { color: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette( final color? when isSelected =>
context, ContainerColors.palette(context, color).selectedBackgroundColor,
color, final color? => ContainerColors.palette(context, color).backgroundColor,
).selectedBackgroundColor,
final color? => ContainerColors.palette(
context,
color,
).backgroundColor,
null => null, null => null,
}, },
side: (item, isSelected) => switch (item.color) { side: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette( final color? when isSelected =>
context, ContainerColors.palette(context, color).selectedBorderSide,
color, final color? => ContainerColors.palette(context, color).borderSide,
).selectedBorderSide,
final color? => ContainerColors.palette(
context,
color,
).borderSide,
null => null, null => null,
}, },
labelPadding: (item) => labelPadding: (item) =>
@@ -773,9 +895,15 @@ class QuickTabSwitcherView extends StatelessWidget {
item.tabMode is! IsolatedTabMode) item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero ? EdgeInsets.zero
: null, : null,
), );
itemLabel: (item) { }
final isSelected = activeItem?.id == item.id;
Widget _chipLabel(
BuildContext context,
QuickTabSwitcherItem item,
bool isSelected,
) {
final appColors = AppColors.of(context);
final row = Row( final row = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -836,24 +964,62 @@ class QuickTabSwitcherView extends StatelessWidget {
context, context,
color, color,
).selectedForegroundColor ).selectedForegroundColor
: ContainerColors.palette( : ContainerColors.palette(context, color).foregroundColor,
context, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color,
).foregroundColor,
fontWeight: isSelected
? FontWeight.w700
: FontWeight.w500,
), ),
child: row, child: row,
), ),
) ?? ) ??
row; row;
}
}
/// FilterChip matching `SelectableChips`' visual contract, used in the
/// reorderable render path. Stateless wrapper so the parent
/// `ReorderableListView` can attach its drag-handle gesture recognizer.
class _ReorderableSwitcherChip extends StatelessWidget {
final QuickTabSwitcherItem item;
final bool isSelected;
final bool showTitles;
final bool showIsolatedTabUi;
final Color selectedBorderColor;
final SelectableChipDecoration<QuickTabSwitcherItem> decoration;
final Widget label;
final Future<void> Function() onTap;
const _ReorderableSwitcherChip({
required this.item,
required this.isSelected,
required this.showTitles,
required this.showIsolatedTabUi,
required this.selectedBorderColor,
required this.decoration,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final itemColor = decoration.color?.call(item, isSelected);
final side = decoration.side?.call(item, isSelected) ??
(isSelected
? BorderSide(color: selectedBorderColor, width: 2.0)
: null);
final labelPadding = decoration.labelPadding?.call(item);
return Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: FilterChip(
color: itemColor != null ? WidgetStatePropertyAll(itemColor) : null,
selected: false,
showCheckmark: false,
labelPadding: labelPadding,
onSelected: (_) {
unawaited(onTap());
}, },
itemAvatar: (item) => item.avatar, label: label,
onSelected: onSelected, avatar: item.avatar,
itemWrap: itemWrapBuilder, side: side,
availableItems: availableItems,
),
), ),
); );
} }
@@ -47,12 +47,19 @@ class TabContextMenuDraggable extends HookConsumerWidget {
/// When true, no [LongPressDraggable] is used; only menu + manual timer. /// When true, no [LongPressDraggable] is used; only menu + manual timer.
final bool externalDrag; final bool externalDrag;
/// Whether the context menu should expose the "Close Tab" item.
/// Default false matches the tab grid/list, where the chip itself has a
/// dedicated close affordance. Callers without an inline close button
/// (e.g. the quick switcher chip bar) opt in.
final bool enableCloseTab;
const TabContextMenuDraggable({ const TabContextMenuDraggable({
required this.tabId, required this.tabId,
required this.child, required this.child,
required this.feedbackSize, required this.feedbackSize,
this.data, this.data,
this.externalDrag = false, this.externalDrag = false,
this.enableCloseTab = false,
super.key, super.key,
}); });
@@ -99,7 +106,7 @@ class TabContextMenuDraggable extends HookConsumerWidget {
enableReloadButton: false, enableReloadButton: false,
enableNavigationButtons: false, enableNavigationButtons: false,
enableAddToHomeScreen: false, enableAddToHomeScreen: false,
enableCloseTab: false, enableCloseTab: enableCloseTab,
builder: builder, builder: builder,
); );
} }
@@ -201,8 +201,8 @@ class TabBarPreviewCard extends HookWidget {
scrollController: quickTabsController, scrollController: quickTabsController,
showTitles: settings.quickTabSwitcherShowTitles, showTitles: settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi, showIsolatedTabUi: settings.showIsolatedTabUi,
enablePinTabInMenu: false,
onSelected: (_) async {}, onSelected: (_) async {},
itemWrapBuilder: (child, _) => child,
); );
} }