diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index b84536c0..2ea50b33 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -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({ + 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 {}, ), ); final reorderEnabled = - effectiveMode == QuickTabSwitcherMode.containerTabs && - canManualReorder; + effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder; final tabItems = tabStates.value.map((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((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(( + 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, ), ); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_depth_indicator.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_depth_indicator.dart new file mode 100644 index 00000000..3c04823b --- /dev/null +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_depth_indicator.dart @@ -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 . + */ +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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 5d6dae23..88da8b8b 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -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; diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart index cd639b81..8ae1d79a 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_preview.dart @@ -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),