side rail initial

This commit is contained in:
Fabian Freund
2026-07-05 08:43:46 +02:00
parent 7931f66f5a
commit d83487325c
16 changed files with 1197 additions and 275 deletions
@@ -27,7 +27,11 @@ import 'package:weblibre/features/geckoview/domain/providers/web_extensions_stat
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
class PinnedAddonBar extends ConsumerWidget {
const PinnedAddonBar({super.key});
const PinnedAddonBar({super.key, this.axis = Axis.horizontal});
/// Layout direction. Vertical stacks the pinned add-on icons for the side
/// rail; horizontal is the standard top/bottom bar layout.
final Axis axis;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -45,37 +49,43 @@ class PinnedAddonBar extends ConsumerWidget {
.toList();
if (pinned.isEmpty) return const SizedBox.shrink();
final isVertical = axis == Axis.vertical;
final items = [
for (final extension in pinned)
InkResponse(
radius: 22,
onTap: () async {
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
onLongPress: () async {
await AddonDetailsRoute(
addonId: extension.extensionId,
).push<void>(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 8.0),
child: ExtensionBadgeIcon(extension),
),
),
];
if (isVertical) {
// On the narrow rail, stack the pinned icons and let them scroll if they
// exceed the available height.
return SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, children: items),
);
}
return Padding(
padding: const EdgeInsets.only(right: 6.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final extension in pinned)
InkResponse(
radius: 22,
onTap: () async {
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
onLongPress: () async {
await AddonDetailsRoute(
addonId: extension.extensionId,
).push<void>(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 8.0,
),
child: ExtensionBadgeIcon(extension),
),
),
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: items),
);
}
}
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
}
String _$browserDataServiceHash() =>
r'a10ac863a4c80e3ed882c3d2a2c1b8535a257ac0';
r'a317f0d56d1bfc61dc99af1e60080aaa248d6eed';
abstract class _$BrowserDataService extends $Notifier<void> {
void build();
@@ -34,11 +34,16 @@ class ContextualToolbar extends HookConsumerWidget {
super.key,
required this.selectedTabId,
required this.displayedSheet,
this.axis = Axis.horizontal,
});
final String? selectedTabId;
final Sheet? displayedSheet;
/// Layout direction, forwarded to [ContextualToolbarView]. Vertical for the
/// side rail.
final Axis axis;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId));
@@ -67,7 +72,7 @@ class ContextualToolbar extends HookConsumerWidget {
.map((button) => _buildButton(scope, context, ref, button))
.toList();
return ContextualToolbarView(buttons: buttons);
return ContextualToolbarView(buttons: buttons, axis: axis);
}
Widget _buildButton(
@@ -90,16 +95,64 @@ class ContextualToolbar extends HookConsumerWidget {
}
class ContextualToolbarView extends StatelessWidget {
const ContextualToolbarView({super.key, required this.buttons});
const ContextualToolbarView({
super.key,
required this.buttons,
this.axis = Axis.horizontal,
});
final List<Widget> buttons;
/// Layout direction of the contextual button strip. Horizontal for the
/// top/bottom tab bar, vertical for the side rail.
final Axis axis;
static const _minButtonWidth = 48.0;
@override
Widget build(BuildContext context) {
if (buttons.isEmpty) return const SizedBox.shrink();
if (axis == Axis.vertical) {
return LayoutBuilder(
builder: (context, constraints) {
// spaceEvenly needs a bounded height; in the rail the contextual
// strip usually sits in an intrinsic (unbounded) slot, so fall back
// to a min-sized fixed-height column there.
final fitsEvenly =
constraints.maxHeight.isFinite &&
constraints.maxHeight >= _minButtonWidth * buttons.length;
if (fitsEvenly) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons,
);
}
// Let buttons size to their natural height instead of clamping them
// into fixed _minButtonWidth-tall slots: some buttons (e.g. the
// tab-count box, which carries its own ToolbarButton padding) are
// taller than that, and a short SizedBox + Center would clip them.
//
// UnconstrainedBox frees the horizontal axis so each button
// shrink-wraps its content instead of stretching to the rail width
// (the tab-count box's inner Center would otherwise fill it); the
// Column then centers each on the cross axis.
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final button in buttons)
UnconstrainedBox(
constrainedAxis: Axis.vertical,
child: button,
),
],
);
},
);
}
return LayoutBuilder(
builder: (context, constraints) {
final fitsEvenly =
@@ -108,9 +108,12 @@ class _AnimatedToolbar extends HookWidget {
}, [visible]);
final slideAnimation = useMemoized(() {
final begin = position == TabBarPosition.top
? const Offset(0, -1)
: const Offset(0, 1);
final begin = switch (position) {
TabBarPosition.top => const Offset(0, -1),
TabBarPosition.bottom => const Offset(0, 1),
TabBarPosition.left => const Offset(-1, 0),
TabBarPosition.right => const Offset(1, 0),
};
return Tween<Offset>(begin: begin, end: Offset.zero).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOutQuart),
@@ -223,7 +226,9 @@ class _TabBar extends HookConsumerWidget {
},
);
// Return the toolbar widget - parent handles animation
// Return the toolbar widget - parent handles animation.
// Rail positions are rendered by a dedicated Stack layer, not _TabBar, but
// are handled here for exhaustiveness/correctness.
return switch (tabBarPosition) {
TabBarPosition.top => BrowserTopAppBar(
showMainToolbar: showMainToolbar,
@@ -239,6 +244,12 @@ class _TabBar extends HookConsumerWidget {
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
),
TabBarPosition.left || TabBarPosition.right => BrowserSideRail(
position: tabBarPosition,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
),
};
}
}
@@ -539,8 +550,13 @@ class BrowserScreen extends HookConsumerWidget {
? 0
: ref.watch(quickTabSwitcherRowCountProvider).value ?? 0;
// Vertical side rail (left/right). Auto-hide is not supported on the rail;
// it is reserved via a plain content offset and dismissed only by gesture.
final isRail = tabBarPosition.isVertical;
final autoHideTabBar =
!isSmallWebActive &&
tabBarPosition.isHorizontal &&
ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.autoHideTabBar,
@@ -641,6 +657,9 @@ class BrowserScreen extends HookConsumerWidget {
bottomAppBarContentSize = const Size.fromHeight(
SmallWebBrowserOverlay.barHeight,
);
} else if (isRail) {
// The rail occupies a side, not the bottom; no bottom bar is rendered.
bottomAppBarContentSize = Size.zero;
} else {
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
bottomAppBarContentSize = BrowserBottomAppBar(
@@ -655,6 +674,25 @@ class BrowserScreen extends HookConsumerWidget {
final bottomAppBarTotalHeight =
bottomAppBarContentSize.height + bottomSafeArea;
// Side rail width reservation (vertical positions only): the fixed content
// width plus the system safe-area inset on the rail's outer edge.
final horizontalSafeArea = switch (tabBarPosition) {
TabBarPosition.left => MediaQuery.of(context).padding.left,
TabBarPosition.right => MediaQuery.of(context).padding.right,
_ => 0.0,
};
final sideRailTotalWidth = isRail
? BrowserTabBar.sideRailWidth + horizontalSafeArea
: 0.0;
// Horizontal insets used to keep overlays (progress, find-in-page) clear of
// the rail on its docked edge.
final railLeftInset = tabBarPosition == TabBarPosition.left
? sideRailTotalWidth
: 0.0;
final railRightInset = tabBarPosition == TabBarPosition.right
? sideRailTotalWidth
: 0.0;
// Calculate top toolbar size for browser offset and progress indicator
final topSafeArea = MediaQuery.of(context).padding.top;
final topAppBarContentSize = BrowserTopAppBar(
@@ -867,8 +905,13 @@ class BrowserScreen extends HookConsumerWidget {
toolbarState == ToolbarVisibility.visible);
// When auto-hide is disabled, constrain browser above toolbar
// (unless toolbar is manually dismissed via swipe gesture)
final bottomOffset = (!autoHideTabBar && toolbarVisible)
// (unless toolbar is manually dismissed via swipe gesture).
// Gated to horizontal positions so the rail (which forces
// auto-hide off) doesn't reserve a phantom bottom inset.
final bottomOffset =
(tabBarPosition.isHorizontal &&
!autoHideTabBar &&
toolbarVisible)
? bottomAppBarTotalHeight
: 0.0;
@@ -879,20 +922,36 @@ class BrowserScreen extends HookConsumerWidget {
? topAppBarTotalHeight
: 0.0;
// Side rail: inset the browser by the rail width on the docked
// edge whenever the rail is visible (no auto-hide, so this is
// a plain content offset — the rail slides out on dismiss).
final leftOffset =
(tabBarPosition == TabBarPosition.left && toolbarVisible)
? sideRailTotalWidth
: 0.0;
final rightOffset =
(tabBarPosition == TabBarPosition.right && toolbarVisible)
? sideRailTotalWidth
: 0.0;
// Only fall back to the system bottom inset when the bottom
// toolbar was explicitly dismissed. The normal auto-hide
// hidden state is still handled by GeckoView's dynamic
// toolbar/clipping logic.
final applyBottomSafeArea =
!tabInFullScreen &&
!isSmallWebActive &&
!sheetDisplayed &&
bottomOffset == 0 &&
toolbarState == ToolbarVisibility.dismissed;
// toolbar/clipping logic. On the rail there is never a bottom
// bar, so the browser always needs the bottom safe inset.
final applyBottomSafeArea = isRail
? (!tabInFullScreen &&
!isSmallWebActive &&
!sheetDisplayed)
: (!tabInFullScreen &&
!isSmallWebActive &&
!sheetDisplayed &&
bottomOffset == 0 &&
toolbarState == ToolbarVisibility.dismissed);
return Positioned(
left: 0,
right: 0,
left: leftOffset,
right: rightOffset,
top: topOffset,
bottom: bottomOffset,
child: _Browser(
@@ -903,6 +962,8 @@ class BrowserScreen extends HookConsumerWidget {
: null,
sheetDisplayed: sheetDisplayed,
hasTopBarOffset: topOffset > 0,
hasLeftBarOffset: leftOffset > 0,
hasRightBarOffset: rightOffset > 0,
applyBottomSafeArea: applyBottomSafeArea,
),
);
@@ -923,11 +984,12 @@ class BrowserScreen extends HookConsumerWidget {
),
),
// Layer 1: Sheet (when displayed) - positioned above toolbar
// Layer 1: Sheet (when displayed) - positioned above toolbar,
// inset past the rail on its docked edge.
if (sheetDisplayed)
Positioned(
left: 0,
right: 0,
left: railLeftInset,
right: railRightInset,
top: 0,
bottom: bottomAppBarTotalHeight,
child: _SheetContainer(
@@ -938,54 +1000,60 @@ class BrowserScreen extends HookConsumerWidget {
),
// Layer 2: Bottom Toolbar (overlay, slides in/out)
// In small web mode, show the discovery overlay instead
Positioned(
left: 0,
right: 0,
bottom: 0,
child: isSmallWebActive
? _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: !tabInFullScreen,
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
elevation: 3,
child: const SmallWebBrowserOverlay(),
// In small web mode, show the discovery overlay instead.
// Skipped entirely for the side rail (Layer 3b below).
if (!isRail)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: isSmallWebActive
? _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: !tabInFullScreen,
child: Material(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
elevation: 3,
child: const SmallWebBrowserOverlay(),
),
)
: Consumer(
// _TabBar is passed via `child` so it is built once and
// reused across toolbar show/hide toggles; only the
// `visible` flag fed to _AnimatedToolbar depends on the
// watched provider.
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(
selectedTabId,
),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: visible,
child: child!,
);
},
child: _TabBar(
tabBarPosition: TabBarPosition.bottom,
showMainToolbar:
tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
pointerMoveEvents:
tabBarPosition == TabBarPosition.bottom
? pointerMoveEventsController.stream
: null,
),
),
)
: Consumer(
// _TabBar is passed via `child` so it is built once and
// reused across toolbar show/hide toggles; only the
// `visible` flag fed to _AnimatedToolbar depends on the
// watched provider.
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return _AnimatedToolbar(
position: TabBarPosition.bottom,
visible: visible,
child: child!,
);
},
child: _TabBar(
tabBarPosition: TabBarPosition.bottom,
showMainToolbar:
tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
pointerMoveEvents:
tabBarPosition == TabBarPosition.bottom
? pointerMoveEventsController.stream
: null,
),
),
),
),
// Layer 3: Top Toolbar (overlay, slides in/out) - only when position is top
if (tabBarPosition == TabBarPosition.top)
@@ -1026,6 +1094,38 @@ class BrowserScreen extends HookConsumerWidget {
),
),
// Layer 3b: Side rail (vertical, left/right). No auto-hide; it
// slides horizontally out of view only on manual dismiss.
if (isRail)
Positioned(
top: 0,
bottom: 0,
left: tabBarPosition == TabBarPosition.left ? 0 : null,
right: tabBarPosition == TabBarPosition.right ? 0 : null,
child: Consumer(
builder: (context, ref, child) {
final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId),
);
final visible =
sheetDisplayed ||
(!tabInFullScreen &&
toolbarState == ToolbarVisibility.visible);
return _AnimatedToolbar(
position: tabBarPosition,
visible: visible,
child: child!,
);
},
child: BrowserSideRail(
position: tabBarPosition,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
),
),
),
// Layer 4: FAB (draggable via long press)
Consumer(
builder: (context, ref, child) {
@@ -1040,6 +1140,14 @@ class BrowserScreen extends HookConsumerWidget {
bottomToolbarVisible: visible,
bottomAppBarHeight: bottomAppBarTotalHeight,
bottomSafeArea: bottomSafeArea,
leftReservedWidth:
(tabBarPosition == TabBarPosition.left && visible)
? sideRailTotalWidth
: 0.0,
rightReservedWidth:
(tabBarPosition == TabBarPosition.right && visible)
? sideRailTotalWidth
: 0.0,
child: child!,
);
},
@@ -1064,12 +1172,24 @@ class BrowserScreen extends HookConsumerWidget {
? Duration.zero
: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
top: tabBarPosition == TabBarPosition.top && visible
// Rail insets track the rail's visibility so overlays
// reclaim the space when it is swiped away.
left: (tabBarPosition == TabBarPosition.left && visible)
? sideRailTotalWidth
: 0.0,
right: (tabBarPosition == TabBarPosition.right && visible)
? sideRailTotalWidth
: 0.0,
// On the rail there is no top/bottom bar; pin the progress
// line to the top of the content area.
top: isRail
? topSafeArea
: tabBarPosition == TabBarPosition.top && visible
? topAppBarTotalHeight
: null,
bottom: tabBarPosition == TabBarPosition.bottom && visible
bottom: isRail
? null
: tabBarPosition == TabBarPosition.bottom && visible
? bottomAppBarTotalHeight
: tabBarPosition == TabBarPosition.bottom
? 0
@@ -1116,10 +1236,18 @@ class BrowserScreen extends HookConsumerWidget {
? Duration.zero
: _AnimatedToolbar._kAnimationDuration,
curve: Curves.easeInOutQuart,
left: 0,
right: 0,
left: (tabBarPosition == TabBarPosition.left && visible)
? sideRailTotalWidth
: 0.0,
right: (tabBarPosition == TabBarPosition.right && visible)
? sideRailTotalWidth
: 0.0,
bottom: math.max(
visible ? bottomAppBarTotalHeight : bottomSafeArea,
isRail
? bottomSafeArea
: (visible
? bottomAppBarTotalHeight
: bottomSafeArea),
MediaQuery.viewInsetsOf(context).bottom,
),
child: child!,
@@ -1213,6 +1341,8 @@ class _Browser extends HookConsumerWidget {
final bool tabInFullScreen;
final bool sheetDisplayed;
final bool hasTopBarOffset;
final bool hasLeftBarOffset;
final bool hasRightBarOffset;
final bool applyBottomSafeArea;
const _Browser({
@@ -1221,6 +1351,8 @@ class _Browser extends HookConsumerWidget {
required this.pointerMoveEventSink,
required this.sheetDisplayed,
required this.hasTopBarOffset,
required this.hasLeftBarOffset,
required this.hasRightBarOffset,
required this.applyBottomSafeArea,
});
@@ -1460,6 +1592,8 @@ class _Browser extends HookConsumerWidget {
isFullscreen: tabInFullScreen,
pointerMoveEventSink: pointerMoveEventSink,
hasTopBarOffset: hasTopBarOffset,
hasLeftBarOffset: hasLeftBarOffset,
hasRightBarOffset: hasRightBarOffset,
applyBottomSafeArea: applyBottomSafeArea,
),
),
@@ -1474,11 +1608,15 @@ class _BrowserView extends StatelessWidget {
final bool isFullscreen;
final StreamSink<Offset>? pointerMoveEventSink;
final bool hasTopBarOffset;
final bool hasLeftBarOffset;
final bool hasRightBarOffset;
final bool applyBottomSafeArea;
const _BrowserView({
required this.isFullscreen,
required this.hasTopBarOffset,
required this.hasLeftBarOffset,
required this.hasRightBarOffset,
required this.applyBottomSafeArea,
this.pointerMoveEventSink,
});
@@ -1488,12 +1626,14 @@ class _BrowserView extends StatelessWidget {
return SafeArea(
// Disable top SafeArea when top bar handles it (has offset applied)
top: !isFullscreen && !hasTopBarOffset,
right: !isFullscreen,
// Disable a side's SafeArea when the rail on that edge already consumed
// the inset via the content offset.
right: !isFullscreen && !hasRightBarOffset,
// Apply bottom SafeArea only when no toolbar/overlay is rendered at the
// bottom (and not in fullscreen). Otherwise the toolbar handles its own
// safe-area inset and the platform view extends behind it.
bottom: applyBottomSafeArea,
left: !isFullscreen,
left: !isFullscreen && !hasLeftBarOffset,
child: Stack(
children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)],
),
@@ -494,6 +494,269 @@ class AppBarTitleView extends StatelessWidget {
}
}
/// Side-rail variant of the address field: an upright site-settings favicon
/// button stacked above the URL, which is rendered as rotated ("vertical")
/// text like a bookshelf spine. Reuses all the same content/behaviour as
/// [AppBarTitle] — only the layout is rotated.
class RailAppBarTitle extends ConsumerWidget {
const RailAppBarTitle({
super.key,
required this.quarterTurns,
this.containerColor,
this.useCustomColor = false,
});
/// Rotation applied to the URL text. 3 (bottom-to-top) reads best on a left
/// rail; 1 (top-to-bottom) on a right rail.
final int quarterTurns;
final Color? containerColor;
final bool useCustomColor;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(selectedTabStateProvider);
final selectedTabType = ref.watch(selectedTabTypeProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final isTabTuneledAsync = ref.watch(isTabTunneledProvider(tabState?.id));
final siteSettingsBadgeState = ref.watch(
showSiteSettingsBadgeProvider.select(
(value) => value.value ?? SiteSettingsBadgeState.hidden,
),
);
if (tabState == null) {
return _EmptyRailAddressField(
quarterTurns: quarterTurns,
onTap: () async {
await SearchRoute(
tabType: selectedTabType ?? settings.effectiveDefaultCreateTabType,
).push(context);
},
);
}
final sandboxSourceUri = ref.watch(
sandboxSourceUriForTabProvider(tabId: tabState.id),
);
return RailAppBarTitleView(
tabState: tabState,
quarterTurns: quarterTurns,
isTabTunneled:
isTabTuneledAsync.hasValue && isTabTuneledAsync.value == true,
siteSettingsBadgeState: siteSettingsBadgeState,
longPressUrlCopy: settings.tabBarLongPressUrlCopy,
containerColor: containerColor,
useCustomColor: useCustomColor,
sandboxSourceUri: sandboxSourceUri,
onSiteSettingsTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.show(SiteSettingsSheet(tabState: tabState));
},
onTitleTap: () async {
await SearchRoute(
tabId: tabState.id,
searchText: searchTextForTab(tabState, sandboxSourceUri),
tabType: tabState.tabMode.toTabType(),
).push(context);
},
);
}
}
class RailAppBarTitleView extends StatelessWidget {
const RailAppBarTitleView({
super.key,
required this.tabState,
required this.quarterTurns,
required this.isTabTunneled,
required this.siteSettingsBadgeState,
required this.onSiteSettingsTap,
required this.onTitleTap,
this.tabIcon,
this.longPressUrlCopy = true,
this.containerColor,
this.useCustomColor = false,
this.sandboxSourceUri,
});
final TabState tabState;
final int quarterTurns;
final bool isTabTunneled;
final SiteSettingsBadgeState siteSettingsBadgeState;
final VoidCallback onSiteSettingsTap;
final VoidCallback onTitleTap;
final Widget? tabIcon;
final bool longPressUrlCopy;
final Color? containerColor;
final bool useCustomColor;
final Uri? sandboxSourceUri;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final appColors = AppColors.of(context);
final containerColor = this.containerColor;
final containerPalette = containerColor != null
? ContainerColors.palette(
context,
containerColor,
useCustomColor: useCustomColor,
)
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ToolbarButton(
onTap: onSiteSettingsTap,
child: Stack(
clipBehavior: Clip.none,
children: [
tabIcon ?? TabIcon(tabState: tabState, iconSize: 24),
if (siteSettingsBadgeState != SiteSettingsBadgeState.hidden)
Positioned(
top: -4,
right: -4,
child: Icon(
siteSettingsBadgeState == SiteSettingsBadgeState.improved
? MdiIcons.shield
: MdiIcons.shieldAlert,
size: 10,
color:
siteSettingsBadgeState == SiteSettingsBadgeState.improved
? Colors.green
: appColors.warningAmber,
),
),
],
),
),
Expanded(
child: GestureDetector(
onTap: onTitleTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
decoration: BoxDecoration(
color: containerColor != null
? containerPalette!.surfaceColor
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
border: containerPalette != null
? Border.all(color: containerPalette.outlineColor)
: null,
),
child: RotatedBox(
quarterTurns: quarterTurns,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (tabState.tabMode is PrivateTabMode) ...[
Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 16,
),
const SizedBox(width: 4),
] else if (tabState.tabMode is IsolatedTabMode) ...[
Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 16,
),
const SizedBox(width: 4),
],
if (isTabTunneled) ...[
const Icon(MdiIcons.tunnelOutline, size: 16),
const SizedBox(width: 4),
],
if (sandboxSourceUri != null) ...[
Icon(
MdiIcons.archiveLockOutline,
color: theme.colorScheme.tertiary,
size: 16,
),
const SizedBox(width: 4),
] else
_SecurityStatusIcon(
tabState: tabState,
size: 16,
containerColor: containerPalette?.accentColor,
),
const SizedBox(width: 6),
Flexible(
child: UriBreadcrumb(
uri: sandboxSourceUri ?? tabState.url,
showHttpScheme: false,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
onTooltipTriggered: longPressUrlCopy
? () async {
await Clipboard.setData(
ClipboardData(
text: (sandboxSourceUri ?? tabState.url)
.toString(),
),
);
}
: null,
),
),
],
),
),
),
),
),
],
);
}
}
class _EmptyRailAddressField extends StatelessWidget {
const _EmptyRailAddressField({
required this.onTap,
required this.quarterTurns,
});
final VoidCallback onTap;
final int quarterTurns;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: RotatedBox(
quarterTurns: quarterTurns,
child: Text(
'Search or enter URL',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
),
),
);
}
}
class _SecurityStatusIcon extends StatelessWidget {
const _SecurityStatusIcon({
required this.tabState,
@@ -159,6 +159,86 @@ class BrowserBottomAppBar extends StatelessWidget {
Size get preferredSize => _size;
}
/// Vertical side-rail wrapper (left/right positions). Exposes a fixed content
/// [preferredSize] width; the caller adds the horizontal safe-area inset on the
/// rail's outer edge to compute the browser content offset.
class BrowserSideRail extends ConsumerWidget {
final bool showContextualToolbar;
final int quickTabSwitcherRowCount;
final bool isSmallWebMode;
/// Which edge the rail is docked to ([TabBarPosition.left] or
/// [TabBarPosition.right]).
final TabBarPosition position;
late final BrowserTabBar _tabBar;
late final _size = Size.fromWidth(_tabBar.getToolbarWidth());
BrowserSideRail({
super.key,
required this.showContextualToolbar,
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
required this.position,
}) {
_tabBar = BrowserTabBar(
displayedSheet: null,
showMainToolbar: true,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
enableGestures: true,
hideMainToolbarButtonsDuplicatedInContextualToolbar: showContextualToolbar,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLeft = position == TabBarPosition.left;
// Tint the outer fill with the active container's surface color (same tint
// the content and BrowserSystemBars use) so the rail's system safe-area
// strips (status/nav bar, docked-edge notch) blend with the rail instead
// of showing a neutral surfaceContainer gap. Falls back to surfaceContainer
// when no container is active.
final selectedTabId = ref.watch(selectedTabProvider);
final showContainerUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
);
final containerColor = ref.watch(
watchTabContainerDataProvider(
selectedTabId,
).select((data) => data.value?.color),
);
final useCustomColor = ref.watch(
watchTabContainerDataProvider(
selectedTabId,
).select((data) => data.value?.metadata.useCustomColor ?? false),
);
final effectiveContainerColor = (showContainerUi && containerColor != null)
? containerColor
: null;
final tintColor = effectiveContainerColor != null
? ContainerColors.palette(
context,
effectiveContainerColor,
useCustomColor: useCustomColor,
).surfaceColor
: Theme.of(context).colorScheme.surfaceContainer;
return ColoredBox(
color: tintColor,
child: SafeArea(
left: isLeft,
right: !isLeft,
child: SizedBox(width: _size.width, child: _tabBar),
),
);
}
Size get preferredSize => _size;
}
class BrowserTabBar extends HookConsumerWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
@@ -182,6 +262,14 @@ class BrowserTabBar extends HookConsumerWidget {
static const contextualToolabarHeight = 54.0;
static const quickTabSwitcherHeight = 48.0;
/// Content width of the vertical side rail (excludes the system safe-area
/// inset on the rail's outer edge, which is added by the caller). Kept equal
/// to [kToolbarHeight] so the rail reuses the same base sizing as the
/// horizontal bar.
static const sideRailWidth = kToolbarHeight;
double getToolbarWidth() => sideRailWidth;
bool get displayAppBar =>
showMainToolbar &&
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
@@ -249,9 +337,50 @@ class BrowserTabBar extends HookConsumerWidget {
final stackingMode = settings.effectiveTabBarStackingMode();
final tabBarPosition = settings.tabBarPosition;
final isVertical = tabBarPosition.isVertical;
final switcherAxis = tabBarPosition.axis;
// Left rail reads bottom-to-top, right rail top-to-bottom.
final railQuarterTurns = tabBarPosition == TabBarPosition.left ? 3 : 1;
final dragStartPosition = useRef(Offset.zero);
// Swipe along the primary switch axis moves between tabs. [delta] is
// (dragStart - dragEnd) along that axis; its sign chooses prev/next.
Future<void> switchTabsBy(double delta) async {
final selectedTab = ref.read(selectedTabProvider);
final setting = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
if (selectedTab == null) return;
switch (setting.tabBarSwipeAction) {
case TabBarSwipeAction.switchLastOpened:
await ref
.read(tabRepositoryProvider.notifier)
.selectPreviouslyOpenedTab(selectedTab);
case TabBarSwipeAction.navigateOrderedTabs:
if (delta < 0) {
await ref
.read(tabRepositoryProvider.notifier)
.selectPreviousTab(selectedTab);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.selectNextTab(selectedTab);
}
}
}
void dismissToolbar() {
if (ref.read(bottomSheetControllerProvider) == null) {
unawaited(HapticFeedback.lightImpact());
ref
.read(toolbarVisibilityControllerProvider(selectedTabId).notifier)
.dismiss();
}
}
final showTabTitle = displayedSheet is! ViewTabsSheet;
final effectiveContainerColor =
@@ -271,6 +400,8 @@ class BrowserTabBar extends HookConsumerWidget {
: null;
return BrowserTabBarView(
axis: switcherAxis,
railOnLeft: tabBarPosition == TabBarPosition.left,
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0,
@@ -278,7 +409,13 @@ class BrowserTabBar extends HookConsumerWidget {
displayQuickTabSwitcher: displayQuickTabSwitcher,
backgroundColor: effectiveContainerPalette?.surfaceColor,
title: showTabTitle
? settings.tabBarLayout == TabBarLayout.compact
? isVertical
? RailAppBarTitle(
quarterTurns: railQuarterTurns,
containerColor: effectiveContainerColor,
useCustomColor: effectiveUseCustomColor,
)
: settings.tabBarLayout == TabBarLayout.compact
? CompactAppBarTitle(
containerColor: effectiveContainerColor,
useCustomColor: effectiveUseCustomColor,
@@ -289,7 +426,7 @@ class BrowserTabBar extends HookConsumerWidget {
)
: null,
actions: [
const PinnedAddonBar(),
PinnedAddonBar(axis: switcherAxis),
if (isSmallWebMode)
ReaderButton(
buttonBuilder: (isLoading, readerActive, icon) => ToolbarButton(
@@ -314,31 +451,57 @@ class BrowserTabBar extends HookConsumerWidget {
],
quickTabSwitcher: switch (stackingMode) {
TabBarStackingMode.disabled => const SizedBox.shrink(),
TabBarStackingMode.lastUsedTabs => const QuickTabSwitcher(
TabBarStackingMode.lastUsedTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
axis: switcherAxis,
),
TabBarStackingMode.containerTabs => const QuickTabSwitcher(
TabBarStackingMode.containerTabs => QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
axis: switcherAxis,
),
TabBarStackingMode.accordion => AccordionQuickTabSwitcher(
axis: switcherAxis,
),
TabBarStackingMode.accordion => const AccordionQuickTabSwitcher(),
// History fallback only on the MRU row, so empty-state history
// chips don't show twice.
TabBarStackingMode.twoLevel => const Column(
mainAxisSize: MainAxisSize.min,
children: [
QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
enableHistoryFallback: false,
),
QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
),
],
),
// 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,
),
],
),
},
contextualToolbar: ContextualToolbar(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
axis: switcherAxis,
),
onHorizontalDragStart: !enableGestures
? null
@@ -349,30 +512,21 @@ class BrowserTabBar extends HookConsumerWidget {
? null
: (details) async {
final distance = dragStartPosition.value - details.globalPosition;
const dismissThreshold = kToolbarHeight * 0.5;
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
final selectedTab = ref.read(selectedTabProvider);
final setting = await ref
.read(generalSettingsRepositoryProvider.notifier)
.fetchSettings();
if (selectedTab != null) {
switch (setting.tabBarSwipeAction) {
case TabBarSwipeAction.switchLastOpened:
await ref
.read(tabRepositoryProvider.notifier)
.selectPreviouslyOpenedTab(selectedTab);
case TabBarSwipeAction.navigateOrderedTabs:
if (distance.dx < 0) {
await ref
.read(tabRepositoryProvider.notifier)
.selectPreviousTab(selectedTab);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.selectNextTab(selectedTab);
}
}
if (isVertical) {
// Rail: horizontal swipe dismisses toward the docked edge.
// distance = start - end, so a leftward swipe is positive dx.
final shouldDismiss = switch (tabBarPosition) {
TabBarPosition.left => distance.dx > dismissThreshold,
TabBarPosition.right => distance.dx < -dismissThreshold,
_ => false,
};
if (shouldDismiss) dismissToolbar();
} else {
// Horizontal bar: horizontal swipe switches tabs.
if (distance.dx.abs() > 50 && distance.dy.abs() < 20) {
await switchTabsBy(distance.dx);
}
}
},
@@ -383,10 +537,18 @@ class BrowserTabBar extends HookConsumerWidget {
},
onVerticalDragEnd: !enableGestures
? null
: (details) {
: (details) async {
final distance = dragStartPosition.value - details.globalPosition;
// Swipe direction for dismiss depends on toolbar position:
if (isVertical) {
// Rail: vertical swipe switches tabs.
if (distance.dy.abs() > 50 && distance.dx.abs() < 20) {
await switchTabsBy(distance.dy);
}
return;
}
// Horizontal bar dismiss direction depends on position:
// - Bottom bar: swipe down to dismiss (positive distance.dy)
// - Top bar: swipe up to dismiss (negative distance.dy)
const dismissThreshold = kToolbarHeight * 0.5;
@@ -397,18 +559,9 @@ class BrowserTabBar extends HookConsumerWidget {
TabBarPosition.top =>
!distance.dy.isNegative &&
distance.dy.abs() > dismissThreshold,
_ => false,
};
if (shouldDismiss &&
ref.read(bottomSheetControllerProvider) == null) {
unawaited(HapticFeedback.lightImpact());
ref
.read(
toolbarVisibilityControllerProvider(
selectedTabId,
).notifier,
)
.dismiss();
}
if (shouldDismiss) dismissToolbar();
},
);
}
@@ -427,12 +580,21 @@ class BrowserTabBarView extends StatelessWidget {
required this.actions,
required this.quickTabSwitcher,
required this.contextualToolbar,
this.axis = Axis.horizontal,
this.railOnLeft = true,
this.onHorizontalDragStart,
this.onHorizontalDragEnd,
this.onVerticalDragStart,
this.onVerticalDragEnd,
});
/// Layout orientation. Vertical renders the side-rail form.
final Axis axis;
/// For the vertical rail, whether it is docked to the left edge (affects
/// nothing structural here yet; reserved for edge-specific tweaks).
final bool railOnLeft;
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
@@ -454,6 +616,54 @@ class BrowserTabBarView extends StatelessWidget {
final effectiveBackgroundColor =
backgroundColor ?? colorScheme.surfaceContainer;
if (axis == Axis.vertical) {
return GestureDetector(
onHorizontalDragStart: onHorizontalDragStart,
onHorizontalDragEnd: onHorizontalDragEnd,
onVerticalDragStart: onVerticalDragStart,
onVerticalDragEnd: onVerticalDragEnd,
child: ColoredBox(
color: effectiveBackgroundColor,
child: Column(
children: [
// Literal section order (switcher → URL+actions → contextual);
// the switcher is the flexible scroll region.
if (showQuickTabSwitcherBar)
Expanded(
flex: 3,
child: Visibility(
visible: displayQuickTabSwitcher,
maintainState: true,
child: quickTabSwitcher,
),
),
if (showMainToolbar)
Expanded(
flex: 2,
child: Visibility(
visible: displayAppBar,
maintainState: true,
// Horizontal inset so the URL pile and action buttons don't
// sit flush against the rail edges, matching the breathing
// room the horizontal bar's title/actions get.
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
children: [
Expanded(child: title ?? const SizedBox.shrink()),
...actions,
],
),
),
),
),
if (showContextualToolbar) contextualToolbar,
],
),
),
);
}
return GestureDetector(
// Tap handling moved to AppBarTitle for split icon/title behavior
onHorizontalDragStart: onHorizontalDragStart,
@@ -505,10 +715,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
/// chips don't show twice.
final bool enableHistoryFallback;
/// Direction the chips list flows. Vertical for the side rail.
final Axis axis;
const QuickTabSwitcher({
super.key,
required this.quickTabSwitcherMode,
this.enableHistoryFallback = true,
this.axis = Axis.horizontal,
});
@override
@@ -516,11 +730,13 @@ class QuickTabSwitcher extends HookConsumerWidget {
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
);
final showTitles = ref.watch(
final showTitlesSetting = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
// Titles can't fit the narrow vertical rail; force icon-only chips there.
final showTitles = axis != Axis.vertical && showTitlesSetting;
final titleMaxWidth = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherTitleWidth,
@@ -677,6 +893,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
scrollController: chipScrollController,
scrollKey: scrollKey,
activeItemKey: activeItemKey.value,
axis: axis,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
hierarchyGlyphs: hierarchyGlyphs,
@@ -764,8 +981,12 @@ class QuickTabSwitcherView extends StatelessWidget {
this.onCloseItem,
this.onReorderItem,
this.reorderableItemCount = 0,
this.axis = Axis.horizontal,
});
/// Direction the chips flow. Vertical for the side rail.
final Axis axis;
final List<QuickTabSwitcherItem> availableItems;
final QuickTabSwitcherItem? activeItem;
final ScrollController scrollController;
@@ -803,25 +1024,36 @@ class QuickTabSwitcherView extends StatelessWidget {
bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
/// Whether [item]'s chip shows a close button.
/// Whether [item]'s chip shows a close button. Never on the narrow vertical
/// rail: an icon-only chip has no room for a close button beside it (it
/// overflows). Closing stays available via the long-press menu.
bool _canShowCloseButton(QuickTabSwitcherItem item) =>
!_isVertical &&
onCloseItem != null &&
!item.isHistory &&
!item.isPlaceholder &&
(showCloseButtonOnAllTabs || item.isActive);
bool get _isVertical => axis == Axis.vertical;
@override
Widget build(BuildContext context) {
if (availableItems.isEmpty) {
// Hold the 48px row slot: in two-level stacking an empty row must not
// Hold the 48px slot: in two-level stacking an empty row must not
// collapse, since the toolbar height already accounts for both rows.
return const SizedBox(height: 48);
// On the rail the cross-axis width is fixed and the (vertical) list
// fills the available height.
return _isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
padding: _isVertical
? const EdgeInsets.symmetric(vertical: 4.0)
: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
height: 48,
// Vertical fills both axes of the rail content column; horizontal keeps
// the fixed 48px row height.
height: _isVertical ? double.maxFinite : 48,
width: double.maxFinite,
child: _reorderEnabled
? _buildReorderableList(context)
@@ -838,6 +1070,7 @@ class QuickTabSwitcherView extends StatelessWidget {
scrollController: scrollController,
scrollKey: scrollKey,
activeItemKey: activeItemKey,
scrollDirection: axis,
cacheExtent: 500,
itemId: (item) => item.id,
selectedItem: activeItem,
@@ -867,7 +1100,7 @@ class QuickTabSwitcherView extends StatelessWidget {
return ReorderableListView.builder(
key: scrollKey,
scrollController: scrollController,
scrollDirection: Axis.horizontal,
scrollDirection: axis,
buildDefaultDragHandles: true,
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
itemCount: reorderableCount,
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -30,6 +31,8 @@ class DraggableFab extends HookConsumerWidget {
final bool bottomToolbarVisible;
final double bottomAppBarHeight;
final double bottomSafeArea;
final double leftReservedWidth;
final double rightReservedWidth;
const DraggableFab({
super.key,
@@ -38,6 +41,8 @@ class DraggableFab extends HookConsumerWidget {
required this.bottomToolbarVisible,
required this.bottomAppBarHeight,
required this.bottomSafeArea,
this.leftReservedWidth = 0.0,
this.rightReservedWidth = 0.0,
});
static const _edgePadding = 16.0;
@@ -82,11 +87,20 @@ class DraggableFab extends HookConsumerWidget {
final defaultBottom = bottomToolbarVisible
? bottomAppBarHeight + _edgePadding
: _edgePadding + bottomSafeArea;
const defaultRight = _edgePadding;
final defaultRight = _edgePadding + rightReservedWidth;
// Current position: custom if set, otherwise default
final currentRight = customOffset.value?.dx ?? defaultRight;
final currentBottom = customOffset.value?.dy ?? defaultBottom;
final rawRight = customOffset.value?.dx ?? defaultRight;
final rawBottom = customOffset.value?.dy ?? defaultBottom;
final currentOffset = _clampToBounds(
Offset(rawRight, rawBottom),
screenSize: screenSize,
padding: padding,
fabWidth: fabWidth,
fabHeight: fabHeight,
);
final currentRight = currentOffset.dx;
final currentBottom = currentOffset.dy;
return Positioned(
right: currentRight,
@@ -145,19 +159,27 @@ class DraggableFab extends HookConsumerWidget {
required double fabHeight,
}) {
const minEdgePadding = 8.0;
final minRight =
math.max(padding.right, rightReservedWidth) + minEdgePadding;
final maxRight = math.max(
minRight,
screenSize.width -
fabWidth -
math.max(padding.left, leftReservedWidth) -
minEdgePadding,
);
final minBottom = padding.bottom + minEdgePadding;
final maxBottom = math.max(
minBottom,
screenSize.height - fabHeight - padding.top - minEdgePadding,
);
// Distances from the bottom-right corner: keep at least the safe-area inset
// plus a margin on the near edge, and leave room for the (possibly stacked)
// FAB on the far edge so it can't be dragged off-screen.
return Offset(
offset.dx.clamp(
padding.right + minEdgePadding,
screenSize.width - fabWidth - padding.left - minEdgePadding,
),
offset.dy.clamp(
padding.bottom + minEdgePadding,
screenSize.height - fabHeight - padding.top - minEdgePadding,
),
offset.dx.clamp(minRight, maxRight),
offset.dy.clamp(minBottom, maxBottom),
);
}
}
@@ -50,10 +50,14 @@ import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
/// "expanded" — its tabs appear inline right after its header. Tapping
/// another header selects that container, collapsing the previous group.
class AccordionQuickTabSwitcher extends HookConsumerWidget {
const AccordionQuickTabSwitcher({super.key});
const AccordionQuickTabSwitcher({super.key, this.axis = Axis.horizontal});
/// Direction the accordion flows. Vertical for the side rail.
final Axis axis;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isVertical = axis == Axis.vertical;
final scrollController = useScrollController();
final activeChipKey = useRef(GlobalKey());
final isUserScrolling = useRef(false);
@@ -63,11 +67,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
return userScrollTimer.value?.cancel;
}, []);
final showTitles = ref.watch(
final showTitlesSetting = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
// Titles can't fit the narrow vertical rail; force icon-only chips there.
final showTitles = !isVertical && showTitlesSetting;
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
);
@@ -172,8 +178,13 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
Widget buildTabChip(QuickTabSwitcherItem item) {
final isSelected = item.isActive;
// The narrow rail can't fit a close button beside the icon-only chip; it
// overflows (and the active tab's thick border makes it worse). Closing
// stays available via the long-press menu.
final canClose =
!item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive);
!isVertical &&
!item.isPlaceholder &&
(showCloseButtonOnAllTabs || item.isActive);
final chip = QuickTabSwitcherChip(
item: item,
@@ -300,9 +311,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
);
if (entries.isEmpty) {
// Hold the 48px row slot; the bar visibility is decided upstream by
// Hold the 48px slot; the bar visibility is decided upstream by
// quickTabSwitcherRowCountProvider.
return const SizedBox(height: 48);
return isVertical ? const SizedBox(width: 48) : const SizedBox(height: 48);
}
return NotificationListener<UserScrollNotification>(
@@ -315,10 +326,12 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
return false;
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
padding: isVertical
? const EdgeInsets.symmetric(vertical: 4.0)
: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
height: 48,
width: double.maxFinite,
height: isVertical ? double.maxFinite : 48,
width: isVertical ? 48 : double.maxFinite,
child: FadingScroll(
controller: scrollController,
fadingSize: 15,
@@ -326,7 +339,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
return ListView.builder(
key: const PageStorageKey('quick_tab_switcher_accordion'),
controller: controller,
scrollDirection: Axis.horizontal,
scrollDirection: axis,
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
itemCount: entries.length,
itemBuilder: (context, index) {
@@ -334,6 +347,9 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
final child = switch (entry) {
_AccordionHeaderEntry() => _AccordionHeaderChip(
entry: entry,
// The narrow rail can't fit the container title; show the
// container icon avatar + count badge only.
showTitle: !isVertical,
onSelected: () => selectContainer(entry.container?.id),
),
_AccordionTabEntry(:final item) => buildTabChip(item),
@@ -346,6 +362,7 @@ class AccordionQuickTabSwitcher extends HookConsumerWidget {
child: _TraySlice(
position: trayPositions[index],
fill: trayFill,
axis: axis,
child: child,
),
);
@@ -407,7 +424,15 @@ class _AccordionHeaderChip extends StatelessWidget {
final _AccordionHeaderEntry entry;
final VoidCallback onSelected;
const _AccordionHeaderChip({required this.entry, required this.onSelected});
/// When false (e.g. the narrow vertical rail) the container title is hidden
/// and only the icon avatar + count badge are shown, so the chip fits.
final bool showTitle;
const _AccordionHeaderChip({
required this.entry,
required this.onSelected,
this.showTitle = true,
});
@override
Widget build(BuildContext context) {
@@ -446,10 +471,49 @@ class _AccordionHeaderChip extends StatelessWidget {
)
: null;
final iconAvatar = container != null
? buildContainerChipAvatar(context, container, true)
: Icon(MdiIcons.folderHidden, color: nullForeground);
// Same fill regardless of selection — the tray (added when expanded)
// is what signals the active container, not a header recolor.
final side = BorderSide(width: 2, color: fill);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: side,
);
if (!showTitle) {
// Narrow rail: no room for the avatar slot + title + trailing badge side
// by side (the badge gets clipped). Stack the container icon over the
// count badge inside the label instead, dropping the avatar slot.
return FilterChip(
labelPadding: EdgeInsets.zero,
label: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (iconAvatar != null) iconAvatar,
if (countBadge != null) ...[
if (iconAvatar != null) const SizedBox(height: 4),
countBadge,
],
],
),
color: WidgetStatePropertyAll(fill),
selected: false,
showCheckmark: false,
onSelected: (value) {
if (value) {
onSelected();
}
},
side: side,
shape: shape,
);
}
return FilterChip(
avatar: container != null
? buildContainerChipAvatar(context, container, true)
: Icon(MdiIcons.folderHidden, color: nullForeground),
avatar: iconAvatar,
label: container != null
? buildContainerChipLabel(
context,
@@ -468,8 +532,6 @@ class _AccordionHeaderChip extends StatelessWidget {
),
),
),
// Same fill regardless of selection — the tray (added when expanded)
// is what signals the active container, not a header recolor.
color: WidgetStatePropertyAll(fill),
selected: false,
showCheckmark: false,
@@ -478,11 +540,8 @@ class _AccordionHeaderChip extends StatelessWidget {
onSelected();
}
},
side: BorderSide(width: 2, color: fill),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(width: 2, color: fill),
),
side: side,
shape: shape,
);
}
}
@@ -500,11 +559,13 @@ class _TraySlice extends StatelessWidget {
final _TrayPosition position;
final Color fill;
final Widget child;
final Axis axis;
const _TraySlice({
required this.position,
required this.fill,
required this.child,
this.axis = Axis.horizontal,
});
/// Corner radius of the chips, matched by the tray so it hugs the first and
@@ -513,40 +574,59 @@ class _TraySlice extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isVertical = axis == Axis.vertical;
if (position == _TrayPosition.none) {
// Standalone container header: regular inter-chip spacing, vertically
// centered to line up with the tray slices.
// Standalone container header: regular inter-chip spacing, centered
// on the cross axis to line up with the tray slices.
return Padding(
padding: const EdgeInsets.fromLTRB(0.0, 2.0, 8.0, 2.0),
padding: isVertical
? const EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 8.0)
: const EdgeInsets.fromLTRB(0.0, 2.0, 8.0, 2.0),
child: child,
);
}
final borderRadius = switch (position) {
_TrayPosition.solo => const BorderRadius.all(_radius),
_TrayPosition.start => const BorderRadius.horizontal(left: _radius),
_TrayPosition.end => const BorderRadius.horizontal(right: _radius),
_TrayPosition.middle || _TrayPosition.none => BorderRadius.zero,
final borderRadius = switch ((position, isVertical)) {
(_TrayPosition.solo, _) => const BorderRadius.all(_radius),
(_TrayPosition.start, false) => const BorderRadius.horizontal(
left: _radius,
),
(_TrayPosition.end, false) => const BorderRadius.horizontal(
right: _radius,
),
(_TrayPosition.start, true) => const BorderRadius.vertical(top: _radius),
(_TrayPosition.end, true) => const BorderRadius.vertical(bottom: _radius),
(_TrayPosition.middle, _) || (_TrayPosition.none, _) => BorderRadius.zero,
};
final isRightEdge =
final isTrailingEdge =
position == _TrayPosition.end || position == _TrayPosition.solo;
return Padding(
padding: EdgeInsets.only(
top: 2.0,
bottom: 2.0,
// Transparent gap after the tray so a following standalone header
// doesn't butt up against the rounded right edge.
right: isRightEdge ? 8.0 : 0.0,
),
// Transparent gap after the tray so a following standalone header
// doesn't butt up against the rounded trailing edge.
padding: isVertical
? EdgeInsets.only(
left: 2.0,
right: 2.0,
bottom: isTrailingEdge ? 8.0 : 0.0,
)
: EdgeInsets.only(
top: 2.0,
bottom: 2.0,
right: isTrailingEdge ? 8.0 : 0.0,
),
child: SizedBox(
height: 44.0,
height: isVertical ? null : 44.0,
width: isVertical ? 44.0 : null,
child: Container(
decoration: BoxDecoration(color: fill, borderRadius: borderRadius),
// A small inset on every side so the first/last chip get the same
// breathing room from the tray edge as the inter-chip seam gaps.
padding: const EdgeInsets.symmetric(horizontal: 4.0),
// A small inset so the first/last chip get the same breathing room
// from the tray edge as the inter-chip seam gaps.
padding: isVertical
? const EdgeInsets.symmetric(vertical: 4.0)
: const EdgeInsets.symmetric(horizontal: 4.0),
child: Center(child: child),
),
),
@@ -203,7 +203,7 @@ final class BrowsingDownloadsProvider
}
}
String _$browsingDownloadsHash() => r'938ae4d26b4e0a3f428d3d530a71cb5d8720f317';
String _$browsingDownloadsHash() => r'a526dab6915085e6fb47a33d5acc892c3f8e17b2';
// **************************************************************************
// JsonGenerator
@@ -49,7 +49,7 @@ final class ContainerHistoryRepositoryProvider
}
String _$containerHistoryRepositoryHash() =>
r'8872a421664c0ac8d6b0bd3ca76e1300677b0873';
r'89bd0a552b47643a8ef8c182901af3811e96c271';
/// Mutations that combine the visit→container relation (`visit_container`) with
/// Mozilla Places, the source of truth for the visits themselves.
@@ -69,7 +69,7 @@ final class VisitContainerRecorderProvider
}
String _$visitContainerRecorderHash() =>
r'2d5742816ec08bc37edf2b56a933b6a324e8b038';
r'13ed2d5a188efd2a4419585f4989e782263884aa';
/// Records the visit→container relation. Mozilla Places owns the visit itself;
/// on each Places visit the native [WebLibreHistoryDelegate] forwards the
@@ -203,6 +203,16 @@ class _TabBarPositionSection extends HookConsumerWidget {
title: Text('Bottom'),
subtitle: Text('Tab bar with auto-hide support'),
),
RadioListTile.adaptive(
value: TabBarPosition.left,
title: Text('Left'),
subtitle: Text('Vertical side rail, swipe to hide'),
),
RadioListTile.adaptive(
value: TabBarPosition.right,
title: Text('Right'),
subtitle: Text('Vertical side rail, swipe to hide'),
),
],
),
),
@@ -72,13 +72,20 @@ class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
double get _headerHeight => compact ? 0.0 : _kHeaderHeight;
@override
double get minExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
/// Fixed preview height for the vertical rail (the rail flows along the
/// height, so it can't be derived from stacked section heights).
static const _kRailPreviewHeight = 220.0;
static const _kCompactRailPreviewHeight = 140.0;
double get _contentHeight => settings.tabBarPosition.isVertical
? (compact ? _kCompactRailPreviewHeight : _kRailPreviewHeight)
: _baseHeight + _toolbarHeight;
@override
double get maxExtent =>
_baseHeight + _toolbarHeight + _headerHeight + padding.vertical;
double get minExtent => _contentHeight + _headerHeight + padding.vertical;
@override
double get maxExtent => _contentHeight + _headerHeight + padding.vertical;
@override
Widget build(
@@ -200,12 +207,17 @@ class TabBarPreviewCard extends HookWidget {
},
);
Widget buildQuickTabSwitcherRow(ScrollController scrollController) {
Widget buildQuickTabSwitcherRow(
ScrollController scrollController, {
Axis axis = Axis.horizontal,
}) {
return QuickTabSwitcherView(
availableItems: previewQuickItems,
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
scrollController: scrollController,
showTitles: settings.quickTabSwitcherShowTitles,
axis: axis,
showTitles:
axis != Axis.vertical && settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi,
hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs,
titleMaxWidth: settings.quickTabSwitcherTitleWidth,
@@ -217,24 +229,25 @@ class TabBarPreviewCard extends HookWidget {
);
}
Widget buildQuickTabSwitcher() {
Widget buildQuickTabSwitcher({Axis axis = Axis.horizontal}) {
// The accordion preview reuses the single-row layout; container header
// chips need live container data that the static preview doesn't have.
if (settings.effectiveTabBarStackingMode() ==
TabBarStackingMode.twoLevel) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
buildQuickTabSwitcherRow(quickTabsController),
buildQuickTabSwitcherRow(quickTabsSecondRowController),
],
);
final rows = [
buildQuickTabSwitcherRow(quickTabsController, axis: axis),
buildQuickTabSwitcherRow(quickTabsSecondRowController, axis: axis),
];
return axis == Axis.vertical
? Column(children: [for (final row in rows) Expanded(child: row)])
: Column(mainAxisSize: MainAxisSize.min, children: rows);
}
return buildQuickTabSwitcherRow(quickTabsController);
return buildQuickTabSwitcherRow(quickTabsController, axis: axis);
}
Widget buildContextualToolbar() {
Widget buildContextualToolbar({Axis axis = Axis.horizontal}) {
return ContextualToolbarView(
axis: axis,
buttons: [
NavigateBackButtonView(
canGoBack: true,
@@ -307,43 +320,91 @@ class TabBarPreviewCard extends HookWidget {
contextualToolbar: buildContextualToolbar(),
);
final previewContent = Container(
clipBehavior: Clip.antiAlias,
final isRailPreview = settings.tabBarPosition.isVertical;
final railToolbar = BrowserTabBarView(
axis: Axis.vertical,
railOnLeft: settings.tabBarPosition == TabBarPosition.left,
showMainToolbar: true,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor:
previewContainerPalette?.surfaceColor ?? colorScheme.surfaceContainer,
title: _RailPreviewTitle(
tabState: previewTabState,
quarterTurns: settings.tabBarPosition == TabBarPosition.left ? 3 : 1,
),
actions: mainToolbarActions,
quickTabSwitcher: buildQuickTabSwitcher(axis: Axis.vertical),
contextualToolbar: buildContextualToolbar(axis: Axis.vertical),
);
final pageContentBox = Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: compact
? colorScheme.surface.withValues(alpha: 0.7)
: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(4),
? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7)
: colorScheme.surfaceContainerLowest,
border: Border.symmetric(
horizontal: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Column(
children: [
if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar,
Container(
height: compact ? 40 : 72,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
color: compact
? colorScheme.surfaceContainerLowest.withValues(alpha: 0.7)
: colorScheme.surfaceContainerLowest,
border: Border.symmetric(
horizontal: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Text(
'Page Content',
style: Theme.of(context).textTheme.labelMedium,
),
),
if (settings.tabBarPosition == TabBarPosition.top)
topBottomToolbar
else
bottomCombinedToolbar,
],
child: Text(
'Page Content',
style: Theme.of(context).textTheme.labelMedium,
),
);
final Widget previewContent;
if (isRailPreview) {
final rail = SizedBox(
width: BrowserTabBar.sideRailWidth,
child: railToolbar,
);
final railOnLeft = settings.tabBarPosition == TabBarPosition.left;
previewContent = Container(
clipBehavior: Clip.antiAlias,
height: compact ? 140 : 220,
decoration: BoxDecoration(
color: compact
? colorScheme.surface.withValues(alpha: 0.7)
: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: railOnLeft
? [rail, Expanded(child: pageContentBox)]
: [Expanded(child: pageContentBox), rail],
),
);
} else {
previewContent = Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: compact
? colorScheme.surface.withValues(alpha: 0.7)
: colorScheme.surface,
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(4),
),
child: Column(
children: [
if (settings.tabBarPosition == TabBarPosition.top) topMainToolbar,
SizedBox(height: compact ? 40 : 72, child: pageContentBox),
if (settings.tabBarPosition == TabBarPosition.top)
topBottomToolbar
else
bottomCombinedToolbar,
],
),
);
}
if (compact) {
return previewContent;
}
@@ -409,4 +470,25 @@ class _CompactPreviewTitle extends StatelessWidget {
}
}
class _RailPreviewTitle extends StatelessWidget {
const _RailPreviewTitle({required this.tabState, required this.quarterTurns});
final TabState tabState;
final int quarterTurns;
@override
Widget build(BuildContext context) {
return RailAppBarTitleView(
tabState: tabState,
quarterTurns: quarterTurns,
isTabTunneled: false,
siteSettingsBadgeState: SiteSettingsBadgeState.hidden,
onSiteSettingsTap: _noop,
onTitleTap: _noop,
tabIcon: const Icon(MdiIcons.web, size: 24),
longPressUrlCopy: false,
);
}
}
void _noop() {}
@@ -87,7 +87,23 @@ enum TabIntentOpenSetting { regular, private, isolated, ask }
enum TabDirection { newestFirst, oldestFirst }
enum TabBarPosition { top, bottom }
enum TabBarPosition {
top,
bottom,
left,
right;
/// Whether the tab bar is rendered as a vertical side rail (left/right)
/// rather than a horizontal bar (top/bottom).
bool get isVertical =>
this == TabBarPosition.left || this == TabBarPosition.right;
/// Whether the tab bar is rendered as a horizontal bar (top/bottom).
bool get isHorizontal => !isVertical;
/// Main axis along which the bar's content flows.
Axis get axis => isVertical ? Axis.vertical : Axis.horizontal;
}
enum TabBarLayout { withTitle, compact }
@@ -1246,6 +1246,8 @@ const _$TabBarSwipeActionEnumMap = {
const _$TabBarPositionEnumMap = {
TabBarPosition.top: 'top',
TabBarPosition.bottom: 'bottom',
TabBarPosition.left: 'left',
TabBarPosition.right: 'right',
};
const _$TabBarLayoutEnumMap = {
@@ -145,6 +145,10 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final GlobalKey? activeItemKey;
final double? cacheExtent;
/// Direction the chip list scrolls / lays out. Horizontal for the standard
/// top/bottom tab bar; vertical for the side rail.
final Axis scrollDirection;
/// Key applied to the underlying scroll view. Supply a [PageStorageKey] to
/// preserve the scroll offset across rebuilds/remounts (e.g. when a host
/// widget is torn down and recreated by a bottom sheet open/close).
@@ -193,6 +197,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
this.activeItemKey,
this.cacheExtent = 0,
this.scrollKey,
this.scrollDirection = Axis.horizontal,
super.key,
});
@@ -221,10 +226,14 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final prefixCount = prefixListItems.length;
final totalCount = prefixCount + items.length;
final isVertical = scrollDirection == Axis.vertical;
Widget buildPrefix(int index) {
return Padding(
key: ValueKey('__selectable_chips_prefix_$index'),
padding: const EdgeInsets.only(top: 4.0, right: 4),
padding: isVertical
? const EdgeInsets.only(bottom: 4.0)
: const EdgeInsets.only(top: 4.0, right: 4),
child: prefixListItems[index],
);
}
@@ -238,7 +247,9 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
final canDeleteItem =
enableDelete && (deco?.canDelete?.call(item) ?? true);
final child = Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
padding: isVertical
? const EdgeInsets.only(bottom: 8.0)
: const EdgeInsets.only(right: 8.0, top: 4.0),
child: _BadgeWrapper(
count: itemBadgeCount?.call(item),
child: _GestureWrapper(
@@ -307,7 +318,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
scrollCacheExtent: cacheExtent.mapNotNull(
(extent) => ScrollCacheExtent.pixels(extent),
),
scrollDirection: Axis.horizontal,
scrollDirection: scrollDirection,
itemCount: totalCount,
itemBuilder: (context, index) => index < prefixCount
? buildPrefix(index)
@@ -320,7 +331,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
scrollCacheExtent: cacheExtent.mapNotNull(
(extent) => ScrollCacheExtent.pixels(extent),
),
scrollDirection: Axis.horizontal,
scrollDirection: scrollDirection,
buildDefaultDragHandles: false,
itemCount: totalCount,
itemBuilder: (context, index) => index < prefixCount