From 7490736258835f8aad1e5b9558d0ebe467461b1c Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 8 Jul 2026 06:23:36 +0200 Subject: [PATCH] fix side rail overflows --- .../browser_modules/bottom_app_bar.dart | 53 ++++-------- .../quick_tab_switcher_accordion.dart | 6 +- .../quick_tab_switcher_chip.dart | 80 ++++++++++++++++++- .../widgets/toolbar_layout_content.dart | 24 +++--- .../user/data/models/general_settings.dart | 15 +++- 5 files changed, 125 insertions(+), 53 deletions(-) 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 306a20cd..2043475d 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 @@ -481,42 +481,21 @@ class BrowserTabBar extends HookConsumerWidget { axis: switcherAxis, ), // History fallback only on the MRU row, so empty-state history - // chips don't show twice. On the rail the two rows stack as two - // equal-height vertical lists. - TabBarStackingMode.twoLevel => - isVertical - ? Column( - children: [ - Expanded( - child: QuickTabSwitcher( - quickTabSwitcherMode: - QuickTabSwitcherMode.containerTabs, - enableHistoryFallback: false, - axis: switcherAxis, - ), - ), - Expanded( - child: QuickTabSwitcher( - quickTabSwitcherMode: - QuickTabSwitcherMode.lastUsedTabs, - axis: switcherAxis, - ), - ), - ], - ) - : const Column( - mainAxisSize: MainAxisSize.min, - children: [ - QuickTabSwitcher( - quickTabSwitcherMode: - QuickTabSwitcherMode.containerTabs, - enableHistoryFallback: false, - ), - QuickTabSwitcher( - quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, - ), - ], - ), + // chips don't show twice. Only reached on the horizontal bar: + // effectiveTabBarStackingMode() degrades twoLevel to containerTabs + // on the narrow vertical rail, where two stacked rows don't fit. + TabBarStackingMode.twoLevel => const Column( + mainAxisSize: MainAxisSize.min, + children: [ + QuickTabSwitcher( + quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs, + enableHistoryFallback: false, + ), + QuickTabSwitcher( + quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs, + ), + ], + ), }, ), contextualToolbar: ContextualToolbar( @@ -1228,6 +1207,7 @@ class QuickTabSwitcherView extends StatelessWidget { context, showTitles: showTitles, hierarchyGlyphs: hierarchyGlyphs, + isVertical: _isVertical, canDelete: _canShowCloseButton, ); } @@ -1245,6 +1225,7 @@ class QuickTabSwitcherView extends StatelessWidget { showIsolatedTabUi: showIsolatedTabUi, hierarchyGlyphs: hierarchyGlyphs, titleMaxWidth: titleMaxWidth, + isVertical: _isVertical, ); } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart index 2e97e0e2..f53f4444 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_accordion.dart @@ -162,6 +162,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { context, showTitles: showTitles, hierarchyGlyphs: hierarchyGlyphs, + isVertical: isVertical, // The tray is painted in the container color, so the active tab's normal // (transparent) selected border would blend in; give it a thicker border // in the container's outline color instead. @@ -201,6 +202,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget { showIsolatedTabUi: showIsolatedTabUi, hierarchyGlyphs: hierarchyGlyphs, titleMaxWidth: titleMaxWidth, + isVertical: isVertical, ), // Spacing inside the expanded group is owned by the surrounding tray // slice so the slices abut into one continuous background. @@ -527,7 +529,9 @@ class _AccordionHeaderChip extends StatelessWidget { if (iconAvatar != null) iconAvatar, if (countBadge != null) ...[ if (iconAvatar != null) const SizedBox(height: 4), - countBadge, + // Multi-digit counts can exceed the narrow rail's fixed 48px chip + // width; scale the badge down to fit instead of overflowing. + FittedBox(fit: BoxFit.scaleDown, child: countBadge), ], ], ), diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart index 522e754b..14217372 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart @@ -146,6 +146,10 @@ buildQuickTabSwitcherChipDecoration( BuildContext context, { required bool showTitles, required int hierarchyGlyphs, + // On the vertical rail nesting is shown as a corner badge on the favicon (not + // a leading pill), so a nested chip carries no extra inline width and can use + // the same zero label padding as a leaf chip instead of overflowing. + bool isVertical = false, bool Function(QuickTabSwitcherItem item)? canDelete, // When true, the selected item gets a thicker border in its container's // outline color (instead of the usual transparent selected border). The @@ -195,7 +199,7 @@ buildQuickTabSwitcherChipDecoration( !item.isHistory && !item.isPinned && !item.isSandbox && - (item.depth == 0 || hierarchyGlyphs == 0) && + (item.depth == 0 || hierarchyGlyphs == 0 || isVertical) && item.tabMode is! PrivateTabMode && item.tabMode is! IsolatedTabMode) ? EdgeInsets.zero @@ -213,13 +217,22 @@ Widget buildQuickTabSwitcherChipLabel( required bool showIsolatedTabUi, required int hierarchyGlyphs, required double titleMaxWidth, + // On the narrow vertical rail the leading depth pill has no room beside the + // favicon (it clips the icon), so nesting is shown as a compact corner badge + // overlaid on the favicon instead. + bool isVertical = false, }) { final appColors = AppColors.of(context); final hasTitle = item.isHistory || showTitles; + final isNested = item.depth > 0 && hierarchyGlyphs > 0; + final showInlineDepth = isNested && !isVertical; + final avatar = (isNested && isVertical) + ? _RailDepthAvatar(depth: item.depth, child: item.avatar) + : item.avatar; final row = Row( mainAxisSize: MainAxisSize.min, children: [ - if (item.depth > 0 && hierarchyGlyphs > 0) + if (showInlineDepth) Padding( padding: const EdgeInsets.only(right: 6.0), child: TabDepthIndicator( @@ -232,7 +245,7 @@ Widget buildQuickTabSwitcherChipLabel( ), Padding( padding: EdgeInsets.only(right: hasTitle ? 6.0 : 0.0), - child: item.avatar, + child: avatar, ), if (hasTitle) ConstrainedBox( @@ -305,6 +318,67 @@ Widget buildQuickTabSwitcherChipLabel( row; } +/// Favicon with a compact nesting badge overlaid on its bottom-right corner, +/// used on the vertical side rail where the inline [TabDepthIndicator] pill +/// would clip the favicon. The badge shows a subdirectory-arrow glyph for a +/// direct child and the depth number for deeper nesting. +class _RailDepthAvatar extends StatelessWidget { + final int depth; + final Widget child; + + const _RailDepthAvatar({required this.depth, required this.child}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + // Keep the exact 20px footprint of a plain favicon so a nested chip stays + // the same width as a leaf chip on the narrow rail; the badge is anchored + // inside the horizontal bounds (only the vertical corner is allowed to + // bleed, where there's ample room) so it never widens the chip. + return SizedBox( + width: 20, + height: 20, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: child), + Positioned( + right: -3, + bottom: -5, + child: Container( + constraints: const BoxConstraints(minWidth: 12, minHeight: 12), + padding: const EdgeInsets.symmetric(horizontal: 1.0), + decoration: BoxDecoration( + color: scheme.secondaryContainer, + borderRadius: const BorderRadius.all(Radius.circular(6.0)), + border: Border.all(color: scheme.surfaceContainer, width: 1.0), + ), + child: Center( + child: depth > 1 + ? Text( + '$depth', + style: TextStyle( + fontSize: 9, + height: 1.0, + fontWeight: FontWeight.w700, + color: scheme.onSecondaryContainer, + ), + ) + : Icon( + MdiIcons.subdirectoryArrowRight, + size: 10, + color: scheme.onSecondaryContainer, + ), + ), + ), + ), + ], + ), + ); + } +} + /// FilterChip matching [SelectableChips]' visual contract, used by render /// paths that can't go through [SelectableChips] itself (the reorderable /// list and the accordion view). Stateless wrapper so a parent diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart index 868a1aa1..f96a6082 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart @@ -400,13 +400,13 @@ class _TabBarStackingModeSection extends HookConsumerWidget { title: Text('Recently Used Tabs'), subtitle: Text('Recently used tabs across all containers'), ), - if (settings.showContainerUi) ...const [ - RadioListTile.adaptive( + if (settings.showContainerUi) ...[ + const RadioListTile.adaptive( value: TabBarStackingMode.containerTabs, title: Text('Container Tabs'), subtitle: Text('Ordered tabs of the selected container'), ), - RadioListTile.adaptive( + const RadioListTile.adaptive( value: TabBarStackingMode.accordion, title: Text('Accordion'), subtitle: Text( @@ -414,14 +414,18 @@ class _TabBarStackingModeSection extends HookConsumerWidget { "container's tabs expanded inline", ), ), - RadioListTile.adaptive( - value: TabBarStackingMode.twoLevel, - title: Text('Two Rows'), - subtitle: Text( - 'Tabs of the selected container on top, recently used ' - 'tabs below', + // Two stacked rows don't fit the narrow vertical side rail, + // where the mode degrades to Container Tabs; hide the option + // for left/right positions to avoid a no-op choice. + if (!settings.tabBarPosition.isVertical) + const RadioListTile.adaptive( + value: TabBarStackingMode.twoLevel, + title: Text('Two Rows'), + subtitle: Text( + 'Tabs of the selected container on top, recently used ' + 'tabs below', + ), ), - ), ], const RadioListTile.adaptive( value: TabBarStackingMode.disabled, diff --git a/apps/weblibre/lib/features/user/data/models/general_settings.dart b/apps/weblibre/lib/features/user/data/models/general_settings.dart index f9edd4a0..56d2dd71 100644 --- a/apps/weblibre/lib/features/user/data/models/general_settings.dart +++ b/apps/weblibre/lib/features/user/data/models/general_settings.dart @@ -511,17 +511,26 @@ class GeneralSettings with FastEquatable { } /// Container-dependent stacking modes degrade to a single recently-used - /// row when the container UI is disabled. + /// row when the container UI is disabled. Two-level stacking additionally + /// degrades to the single container-tabs row on the narrow vertical rail, + /// where two stacked chip lists have no room. TabBarStackingMode effectiveTabBarStackingMode() { + var mode = tabBarStackingMode; + + if (tabBarPosition.isVertical && mode == TabBarStackingMode.twoLevel) { + mode = TabBarStackingMode.containerTabs; + } + if (!showContainerUi && const { TabBarStackingMode.containerTabs, TabBarStackingMode.accordion, TabBarStackingMode.twoLevel, - }.contains(tabBarStackingMode)) { + }.contains(mode)) { return TabBarStackingMode.lastUsedTabs; } - return tabBarStackingMode; + + return mode; } @override