quick tab switcher depth

This commit is contained in:
Fabian Freund
2026-05-26 12:43:02 +02:00
parent 42c09dab3a
commit 3676ed6b08
4 changed files with 153 additions and 77 deletions
@@ -46,12 +46,15 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
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_context_menu_draggable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_depth_indicator.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/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_entity.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/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.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';
@@ -460,6 +463,7 @@ class QuickTabSwitcherItem with FastEquatable {
final bool isHistory; final bool isHistory;
final bool isPinned; final bool isPinned;
final bool isSandbox; final bool isSandbox;
final int depth;
final String title; final String title;
final Uri url; final Uri url;
final Widget avatar; final Widget avatar;
@@ -475,6 +479,7 @@ class QuickTabSwitcherItem with FastEquatable {
required this.url, required this.url,
required this.avatar, required this.avatar,
this.isSandbox = false, this.isSandbox = false,
this.depth = 0,
}); });
@override @override
@@ -486,6 +491,7 @@ class QuickTabSwitcherItem with FastEquatable {
isHistory, isHistory,
isPinned, isPinned,
isSandbox, isSandbox,
depth,
title, title,
url, url,
avatar, avatar,
@@ -531,14 +537,36 @@ class QuickTabSwitcher extends HookConsumerWidget {
final sortPinnedFirst = ref.watch( final sortPinnedFirst = ref.watch(
tabViewFilterControllerProvider.select((v) => v.sortPinnedFirst), tabViewFilterControllerProvider.select((v) => v.sortPinnedFirst),
); );
final showHierarchicalTabs = ref.watch(
tabViewFilterControllerProvider.select((v) => v.showHierarchicalTabs),
);
final selectedContainerId = ref.watch(selectedContainerProvider);
final hierarchyContainerId =
effectiveMode == QuickTabSwitcherMode.containerTabs
? selectedContainerId
: null;
final tabDepthById = ref
.watch(
groupedTabListItemsProvider(containerId: hierarchyContainerId).select(
(value) {
return EquatableValue(<String, int>{
if (showHierarchicalTabs)
for (final item in value.value)
if (item is TabListChildItem) item.tabId: item.depth,
});
},
),
)
.value;
final pinnedTabIds = ref.watch( final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select( watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{}, (value) => value.value ?? const <String>{},
), ),
); );
final reorderEnabled = final reorderEnabled =
effectiveMode == QuickTabSwitcherMode.containerTabs && effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder;
canManualReorder;
final tabItems = tabStates.value.map<QuickTabSwitcherItem>((state) { final tabItems = tabStates.value.map<QuickTabSwitcherItem>((state) {
final sandboxSourceUri = parseSandboxSource( final sandboxSourceUri = parseSandboxSource(
sandboxCaptureMap[state.$1.id], sandboxCaptureMap[state.$1.id],
@@ -556,12 +584,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
isHistory: false, isHistory: false,
isPinned: pinnedTabIds.contains(state.$1.id), isPinned: pinnedTabIds.contains(state.$1.id),
isSandbox: sandboxSourceUri != null, isSandbox: sandboxSourceUri != null,
depth: tabDepthById[state.$1.id] ?? 0,
url: displayUrl, url: displayUrl,
avatar: TabIcon(tabState: state.$1, iconSize: 20), avatar: TabIcon(tabState: state.$1, iconSize: 20),
); );
}).toList(); }).toList();
final historyItems = (historySuggestions ?? []) final historyItems = (historySuggestions ?? []).map<QuickTabSwitcherItem>((
.map<QuickTabSwitcherItem>((state) { state,
) {
final url = Uri.parse(state.url); final url = Uri.parse(state.url);
return QuickTabSwitcherItem( return QuickTabSwitcherItem(
color: null, color: null,
@@ -574,8 +604,7 @@ 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 availableItems = [...tabItems, ...historyItems];
final activeItem = availableItems.isEmpty final activeItem = availableItems.isEmpty
@@ -666,8 +695,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
activeItemKey: activeItemKey.value, activeItemKey: activeItemKey.value,
showTitles: showTitles, showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi, showIsolatedTabUi: showIsolatedTabUi,
enablePinTabInMenu: enablePinTabInMenu: effectiveMode == QuickTabSwitcherMode.containerTabs,
effectiveMode == QuickTabSwitcherMode.containerTabs,
onSelected: (item) async { onSelected: (item) async {
if (!item.isHistory && item.isActive) { if (!item.isHistory && item.isActive) {
return; return;
@@ -687,8 +715,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
onReorderItem: !reorderEnabled onReorderItem: !reorderEnabled
? null ? null
: (oldIndex, newIndex) async { : (oldIndex, newIndex) async {
if (oldIndex >= tabItems.length || if (oldIndex >= tabItems.length || newIndex > tabItems.length) {
newIndex > tabItems.length) {
return; return;
} }
final visibleItems = [ final visibleItems = [
@@ -762,8 +789,7 @@ class QuickTabSwitcherView extends StatelessWidget {
/// or after are appended as a static trailing row (e.g. history hints). /// or after are appended as a static trailing row (e.g. history hints).
final int reorderableItemCount; final int reorderableItemCount;
bool get _reorderEnabled => bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
onReorderItem != null && reorderableItemCount > 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -795,13 +821,10 @@ class QuickTabSwitcherView extends StatelessWidget {
selectedItem: activeItem, selectedItem: activeItem,
selectedBorderColor: Theme.of(context).colorScheme.primary, selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: _chipDecoration(context), decoration: _chipDecoration(context),
itemLabel: (item) => itemLabel: (item) => _chipLabel(context, item, activeItem?.id == item.id),
_chipLabel(context, item, activeItem?.id == item.id),
itemAvatar: (item) => item.avatar,
onSelected: onSelected, onSelected: onSelected,
itemWrap: (child, item) => item.isHistory itemWrap: (child, item) =>
? child item.isHistory ? child : _wrapWithMenu(itemId: item.id, child: child),
: _wrapWithMenu(itemId: item.id, child: child),
availableItems: availableItems, availableItems: availableItems,
); );
} }
@@ -884,14 +907,18 @@ class QuickTabSwitcherView extends StatelessWidget {
) { ) {
return SelectableChipDecoration( return SelectableChipDecoration(
color: (item, isSelected) => switch (item.color) { color: (item, isSelected) => switch (item.color) {
final color? when isSelected => final color? when isSelected => ContainerColors.palette(
ContainerColors.palette(context, color).selectedBackgroundColor, context,
color,
).selectedBackgroundColor,
final color? => ContainerColors.palette(context, color).backgroundColor, 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 => final color? when isSelected => ContainerColors.palette(
ContainerColors.palette(context, color).selectedBorderSide, context,
color,
).selectedBorderSide,
final color? => ContainerColors.palette(context, color).borderSide, final color? => ContainerColors.palette(context, color).borderSide,
null => null, null => null,
}, },
@@ -900,6 +927,7 @@ class QuickTabSwitcherView extends StatelessWidget {
!item.isHistory && !item.isHistory &&
!item.isPinned && !item.isPinned &&
!item.isSandbox && !item.isSandbox &&
item.depth == 0 &&
item.tabMode is! PrivateTabMode && item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode) item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero ? EdgeInsets.zero
@@ -913,10 +941,25 @@ class QuickTabSwitcherView extends StatelessWidget {
bool isSelected, bool isSelected,
) { ) {
final appColors = AppColors.of(context); final appColors = AppColors.of(context);
final hasTitle = item.isHistory || showTitles;
final row = Row( final row = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (item.isHistory || showTitles) if (item.depth > 0)
Padding(
padding: const EdgeInsets.only(right: 6.0),
child: TabDepthIndicator(
depth: item.depth,
height: 24.0,
iconSize: 14.0,
horizontalPadding: 4.0,
),
),
Padding(
padding: EdgeInsets.only(right: hasTitle ? 6.0 : 0.0),
child: item.avatar,
),
if (hasTitle)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 64), constraints: const BoxConstraints(maxWidth: 64),
child: Text(item.title), child: Text(item.title),
@@ -1010,7 +1053,8 @@ class _ReorderableSwitcherChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final itemColor = decoration.color?.call(item, isSelected); final itemColor = decoration.color?.call(item, isSelected);
final side = decoration.side?.call(item, isSelected) ?? final side =
decoration.side?.call(item, isSelected) ??
(isSelected (isSelected
? BorderSide(color: selectedBorderColor, width: 2.0) ? BorderSide(color: selectedBorderColor, width: 2.0)
: null); : null);
@@ -1027,7 +1071,6 @@ class _ReorderableSwitcherChip extends StatelessWidget {
unawaited(onTap()); unawaited(onTap());
}, },
label: label, label: label,
avatar: item.avatar,
side: side, side: side,
), ),
); );
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
// Cap glyph count so the badge stays readable on deep trees.
const int _maxDepthGlyphs = 4;
class TabDepthIndicator extends StatelessWidget {
final int depth;
final double height;
final double iconSize;
final double horizontalPadding;
const TabDepthIndicator({
required this.depth,
this.height = 28.0,
this.iconSize = 16.0,
this.horizontalPadding = 6.0,
super.key,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final glyphCount = math.min(depth, _maxDepthGlyphs);
return SizedBox(
height: height,
child: Material(
color: scheme.surfaceContainerHighest.withAlpha(200),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < glyphCount; i++)
Icon(
MdiIcons.subdirectoryArrowRight,
size: iconSize,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
@@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.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_view/tab_depth_indicator.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.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';
@@ -308,7 +309,7 @@ class GridTabPreview extends HookConsumerWidget {
Positioned( Positioned(
bottom: 6.0, bottom: 6.0,
left: 6.0, left: 6.0,
child: _GridDepthIndicator(depth: depth), child: TabDepthIndicator(depth: depth),
), ),
if (trailingChild != null || isPinned || groupToggle != null) if (trailingChild != null || isPinned || groupToggle != null)
Positioned( Positioned(
@@ -700,45 +701,6 @@ class ListTabPreview extends HookConsumerWidget {
const double _listIndentStep = 16.0; const double _listIndentStep = 16.0;
const int _listMaxIndentLevels = 3; const int _listMaxIndentLevels = 3;
// Cap glyph count so the badge stays readable on deep trees.
const int _gridMaxDepthGlyphs = 4;
class _GridDepthIndicator extends StatelessWidget {
final int depth;
const _GridDepthIndicator({required this.depth});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final glyphCount = math.min(depth, _gridMaxDepthGlyphs);
// Same shell as the top-left toggle / top-right close button:
// 28px tall, surfaceContainerHighest with 200 alpha, 8px radius,
// onSurfaceVariant icons at 16px.
return SizedBox(
height: 28,
child: Material(
color: scheme.surfaceContainerHighest.withAlpha(200),
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < glyphCount; i++)
Icon(
MdiIcons.subdirectoryArrowRight,
size: 16,
color: scheme.onSurfaceVariant,
),
],
),
),
),
);
}
}
class _IndentGuidePainter extends CustomPainter { class _IndentGuidePainter extends CustomPainter {
final Color color; final Color color;
final double stubInsetFromRight; final double stubInsetFromRight;
@@ -147,6 +147,7 @@ class TabBarPreviewCard extends HookWidget {
tabMode: TabMode.private, tabMode: TabMode.private,
isHistory: false, isHistory: false,
isPinned: false, isPinned: false,
depth: 1,
url: Uri.parse('https://example.com/private'), url: Uri.parse('https://example.com/private'),
color: null, color: null,
avatar: const Icon(MdiIcons.web, size: 20), avatar: const Icon(MdiIcons.web, size: 20),
@@ -159,6 +160,7 @@ class TabBarPreviewCard extends HookWidget {
tabMode: TabMode.isolated('preview-isolated-context'), tabMode: TabMode.isolated('preview-isolated-context'),
isHistory: false, isHistory: false,
isPinned: false, isPinned: false,
depth: 2,
url: Uri.parse('https://example.com/bank'), url: Uri.parse('https://example.com/bank'),
color: null, color: null,
avatar: const Icon(MdiIcons.web, size: 20), avatar: const Icon(MdiIcons.web, size: 20),