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_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_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/toolbar_button.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/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/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/utils/container_colors.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
@@ -460,6 +463,7 @@ class QuickTabSwitcherItem with FastEquatable {
final bool isHistory;
final bool isPinned;
final bool isSandbox;
final int depth;
final String title;
final Uri url;
final Widget avatar;
@@ -475,6 +479,7 @@ class QuickTabSwitcherItem with FastEquatable {
required this.url,
required this.avatar,
this.isSandbox = false,
this.depth = 0,
});
@override
@@ -486,6 +491,7 @@ class QuickTabSwitcherItem with FastEquatable {
isHistory,
isPinned,
isSandbox,
depth,
title,
url,
avatar,
@@ -531,14 +537,36 @@ class QuickTabSwitcher extends HookConsumerWidget {
final sortPinnedFirst = ref.watch(
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(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
final reorderEnabled =
effectiveMode == QuickTabSwitcherMode.containerTabs &&
canManualReorder;
effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder;
final tabItems = tabStates.value.map<QuickTabSwitcherItem>((state) {
final sandboxSourceUri = parseSandboxSource(
sandboxCaptureMap[state.$1.id],
@@ -556,26 +584,27 @@ class QuickTabSwitcher extends HookConsumerWidget {
isHistory: false,
isPinned: pinnedTabIds.contains(state.$1.id),
isSandbox: sandboxSourceUri != null,
depth: tabDepthById[state.$1.id] ?? 0,
url: displayUrl,
avatar: TabIcon(tabState: state.$1, iconSize: 20),
);
}).toList();
final historyItems = (historySuggestions ?? [])
.map<QuickTabSwitcherItem>((state) {
final url = Uri.parse(state.url);
return QuickTabSwitcherItem(
color: null,
id: state.url,
isActive: false,
title: state.title ?? url.authority,
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: url,
avatar: UrlIcon([url], iconSize: 20),
);
})
.toList();
final historyItems = (historySuggestions ?? []).map<QuickTabSwitcherItem>((
state,
) {
final url = Uri.parse(state.url);
return QuickTabSwitcherItem(
color: null,
id: state.url,
isActive: false,
title: state.title ?? url.authority,
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: url,
avatar: UrlIcon([url], iconSize: 20),
);
}).toList();
final availableItems = [...tabItems, ...historyItems];
final activeItem = availableItems.isEmpty
@@ -666,8 +695,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
activeItemKey: activeItemKey.value,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
enablePinTabInMenu:
effectiveMode == QuickTabSwitcherMode.containerTabs,
enablePinTabInMenu: effectiveMode == QuickTabSwitcherMode.containerTabs,
onSelected: (item) async {
if (!item.isHistory && item.isActive) {
return;
@@ -687,8 +715,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
onReorderItem: !reorderEnabled
? null
: (oldIndex, newIndex) async {
if (oldIndex >= tabItems.length ||
newIndex > tabItems.length) {
if (oldIndex >= tabItems.length || newIndex > tabItems.length) {
return;
}
final visibleItems = [
@@ -762,8 +789,7 @@ class QuickTabSwitcherView extends StatelessWidget {
/// or after are appended as a static trailing row (e.g. history hints).
final int reorderableItemCount;
bool get _reorderEnabled =>
onReorderItem != null && reorderableItemCount > 0;
bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
@override
Widget build(BuildContext context) {
@@ -795,13 +821,10 @@ class QuickTabSwitcherView extends StatelessWidget {
selectedItem: activeItem,
selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: _chipDecoration(context),
itemLabel: (item) =>
_chipLabel(context, item, activeItem?.id == item.id),
itemAvatar: (item) => item.avatar,
itemLabel: (item) => _chipLabel(context, item, activeItem?.id == item.id),
onSelected: onSelected,
itemWrap: (child, item) => item.isHistory
? child
: _wrapWithMenu(itemId: item.id, child: child),
itemWrap: (child, item) =>
item.isHistory ? child : _wrapWithMenu(itemId: item.id, child: child),
availableItems: availableItems,
);
}
@@ -884,14 +907,18 @@ class QuickTabSwitcherView extends StatelessWidget {
) {
return SelectableChipDecoration(
color: (item, isSelected) => switch (item.color) {
final color? when isSelected =>
ContainerColors.palette(context, color).selectedBackgroundColor,
final color? when isSelected => ContainerColors.palette(
context,
color,
).selectedBackgroundColor,
final color? => ContainerColors.palette(context, color).backgroundColor,
null => null,
},
side: (item, isSelected) => switch (item.color) {
final color? when isSelected =>
ContainerColors.palette(context, color).selectedBorderSide,
final color? when isSelected => ContainerColors.palette(
context,
color,
).selectedBorderSide,
final color? => ContainerColors.palette(context, color).borderSide,
null => null,
},
@@ -900,6 +927,7 @@ class QuickTabSwitcherView extends StatelessWidget {
!item.isHistory &&
!item.isPinned &&
!item.isSandbox &&
item.depth == 0 &&
item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero
@@ -913,10 +941,25 @@ class QuickTabSwitcherView extends StatelessWidget {
bool isSelected,
) {
final appColors = AppColors.of(context);
final hasTitle = item.isHistory || showTitles;
final row = Row(
mainAxisSize: MainAxisSize.min,
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(
constraints: const BoxConstraints(maxWidth: 64),
child: Text(item.title),
@@ -1010,7 +1053,8 @@ class _ReorderableSwitcherChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final itemColor = decoration.color?.call(item, isSelected);
final side = decoration.side?.call(item, isSelected) ??
final side =
decoration.side?.call(item, isSelected) ??
(isSelected
? BorderSide(color: selectedBorderColor, width: 2.0)
: null);
@@ -1027,7 +1071,6 @@ class _ReorderableSwitcherChip extends StatelessWidget {
unawaited(onTap());
},
label: label,
avatar: item.avatar,
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/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_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/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
@@ -308,7 +309,7 @@ class GridTabPreview extends HookConsumerWidget {
Positioned(
bottom: 6.0,
left: 6.0,
child: _GridDepthIndicator(depth: depth),
child: TabDepthIndicator(depth: depth),
),
if (trailingChild != null || isPinned || groupToggle != null)
Positioned(
@@ -700,45 +701,6 @@ class ListTabPreview extends HookConsumerWidget {
const double _listIndentStep = 16.0;
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 {
final Color color;
final double stubInsetFromRight;
@@ -147,6 +147,7 @@ class TabBarPreviewCard extends HookWidget {
tabMode: TabMode.private,
isHistory: false,
isPinned: false,
depth: 1,
url: Uri.parse('https://example.com/private'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),
@@ -159,6 +160,7 @@ class TabBarPreviewCard extends HookWidget {
tabMode: TabMode.isolated('preview-isolated-context'),
isHistory: false,
isPinned: false,
depth: 2,
url: Uri.parse('https://example.com/bank'),
color: null,
avatar: const Icon(MdiIcons.web, size: 20),