fix side rail overflows

This commit is contained in:
Fabian Freund
2026-07-08 06:23:36 +02:00
parent 1cde1e3ecd
commit 7490736258
5 changed files with 125 additions and 53 deletions
@@ -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,
);
}
}
@@ -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),
],
],
),
@@ -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
@@ -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,
@@ -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