improved quick tab switcher & general tab design/logic;

This commit is contained in:
Fabian Freund
2026-06-12 03:47:20 +02:00
parent 83c78b53d3
commit 3c5f472bdf
35 changed files with 2715 additions and 768 deletions
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
part 'pending_tab_selection.g.dart';
/// Queues the selection of a tab that exists in the local DB but has not
/// been delivered by the native session restore yet (placeholder chips at
/// cold start). The queued tab is selected as soon as its native state
/// arrives; the queue clears itself if restore finishes without it.
@Riverpod(keepAlive: true)
class PendingTabSelection extends _$PendingTabSelection {
void queue(String tabId) {
if (state != tabId) {
state = tabId;
}
}
void clear() {
state = null;
}
@override
String? build() {
ref.listen(
tabStatesProvider,
(previous, next) {
final pendingTabId = state;
if (pendingTabId != null && next.containsKey(pendingTabId)) {
state = null;
unawaited(
ref.read(tabRepositoryProvider.notifier).selectTab(pendingTabId),
);
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to tabStatesProvider',
error: error,
stackTrace: stackTrace,
);
},
);
// Stale-tab safety: when restore finishes and the queued id never
// appeared, the tab no longer exists.
ref.listen(browserRestoreCompleteProvider, (previous, next) {
if (next &&
state != null &&
!ref.read(tabStatesProvider).containsKey(state)) {
state = null;
}
});
return null;
}
}
@@ -0,0 +1,80 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'pending_tab_selection.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Queues the selection of a tab that exists in the local DB but has not
/// been delivered by the native session restore yet (placeholder chips at
/// cold start). The queued tab is selected as soon as its native state
/// arrives; the queue clears itself if restore finishes without it.
@ProviderFor(PendingTabSelection)
final pendingTabSelectionProvider = PendingTabSelectionProvider._();
/// Queues the selection of a tab that exists in the local DB but has not
/// been delivered by the native session restore yet (placeholder chips at
/// cold start). The queued tab is selected as soon as its native state
/// arrives; the queue clears itself if restore finishes without it.
final class PendingTabSelectionProvider
extends $NotifierProvider<PendingTabSelection, String?> {
/// Queues the selection of a tab that exists in the local DB but has not
/// been delivered by the native session restore yet (placeholder chips at
/// cold start). The queued tab is selected as soon as its native state
/// arrives; the queue clears itself if restore finishes without it.
PendingTabSelectionProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pendingTabSelectionProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pendingTabSelectionHash();
@$internal
@override
PendingTabSelection create() => PendingTabSelection();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$pendingTabSelectionHash() =>
r'761d5814b40b1003b59db09a0bb7ef370530b317';
/// Queues the selection of a tab that exists in the local DB but has not
/// been delivered by the native session restore yet (placeholder chips at
/// cold start). The queued tab is selected as soon as its native state
/// arrives; the queue clears itself if restore finishes without it.
abstract class _$PendingTabSelection extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
part 'restore_complete.g.dart';
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
/// session restore has dispatched all persisted tabs into the BrowserStore.
/// While false, DB-cached tabs without a native state are rendered as
/// placeholders and the destructive tab DB sync is deferred.
@Riverpod(keepAlive: true)
class BrowserRestoreComplete extends _$BrowserRestoreComplete {
@override
bool build() {
final eventService = ref.watch(eventServiceProvider);
ref.listen(
fireImmediately: true,
engineReadyStateProvider,
(previous, next) async {
if (next) {
await GeckoTabService().syncEvents(onRestoreComplete: true);
}
},
onError: (error, stackTrace) {
logger.e(
'Error listening to engineReadyStateProvider',
error: error,
stackTrace: stackTrace,
);
},
);
final restoreCompleteSub = eventService.restoreCompleteEvents.listen(
(restoreComplete) {
if (restoreComplete != state) {
state = restoreComplete;
}
},
onError: (Object error, StackTrace stackTrace) {
logger.e(
'Error in restore complete events',
error: error,
stackTrace: stackTrace,
);
},
);
ref.onDispose(() async {
await restoreCompleteSub.cancel();
});
return eventService.restoreCompleteEvents.valueOrNull ?? false;
}
}
@@ -0,0 +1,80 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'restore_complete.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
/// session restore has dispatched all persisted tabs into the BrowserStore.
/// While false, DB-cached tabs without a native state are rendered as
/// placeholders and the destructive tab DB sync is deferred.
@ProviderFor(BrowserRestoreComplete)
final browserRestoreCompleteProvider = BrowserRestoreCompleteProvider._();
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
/// session restore has dispatched all persisted tabs into the BrowserStore.
/// While false, DB-cached tabs without a native state are rendered as
/// placeholders and the destructive tab DB sync is deferred.
final class BrowserRestoreCompleteProvider
extends $NotifierProvider<BrowserRestoreComplete, bool> {
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
/// session restore has dispatched all persisted tabs into the BrowserStore.
/// While false, DB-cached tabs without a native state are rendered as
/// placeholders and the destructive tab DB sync is deferred.
BrowserRestoreCompleteProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'browserRestoreCompleteProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$browserRestoreCompleteHash();
@$internal
@override
BrowserRestoreComplete create() => BrowserRestoreComplete();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$browserRestoreCompleteHash() =>
r'971ce7722781b3cb581cfa9ada0884e91f723b85';
/// Mirrors the native `BrowserState.restoreComplete` flag: false until the
/// session restore has dispatched all persisted tabs into the BrowserStore.
/// While false, DB-cached tabs without a native state are rendered as
/// placeholders and the destructive tab DB sync is deferred.
abstract class _$BrowserRestoreComplete extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -32,6 +32,8 @@ import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/pending_tab_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
@@ -456,6 +458,14 @@ class TabRepository extends _$TabRepository {
}
Future<bool> selectTab(String tabId) async {
// The tab is still a pre-restore placeholder (known to the DB but not to
// the engine yet): queue the selection until the native state arrives.
if (!ref.read(browserRestoreCompleteProvider) &&
!ref.read(tabStatesProvider).containsKey(tabId)) {
ref.read(pendingTabSelectionProvider.notifier).queue(tabId);
return true;
}
final containerData = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerData(tabId);
@@ -768,6 +778,18 @@ class TabRepository extends _$TabRepository {
}
}
Future<void> _drainPendingIsolationCleanup() async {
if (_pendingIsolationCleanup.isEmpty) return;
final pending = Set<String>.of(_pendingIsolationCleanup);
_pendingIsolationCleanup.clear();
for (final contextId in pending) {
if (!ref.mounted) break;
await _cleanupIsolationContextIfEmpty(contextId);
}
}
Future<void> undoClose() {
// Suppress the next reclose pass: undo can resurrect a tab whose
// tombstone is still on disk (from a previous session); without this
@@ -1037,9 +1059,14 @@ class TabRepository extends _$TabRepository {
return;
}
//Only sync tabs if there has been a previous value or is not empty
//Only sync tabs if there has been a previous value or is not empty.
//Additionally require the native session restore to have completed:
//a partial pre-restore list (e.g. a share-intent tab arriving first)
//must not delete the cached rows of tabs that are still being
//restored.
final shouldSyncTabs =
next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false);
ref.read(browserRestoreCompleteProvider) &&
(next.value.isNotEmpty || (previous?.value.isNotEmpty ?? false));
if (shouldSyncTabs) {
final syncTabsResult = await db.tabDao.syncTabs(
@@ -1054,14 +1081,7 @@ class TabRepository extends _$TabRepository {
// Process pending isolation context cleanups after syncTabs
// has deleted the rows, so the count check is accurate.
if (_pendingIsolationCleanup.isNotEmpty) {
final pending = Set<String>.of(_pendingIsolationCleanup);
_pendingIsolationCleanup.clear();
for (final contextId in pending) {
if (!ref.mounted) break;
await _cleanupIsolationContextIfEmpty(contextId);
}
}
await _drainPendingIsolationCleanup();
// One-shot orphan cleanup after tab list stabilizes (5s debounce).
// Also runs for DB-only contexts whose rows were already deleted
@@ -1084,6 +1104,26 @@ class TabRepository extends _$TabRepository {
},
);
// Catch up on the tab list emissions skipped while the restore-complete
// gate above was closed: reconcile the DB once against the current list.
ref.listen(browserRestoreCompleteProvider, (
previous,
restoreComplete,
) async {
if (restoreComplete && !(previous ?? false)) {
final currentTabs = ref.read(tabListProvider).value;
if (currentTabs.isNotEmpty) {
final syncTabsResult = await db.tabDao.syncTabs(
retainTabIds: currentTabs,
);
_pendingIsolationCleanup.addAll(
syncTabsResult.deletedIsolationContextIds,
);
await _drainPendingIsolationCleanup();
}
}
});
final tabStateDebouncer = Debouncer(const Duration(seconds: 1));
Map<String, TabState>? debounceStartValue;
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'd7b67460388c264f477325266a3299fe6df2df07';
String _$tabRepositoryHash() => r'982b80b8ea7c694958bc10cc7e8e15310af6a238';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -28,6 +28,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
@@ -38,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.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/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
@@ -176,6 +178,51 @@ EquatableValue<Map<String, TabState>> containerTabStates(
});
}
/// Synthesizes a placeholder [TabState] from a cached DB row for tabs the
/// native session restore hasn't delivered yet. `TabIcon` falls back to the
/// URL-cached favicon when [TabState.icon] is null, so placeholder chips
/// still render with proper icons and titles.
TabState _placeholderTabState(TabData tab) {
return TabState.$default(tab.id).copyWith(
url: tab.url ?? TabState.defaultUrl,
title: tab.title ?? '',
parentId: tab.parentId,
tabMode: TabMode.fromDbValue(
tab.tabMode,
isolationContextId: tab.isolationContextId,
),
);
}
/// Whether [tab] may be shown as a pre-restore placeholder. Private tabs are
/// never session-restored, so their rows must not produce dangling chips.
bool _canShowAsPlaceholder(TabData tab) =>
tab.tabMode != TabModeDbValue.private;
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
/// the session restore completed (afterwards a missing native state means
/// the tab is gone, not pending).
@Riverpod()
EquatableValue<Set<String>> pendingRestoreTabIds(Ref ref) {
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
if (restoreComplete) {
return EquatableValue(const {});
}
final nativeTabIds = ref.watch(
tabStatesProvider.select((states) => EquatableValue(states.keys.toSet())),
);
final dbTabs =
ref.watch(watchTabsFifoProvider.select((value) => value.value)) ??
const <TabData>[];
return EquatableValue({
for (final tab in dbTabs)
if (_canShowAsPlaceholder(tab) && !nativeTabIds.value.contains(tab.id))
tab.id,
});
}
@Riverpod(keepAlive: true)
EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
final containerData = ref
@@ -189,13 +236,20 @@ EquatableValue<List<TabStateWithContainer>> fifoTabStates(Ref ref) {
);
final tabStates = ref.watch(tabStatesProvider);
final placeholdersActive = !ref.watch(browserRestoreCompleteProvider);
TabState? stateFor(TabData tab) =>
tabStates[tab.id] ??
(placeholdersActive && _canShowAsPlaceholder(tab)
? _placeholderTabState(tab)
: null);
return EquatableValue([
if (sortedTabs != null)
for (final tab in sortedTabs)
if (tabStates.containsKey(tab.id))
if (stateFor(tab) case final state?)
(
tabStates[tab.id]!,
state,
tab.containerId.mapNotNull(
(containerId) => containerData?[containerId],
),
@@ -218,11 +272,43 @@ selectedContainerTabStatesWithContainer(Ref ref) {
(value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))),
);
final sortedTabs = ref.watch(
containerTabEntitiesProvider(filter).select((value) => value.value),
);
final tabStates = ref.watch(tabStatesProvider);
final placeholdersActive = !ref.watch(browserRestoreCompleteProvider);
final selectedContainerTabsData = placeholdersActive
? ref.watch(
watchContainerTabsDataProvider(
filter.containerId,
).select((value) => value.value),
) ??
const <TabData>[]
: const <TabData>[];
final sortedTabs = placeholdersActive
? [
for (final tab in selectedContainerTabsData)
DefaultTabEntity(
tabId: tab.id,
orderKey: tab.orderKey,
containerId: tab.containerId,
),
]
: ref.watch(
containerTabEntitiesProvider(filter).select((value) => value.value),
);
final tabDataById = placeholdersActive
? {for (final tab in selectedContainerTabsData) tab.id: tab}
: const <String, TabData>{};
TabState? stateForEntity(String tabId) {
final state = tabStates[tabId];
if (state != null) {
return state;
}
final tabData = tabDataById[tabId];
if (tabData != null && _canShowAsPlaceholder(tabData)) {
return _placeholderTabState(tabData);
}
return null;
}
final groupedItems = ref
.watch(groupedTabListItemsProvider(containerId: filter.containerId))
@@ -294,12 +380,15 @@ selectedContainerTabStatesWithContainer(Ref ref) {
final groupedOrder = {
for (var i = 0; i < orderedItems.length; i++) orderedItems[i].tabId: i,
};
final orderKeyById = {
for (final tabEntity in sortedTabs) tabEntity.tabId: tabEntity.orderKey,
};
var items = [
for (final tabEntity in sortedTabs)
if (tabStates.containsKey(tabEntity.tabId))
if (stateForEntity(tabEntity.tabId) case final state?)
(
tabStates[tabEntity.tabId]!,
state,
tabEntity.containerId.mapNotNull(
(containerId) => containerData?[containerId],
),
@@ -307,9 +396,15 @@ selectedContainerTabStatesWithContainer(Ref ref) {
];
items.sort((a, b) {
final aIndex = groupedOrder[a.$1.id] ?? orderedItems.length;
final bIndex = groupedOrder[b.$1.id] ?? orderedItems.length;
return aIndex.compareTo(bIndex);
final aIndex = groupedOrder[a.$1.id];
final bIndex = groupedOrder[b.$1.id];
if (aIndex != null && bIndex != null) {
return aIndex.compareTo(bIndex);
}
if (aIndex != null) return -1;
if (bIndex != null) return 1;
return (orderKeyById[a.$1.id] ?? '').compareTo(orderKeyById[b.$1.id] ?? '');
});
// Flat pinned-first: move all pinned tabs before unpinned regardless of
@@ -330,14 +425,9 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
Ref ref,
QuickTabSwitcherMode mode,
) {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final selectedTabId = ref.watch(selectedTabProvider);
final tabStates = switch (effectiveMode) {
final tabStates = switch (mode) {
QuickTabSwitcherMode.lastUsedTabs => ref.watch(fifoTabStatesProvider).value,
QuickTabSwitcherMode.containerTabs =>
ref.watch(selectedContainerTabStatesWithContainerProvider).value,
@@ -352,7 +442,7 @@ EquatableValue<List<TabStateWithContainer>> quickTabSwitcherTabStates(
tabViewFilterControllerProvider.select((v) => v.sortPinnedFirst),
);
return EquatableValue(switch (effectiveMode) {
return EquatableValue(switch (mode) {
QuickTabSwitcherMode.lastUsedTabs => () {
final filtered = tabStates
.where((state) => state.$1.id != selectedTabId)
@@ -378,11 +468,6 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
Ref ref,
QuickTabSwitcherMode mode,
) async {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final showHistorySuggestions = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.quickTabSwitcherShowHistorySuggestions,
@@ -395,7 +480,7 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
final hasTabStates = ref.watch(
quickTabSwitcherTabStatesProvider(
effectiveMode,
mode,
).select((value) => value.value.isNotEmpty),
);
if (hasTabStates) {
@@ -407,28 +492,15 @@ Future<List<VisitInfo>> quickTabSwitcherHistorySuggestions(
.getVisitsPaginated(count: 25);
}
@Riverpod()
AsyncValue<bool> quickTabSwitcherHasResults(
/// Whether a single switcher row of [mode] has anything to render
/// (open tabs, or history suggestions as fallback).
AsyncValue<bool> _quickTabSwitcherRowHasResults(
Ref ref,
QuickTabSwitcherMode mode,
) {
final effectiveMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
),
);
final showQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.tabBarShowQuickTabSwitcherBar,
),
);
if (!showQuickTabSwitcherBar) {
return const AsyncValue.data(false);
}
final hasResults = ref.watch(
quickTabSwitcherTabStatesProvider(
effectiveMode,
mode,
).select((value) => value.value.isNotEmpty),
);
@@ -437,10 +509,64 @@ AsyncValue<bool> quickTabSwitcherHasResults(
}
return ref
.watch(quickTabSwitcherHistorySuggestionsProvider(effectiveMode))
.watch(quickTabSwitcherHistorySuggestionsProvider(mode))
.whenData((visits) => visits.isNotEmpty);
}
/// Number of 48px rows the quick tab switcher bar currently occupies.
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
@Riverpod()
AsyncValue<int> quickTabSwitcherRowCount(Ref ref) {
final stackingMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveTabBarStackingMode(),
),
);
switch (stackingMode) {
case TabBarStackingMode.disabled:
return const AsyncValue.data(0);
case TabBarStackingMode.lastUsedTabs:
return _quickTabSwitcherRowHasResults(
ref,
QuickTabSwitcherMode.lastUsedTabs,
).whenData((hasResults) => hasResults ? 1 : 0);
case TabBarStackingMode.containerTabs:
return _quickTabSwitcherRowHasResults(
ref,
QuickTabSwitcherMode.containerTabs,
).whenData((hasResults) => hasResults ? 1 : 0);
case TabBarStackingMode.accordion:
final hasContainers = ref.watch(
watchContainersWithCountProvider.select(
(value) => value.value?.isNotEmpty ?? false,
),
);
if (hasContainers) {
return const AsyncValue.data(1);
}
return _quickTabSwitcherRowHasResults(
ref,
QuickTabSwitcherMode.containerTabs,
).whenData((hasResults) => hasResults ? 1 : 0);
case TabBarStackingMode.twoLevel:
final containerRow = _quickTabSwitcherRowHasResults(
ref,
QuickTabSwitcherMode.containerTabs,
);
final mruRow = _quickTabSwitcherRowHasResults(
ref,
QuickTabSwitcherMode.lastUsedTabs,
);
// The bar shows both rows whenever either has content; an empty row
// renders blank within its slot.
return containerRow.whenData(
(hasContainerTabs) =>
(hasContainerTabs || (mruRow.value ?? false)) ? 2 : 0,
);
}
}
@Riverpod()
EquatableValue<List<TabEntity>> suggestedTabEntities(
Ref ref,
@@ -424,6 +424,65 @@ final class ContainerTabStatesFamily extends $Family
String toString() => r'containerTabStatesProvider';
}
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
/// the session restore completed (afterwards a missing native state means
/// the tab is gone, not pending).
@ProviderFor(pendingRestoreTabIds)
final pendingRestoreTabIdsProvider = PendingRestoreTabIdsProvider._();
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
/// the session restore completed (afterwards a missing native state means
/// the tab is gone, not pending).
final class PendingRestoreTabIdsProvider
extends
$FunctionalProvider<
EquatableValue<Set<String>>,
EquatableValue<Set<String>>,
EquatableValue<Set<String>>
>
with $Provider<EquatableValue<Set<String>>> {
/// Ids of DB-cached tabs whose native state hasn't arrived yet. Empty once
/// the session restore completed (afterwards a missing native state means
/// the tab is gone, not pending).
PendingRestoreTabIdsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'pendingRestoreTabIdsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$pendingRestoreTabIdsHash();
@$internal
@override
$ProviderElement<EquatableValue<Set<String>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<Set<String>> create(Ref ref) {
return pendingRestoreTabIds(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<Set<String>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<Set<String>>>(value),
);
}
}
String _$pendingRestoreTabIdsHash() =>
r'a7c1639478ebed7d887d9b1d1296757d829dcc6b';
@ProviderFor(fifoTabStates)
final fifoTabStatesProvider = FifoTabStatesProvider._();
@@ -474,7 +533,7 @@ final class FifoTabStatesProvider
}
}
String _$fifoTabStatesHash() => r'f8dc69382d307bc83193a3b839a16313e9c1ab69';
String _$fifoTabStatesHash() => r'a512f96119561096b9cc7d6920e82da6d460ce68';
@ProviderFor(selectedContainerTabStatesWithContainer)
final selectedContainerTabStatesWithContainerProvider =
@@ -529,7 +588,7 @@ final class SelectedContainerTabStatesWithContainerProvider
}
String _$selectedContainerTabStatesWithContainerHash() =>
r'e6fd0e1e0ca4cfb0a0a46e8ff4e63d41032bfad9';
r'6e215f983140f7980d84e3f3d05a8afe95e6f1e7';
@ProviderFor(quickTabSwitcherTabStates)
final quickTabSwitcherTabStatesProvider = QuickTabSwitcherTabStatesFamily._();
@@ -601,7 +660,7 @@ final class QuickTabSwitcherTabStatesProvider
}
String _$quickTabSwitcherTabStatesHash() =>
r'213c9c492055175de8fc684e0c9fb9a3530fbb6f';
r'f61847bd68385e799384adfa5bfe735544fedbf5';
final class QuickTabSwitcherTabStatesFamily extends $Family
with
@@ -684,7 +743,7 @@ final class QuickTabSwitcherHistorySuggestionsProvider
}
String _$quickTabSwitcherHistorySuggestionsHash() =>
r'2e689ce72aac6b5b5b41e0390b168b3fdf5d4251';
r'3446741dfedf6fda9ab61421c1b74370c7f43f5b';
final class QuickTabSwitcherHistorySuggestionsFamily extends $Family
with
@@ -708,89 +767,56 @@ final class QuickTabSwitcherHistorySuggestionsFamily extends $Family
String toString() => r'quickTabSwitcherHistorySuggestionsProvider';
}
@ProviderFor(quickTabSwitcherHasResults)
final quickTabSwitcherHasResultsProvider = QuickTabSwitcherHasResultsFamily._();
/// Number of 48px rows the quick tab switcher bar currently occupies.
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
final class QuickTabSwitcherHasResultsProvider
@ProviderFor(quickTabSwitcherRowCount)
final quickTabSwitcherRowCountProvider = QuickTabSwitcherRowCountProvider._();
/// Number of 48px rows the quick tab switcher bar currently occupies.
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
final class QuickTabSwitcherRowCountProvider
extends
$FunctionalProvider<
AsyncValue<bool>,
AsyncValue<bool>,
AsyncValue<bool>
>
with $Provider<AsyncValue<bool>> {
QuickTabSwitcherHasResultsProvider._({
required QuickTabSwitcherHasResultsFamily super.from,
required QuickTabSwitcherMode super.argument,
}) : super(
retry: null,
name: r'quickTabSwitcherHasResultsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
$FunctionalProvider<AsyncValue<int>, AsyncValue<int>, AsyncValue<int>>
with $Provider<AsyncValue<int>> {
/// Number of 48px rows the quick tab switcher bar currently occupies.
/// 0 hides the bar; feeds the toolbar height / GeckoView viewport math.
QuickTabSwitcherRowCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'quickTabSwitcherRowCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$quickTabSwitcherHasResultsHash();
@override
String toString() {
return r'quickTabSwitcherHasResultsProvider'
''
'($argument)';
}
String debugGetCreateSourceHash() => _$quickTabSwitcherRowCountHash();
@$internal
@override
$ProviderElement<AsyncValue<bool>> $createElement($ProviderPointer pointer) =>
$ProviderElement<AsyncValue<int>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AsyncValue<bool> create(Ref ref) {
final argument = this.argument as QuickTabSwitcherMode;
return quickTabSwitcherHasResults(ref, argument);
AsyncValue<int> create(Ref ref) {
return quickTabSwitcherRowCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<bool> value) {
Override overrideWithValue(AsyncValue<int> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<bool>>(value),
providerOverride: $SyncValueProvider<AsyncValue<int>>(value),
);
}
@override
bool operator ==(Object other) {
return other is QuickTabSwitcherHasResultsProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$quickTabSwitcherHasResultsHash() =>
r'5c488c9933372350c9e87ca1b83145176823dc88';
final class QuickTabSwitcherHasResultsFamily extends $Family
with $FunctionalFamilyOverride<AsyncValue<bool>, QuickTabSwitcherMode> {
QuickTabSwitcherHasResultsFamily._()
: super(
retry: null,
name: r'quickTabSwitcherHasResultsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
QuickTabSwitcherHasResultsProvider call(QuickTabSwitcherMode mode) =>
QuickTabSwitcherHasResultsProvider._(argument: mode, from: this);
@override
String toString() => r'quickTabSwitcherHasResultsProvider';
}
String _$quickTabSwitcherRowCountHash() =>
r'81a2bfbdc4dc88fdf2c66dd9f5bfd6d1ce73a073';
@ProviderFor(suggestedTabEntities)
final suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily._();
@@ -47,6 +47,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/addon_popup_bottom_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_system_bars.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
@@ -115,7 +116,13 @@ class _AnimatedToolbar extends HookWidget {
);
}, [position]);
return SlideTransition(position: slideAnimation, child: child);
// RepaintBoundary so the (heavy) toolbar content is rasterized once and
// merely re-composited at a new offset for each frame of the slide, instead
// of repainting the whole chip row + favicons + menus every animation tick.
return SlideTransition(
position: slideAnimation,
child: RepaintBoundary(child: child),
);
}
}
@@ -124,7 +131,7 @@ class _AnimatedToolbar extends HookWidget {
class _TabBar extends HookConsumerWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final int quickTabSwitcherRowCount;
final Stream<Offset>? pointerMoveEvents;
final TabBarPosition tabBarPosition;
final bool isSmallWebMode;
@@ -133,7 +140,7 @@ class _TabBar extends HookConsumerWidget {
const _TabBar({
required this.showMainToolbar,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.quickTabSwitcherRowCount,
required this.tabBarPosition,
required this.pointerMoveEvents,
required this.isSmallWebMode,
@@ -220,7 +227,7 @@ class _TabBar extends HookConsumerWidget {
TabBarPosition.top => BrowserTopAppBar(
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
enableGestures: enableGestures,
),
@@ -228,7 +235,7 @@ class _TabBar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
),
};
@@ -527,23 +534,9 @@ class BrowserScreen extends HookConsumerWidget {
),
);
final showQuickTabSwitcherBar =
!isSmallWebActive &&
ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.tabBarShowQuickTabSwitcherBar,
),
);
final quickTabSwitcherMode = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.effectiveUiQuickTabSwitcherMode(),
),
);
final quickTabSwitcherHasResults = ref.watch(
quickTabSwitcherHasResultsProvider(quickTabSwitcherMode),
);
final displayQuickTabSwitcherBar =
showQuickTabSwitcherBar && (quickTabSwitcherHasResults.value ?? false);
final quickTabSwitcherRowCount = isSmallWebActive
? 0
: ref.watch(quickTabSwitcherRowCountProvider).value ?? 0;
final autoHideTabBar =
!isSmallWebActive &&
@@ -652,7 +645,7 @@ class BrowserScreen extends HookConsumerWidget {
bottomAppBarContentSize = BrowserBottomAppBar(
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
displayedSheet: displayedSheet,
).preferredSize;
@@ -666,7 +659,7 @@ class BrowserScreen extends HookConsumerWidget {
final topAppBarContentSize = BrowserTopAppBar(
showMainToolbar: tabBarPosition == TabBarPosition.top,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebActive,
enableGestures: !isSmallWebActive,
).preferredSize;
@@ -919,6 +912,20 @@ class BrowserScreen extends HookConsumerWidget {
},
),
// Layer 0.5: System bar tint — fills the status-bar/nav-bar
// inset regions with the active container color (or the tab bar
// surface fallback) and drives the system bar icon brightness.
// Sits above the browser content but below the toolbars, so the
// tab bar's transparent safe-area padding reveals the bottom
// strip and the top toolbar's SafeArea reveals the top strip.
if (!tabInFullScreen)
Positioned.fill(
child: BrowserSystemBars(
topInset: topSafeArea,
bottomInset: bottomSafeArea,
),
),
// Layer 1: Sheet (when displayed) - positioned above toolbar
if (sheetDisplayed)
Positioned(
@@ -973,7 +980,7 @@ class BrowserScreen extends HookConsumerWidget {
showMainToolbar:
tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
pointerMoveEvents:
tabBarPosition == TabBarPosition.bottom
@@ -1012,7 +1019,7 @@ class BrowserScreen extends HookConsumerWidget {
tabBarPosition: TabBarPosition.top,
showMainToolbar: true,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: displayQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebActive,
enableGestures: !isSmallWebActive,
pointerMoveEvents: isSmallWebActive
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
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/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
/// Closes [tabId] with the shared UX contract: confirm before closing the
/// last tab of an isolation group, then offer undo via snackbar.
Future<void> closeTabWithConfirmationAndUndo(
BuildContext context,
WidgetRef ref,
String tabId,
) async {
// Confirm before closing the last tab in an isolation group
final tabState = ref.read(tabStateProvider(tabId));
if (tabState != null && tabState.tabMode is IsolatedTabMode) {
final allStates = ref.read(tabStatesProvider);
final groupCount = allStates.values
.where((s) => s.isolationContextId == tabState.isolationContextId)
.length;
if (groupCount <= 1 && context.mounted) {
final confirmed = await ui_helper.confirmIsolatedTabClose(context);
if (!confirmed) return;
}
}
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
}
@@ -25,13 +25,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
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/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
@@ -41,12 +40,12 @@ import 'package:weblibre/features/geckoview/features/browser/features/contextual
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/presentation/widgets/contextual_toolbar.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/close_tab_helper.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_view_reorder.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
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/browser_modules/quick_tab_switcher_accordion.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.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';
@@ -60,14 +59,17 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/presentation/hooks/scroll_to_active_chip.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
export 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart'
show QuickTabSwitcherItem;
class BrowserTopAppBar extends StatelessWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final int quickTabSwitcherRowCount;
final bool isSmallWebMode;
final bool enableGestures;
@@ -78,7 +80,7 @@ class BrowserTopAppBar extends StatelessWidget {
super.key,
required this.showMainToolbar,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
this.enableGestures = true,
}) {
@@ -86,7 +88,7 @@ class BrowserTopAppBar extends StatelessWidget {
showMainToolbar: showMainToolbar,
displayedSheet: null,
showContextualToolbar: false,
showQuickTabSwitcherBar: false,
quickTabSwitcherRowCount: 0,
isSmallWebMode: isSmallWebMode,
enableGestures: enableGestures,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
@@ -107,7 +109,7 @@ class BrowserTopAppBar extends StatelessWidget {
class BrowserBottomAppBar extends StatelessWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final int quickTabSwitcherRowCount;
final bool isSmallWebMode;
final Sheet? displayedSheet;
final bool enableGestures;
@@ -120,7 +122,7 @@ class BrowserBottomAppBar extends StatelessWidget {
required this.showMainToolbar,
required this.displayedSheet,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
this.enableGestures = true,
}) {
@@ -128,7 +130,7 @@ class BrowserBottomAppBar extends StatelessWidget {
displayedSheet: displayedSheet,
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: isSmallWebMode,
enableGestures: enableGestures,
hideMainToolbarButtonsDuplicatedInContextualToolbar:
@@ -139,12 +141,14 @@ class BrowserBottomAppBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
final colorScheme = Theme.of(context).colorScheme;
return Material(
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
color: colorScheme.surfaceContainer,
// Transparent so the navigation-bar inset region behind this padding is
// filled by the BrowserSystemBars tint strip (matching the active
// container color), instead of a fixed surfaceContainer fill.
color: Colors.transparent,
child: Padding(
padding: EdgeInsets.only(bottom: bottomPadding),
child: SizedBox(height: _size.height, child: _tabBar),
@@ -158,7 +162,7 @@ class BrowserBottomAppBar extends StatelessWidget {
class BrowserTabBar extends HookConsumerWidget {
final bool showMainToolbar;
final bool showContextualToolbar;
final bool showQuickTabSwitcherBar;
final int quickTabSwitcherRowCount;
final Sheet? displayedSheet;
final bool hideMainToolbarButtonsDuplicatedInContextualToolbar;
final bool isSmallWebMode;
@@ -169,7 +173,7 @@ class BrowserTabBar extends HookConsumerWidget {
required this.showMainToolbar,
required this.displayedSheet,
required this.showContextualToolbar,
required this.showQuickTabSwitcherBar,
required this.quickTabSwitcherRowCount,
required this.isSmallWebMode,
required this.enableGestures,
this.hideMainToolbarButtonsDuplicatedInContextualToolbar = false,
@@ -183,7 +187,7 @@ class BrowserTabBar extends HookConsumerWidget {
(!showContextualToolbar || displayedSheet is! ViewTabsSheet);
bool get displayQuickTabSwitcher =>
showQuickTabSwitcherBar && displayedSheet is! ViewTabsSheet;
quickTabSwitcherRowCount > 0 && displayedSheet is! ViewTabsSheet;
double getToolbarHeight() {
var height = 0.0;
@@ -197,7 +201,7 @@ class BrowserTabBar extends HookConsumerWidget {
}
if (displayQuickTabSwitcher) {
height += quickTabSwitcherHeight;
height += quickTabSwitcherHeight * quickTabSwitcherRowCount;
}
return height;
@@ -242,7 +246,7 @@ class BrowserTabBar extends HookConsumerWidget {
).select((data) => data.value?.metadata.useCustomColor ?? false),
);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
final stackingMode = settings.effectiveTabBarStackingMode();
final tabBarPosition = settings.tabBarPosition;
@@ -269,7 +273,7 @@ class BrowserTabBar extends HookConsumerWidget {
return BrowserTabBarView(
showMainToolbar: showMainToolbar,
showContextualToolbar: showContextualToolbar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
showQuickTabSwitcherBar: quickTabSwitcherRowCount > 0,
displayAppBar: displayAppBar,
displayQuickTabSwitcher: displayQuickTabSwitcher,
backgroundColor: effectiveContainerPalette?.surfaceColor,
@@ -308,9 +312,30 @@ class BrowserTabBar extends HookConsumerWidget {
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
],
quickTabSwitcher: QuickTabSwitcher(
quickTabSwitcherMode: quickTabSwitcherMode,
),
quickTabSwitcher: switch (stackingMode) {
TabBarStackingMode.disabled => const SizedBox.shrink(),
TabBarStackingMode.lastUsedTabs => const QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.lastUsedTabs,
),
TabBarStackingMode.containerTabs => const QuickTabSwitcher(
quickTabSwitcherMode: QuickTabSwitcherMode.containerTabs,
),
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,
),
],
),
},
contextualToolbar: ContextualToolbar(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
@@ -472,107 +497,19 @@ class BrowserTabBarView extends StatelessWidget {
}
}
class QuickTabSwitcherItem with FastEquatable {
final Color? color;
final bool useCustomColor;
final String id;
final bool isActive;
final TabMode tabMode;
final bool isHistory;
final bool isPinned;
final bool isSandbox;
final int depth;
final String title;
final Uri url;
final Widget avatar;
QuickTabSwitcherItem({
required this.color,
required this.id,
required this.isActive,
required this.tabMode,
required this.isHistory,
required this.isPinned,
required this.title,
required this.url,
required this.avatar,
this.useCustomColor = false,
this.isSandbox = false,
this.depth = 0,
});
/// Builds a switcher entry for an open tab. [sandboxSourceUri] is the
/// canonical source URL when the tab is a sandbox capture (otherwise null),
/// so the bar shows the real site instead of the loopback capture URL.
factory QuickTabSwitcherItem.tab(
TabStateWithContainer state, {
required String? selectedTabId,
required Set<String> pinnedTabIds,
required Map<String, int> tabDepthById,
required Uri? sandboxSourceUri,
}) {
final (tab, container) = state;
return QuickTabSwitcherItem(
color: container?.color,
useCustomColor: container?.metadata.useCustomColor ?? false,
id: tab.id,
isActive: tab.id == selectedTabId,
title: sandboxSourceUri != null && tab.title.isEmpty
? sandboxSourceUri.authority
: tab.titleOrAuthority,
tabMode: tab.tabMode,
isHistory: false,
isPinned: pinnedTabIds.contains(tab.id),
isSandbox: sandboxSourceUri != null,
depth: tabDepthById[tab.id] ?? 0,
url: sandboxSourceUri ?? tab.url,
avatar: TabIcon(tabState: tab, iconSize: 20),
);
}
/// Builds a switcher entry for a history suggestion (shown only when there
/// are no open tabs in the active mode).
factory QuickTabSwitcherItem.history({
required String url,
required String? title,
}) {
final parsedUrl = Uri.parse(url);
return QuickTabSwitcherItem(
color: null,
id: url,
isActive: false,
title: title ?? parsedUrl.authority,
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: parsedUrl,
avatar: UrlIcon([parsedUrl], iconSize: 20),
);
}
@override
List<Object?> get hashParameters => [
color,
useCustomColor,
id,
isActive,
tabMode,
isHistory,
isPinned,
isSandbox,
depth,
title,
url,
avatar,
];
}
class QuickTabSwitcher extends HookConsumerWidget {
final QuickTabSwitcherMode quickTabSwitcherMode;
const QuickTabSwitcher({super.key, required this.quickTabSwitcherMode});
/// Whether the row falls back to history suggestion chips when it has no
/// open tabs. Disabled for the top row in two-level stacking so history
/// chips don't show twice.
final bool enableHistoryFallback;
const QuickTabSwitcher({
super.key,
required this.quickTabSwitcherMode,
this.enableHistoryFallback = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -584,9 +521,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
(s) => s.quickTabSwitcherShowTitles,
),
);
final effectiveMode = ref.watch(
final titleMaxWidth = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.effectiveUiQuickTabSwitcherMode(),
(s) => s.quickTabSwitcherTitleWidth,
),
);
final showCloseButtonOnAllTabs = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
),
);
final tabBarDirection = ref.watch(
@@ -596,9 +538,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
quickTabSwitcherTabStatesProvider(quickTabSwitcherMode),
);
final selectedTabId = ref.watch(selectedTabProvider);
final historySuggestions = ref
.watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode))
.value;
final historySuggestions = enableHistoryFallback
? ref
.watch(
quickTabSwitcherHistorySuggestionsProvider(
quickTabSwitcherMode,
),
)
.value
: null;
final sandboxCaptureMap =
ref.watch(sandboxCaptureMapProvider).value ?? const {};
// Reorder is only meaningful when the bar renders the user's actual tab
@@ -616,7 +564,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
final showHierarchicalTabs = hierarchyGlyphs > 0;
final selectedContainerId = ref.watch(selectedContainerProvider);
final hierarchyContainerId =
effectiveMode == QuickTabSwitcherMode.containerTabs
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs
? selectedContainerId
: null;
@@ -639,8 +587,14 @@ class QuickTabSwitcher extends HookConsumerWidget {
(value) => value.value ?? const <String>{},
),
);
final reorderEnabled =
effectiveMode == QuickTabSwitcherMode.containerTabs && canManualReorder;
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
final nativeTabIds = ref
.watch(
tabStatesProvider.select(
(states) => EquatableValue(states.keys.toSet()),
),
)
.value;
final tabItems = tabStates.value
.map(
(state) => QuickTabSwitcherItem.tab(
@@ -651,9 +605,17 @@ class QuickTabSwitcher extends HookConsumerWidget {
sandboxSourceUri: parseSandboxSource(
sandboxCaptureMap[state.$1.id],
),
isPlaceholder:
!restoreComplete && !nativeTabIds.contains(state.$1.id),
),
)
.toList();
// Reorder is disabled while placeholders are present: the engine doesn't
// know those tabs yet, so a reorder couldn't be applied consistently.
final reorderEnabled =
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs &&
canManualReorder &&
!tabItems.any((item) => item.isPlaceholder);
final historyItems = (historySuggestions ?? [])
.map(
(visit) =>
@@ -673,7 +635,6 @@ class QuickTabSwitcher extends HookConsumerWidget {
final activeItemKey = useRef(GlobalKey());
final isUserScrolling = useRef(false);
final userScrollTimer = useRef<Timer?>(null);
final didRunInitialAutoScroll = useRef(false);
final scrollKey = PageStorageKey(
'quick_tab_switcher_${quickTabSwitcherMode.name}',
);
@@ -682,61 +643,15 @@ class QuickTabSwitcher extends HookConsumerWidget {
return userScrollTimer.value?.cancel;
}, []);
useEffect(() {
if (isUserScrolling.value) return null;
final isInitialAutoScroll = !didRunInitialAutoScroll.value;
didRunInitialAutoScroll.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isInitialAutoScroll &&
chipScrollController.hasClients &&
chipScrollController.offset != 0) {
return;
}
final context = activeItemKey.value.currentContext;
if (context != null) {
unawaited(
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
} else if (chipScrollController.hasClients) {
final activeIndex = availableItems.indexWhere(
(item) => item.id == selectedTabId,
);
if (activeIndex < 0) return;
final totalItems = availableItems.length;
final maxExtent = chipScrollController.position.maxScrollExtent;
if (totalItems > 0 && maxExtent > 0) {
chipScrollController.jumpTo(
(activeIndex / totalItems * maxExtent).clamp(0.0, maxExtent),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
final retryContext = activeItemKey.value.currentContext;
if (retryContext != null) {
unawaited(
Scrollable.ensureVisible(
retryContext,
alignment: 0.5,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
});
}
}
});
return null;
}, [selectedTabId]);
// Keep the active chip centered when the selection or ordering changes,
// even if it is far outside the lazily-built range.
useScrollToActiveChip<String>(
controller: chipScrollController,
activeChipKey: activeItemKey.value,
activeId: (activeItem?.isActive ?? false) ? activeItem?.id : null,
orderedIds: [for (final item in availableItems) item.id],
isUserScrolling: () => isUserScrolling.value,
);
return NotificationListener<UserScrollNotification>(
onNotification: (notification) {
@@ -765,7 +680,12 @@ class QuickTabSwitcher extends HookConsumerWidget {
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
hierarchyGlyphs: hierarchyGlyphs,
enablePinTabInMenu: effectiveMode == QuickTabSwitcherMode.containerTabs,
titleMaxWidth: titleMaxWidth,
showCloseButtonOnAllTabs: showCloseButtonOnAllTabs,
enablePinTabInMenu:
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs,
onCloseItem: (item) =>
closeTabWithConfirmationAndUndo(context, ref, item.id),
onSelected: (item) async {
if (!item.isHistory && item.isActive) {
return;
@@ -837,8 +757,11 @@ class QuickTabSwitcherView extends StatelessWidget {
required this.showTitles,
required this.showIsolatedTabUi,
this.hierarchyGlyphs = defaultQuickTabSwitcherHierarchyGlyphs,
this.titleMaxWidth = defaultQuickTabSwitcherTitleWidth,
this.showCloseButtonOnAllTabs = false,
required this.enablePinTabInMenu,
required this.onSelected,
this.onCloseItem,
this.onReorderItem,
this.reorderableItemCount = 0,
});
@@ -855,9 +778,20 @@ class QuickTabSwitcherView extends StatelessWidget {
/// into an icon + count badge. A value of 0 hides the indicator entirely.
final int hierarchyGlyphs;
/// Max width of a chip's title text.
final double titleMaxWidth;
/// Whether every tab chip shows a close button. The active tab's chip
/// always shows one when [onCloseItem] is set.
final bool showCloseButtonOnAllTabs;
final bool enablePinTabInMenu;
final Future<void> Function(QuickTabSwitcherItem item) onSelected;
/// Close handler backing the chips' close buttons. When null no close
/// buttons are shown at all.
final Future<void> Function(QuickTabSwitcherItem item)? onCloseItem;
/// When non-null, the first [reorderableItemCount] items are rendered as a
/// horizontal `ReorderableListView` driven by this callback. Otherwise the
/// view falls back to the non-reorderable `SelectableChips` layout.
@@ -869,10 +803,19 @@ class QuickTabSwitcherView extends StatelessWidget {
bool get _reorderEnabled => onReorderItem != null && reorderableItemCount > 0;
/// Whether [item]'s chip shows a close button.
bool _canShowCloseButton(QuickTabSwitcherItem item) =>
onCloseItem != null &&
!item.isHistory &&
!item.isPlaceholder &&
(showCloseButtonOnAllTabs || item.isActive);
@override
Widget build(BuildContext context) {
if (availableItems.isEmpty) {
return const SizedBox.shrink();
// Hold the 48px row 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);
}
return Padding(
@@ -889,7 +832,7 @@ class QuickTabSwitcherView extends StatelessWidget {
Widget _buildSelectableChips(BuildContext context) {
return SelectableChips<QuickTabSwitcherItem, QuickTabSwitcherItem, String>(
enableDelete: false,
enableDelete: onCloseItem != null,
sortSelectedFirst: false,
maxCount: null,
scrollController: scrollController,
@@ -902,8 +845,11 @@ class QuickTabSwitcherView extends StatelessWidget {
decoration: _chipDecoration(context),
itemLabel: (item) => _chipLabel(context, item, activeItem?.id == item.id),
onSelected: onSelected,
onDeleted: (item) {
unawaited(onCloseItem?.call(item));
},
itemWrap: (child, item) =>
item.isHistory ? child : _wrapWithMenu(itemId: item.id, child: child),
item.isHistory ? child : _wrapWithMenu(item: item, child: child),
availableItems: availableItems,
);
}
@@ -928,15 +874,14 @@ class QuickTabSwitcherView extends StatelessWidget {
itemBuilder: (context, index) {
final item = availableItems[index];
final isSelected = activeItem?.id == item.id;
final chip = _ReorderableSwitcherChip(
final chip = QuickTabSwitcherChip(
item: item,
isSelected: isSelected,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: _chipDecoration(context),
label: _chipLabel(context, item, isSelected),
onTap: () => onSelected(item),
onDelete: _canShowCloseButton(item) ? () => onCloseItem!(item) : null,
);
final keyedForActive = isSelected && activeItemKey != null
? KeyedSubtree(key: activeItemKey, child: chip)
@@ -956,72 +901,26 @@ class QuickTabSwitcherView extends StatelessWidget {
);
}
Widget _wrapWithMenu({required String itemId, required Widget child}) {
return TabMenu(
selectedTabId: itemId,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
enableReloadButton: false,
enableNavigationButtons: false,
enableAddToHomeScreen: false,
Widget _wrapWithMenu({
required QuickTabSwitcherItem item,
required Widget child,
}) {
return wrapQuickTabSwitcherChipWithMenu(
itemId: item.id,
enabled: !item.isPlaceholder,
enablePinTab: enablePinTabInMenu,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
);
},
child: child,
);
}
SelectableChipDecoration<QuickTabSwitcherItem> _chipDecoration(
BuildContext context,
) {
return SelectableChipDecoration(
color: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedBackgroundColor,
final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).backgroundColor,
null => null,
},
side: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedBorderSide,
final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).borderSide,
null => null,
},
labelPadding: (item) =>
(!showTitles &&
!item.isHistory &&
!item.isPinned &&
!item.isSandbox &&
(item.depth == 0 || hierarchyGlyphs == 0) &&
item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero
: null,
return buildQuickTabSwitcherChipDecoration(
context,
showTitles: showTitles,
hierarchyGlyphs: hierarchyGlyphs,
canDelete: _canShowCloseButton,
);
}
@@ -1030,145 +929,14 @@ class QuickTabSwitcherView extends StatelessWidget {
QuickTabSwitcherItem item,
bool isSelected,
) {
final appColors = AppColors.of(context);
final hasTitle = item.isHistory || showTitles;
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.depth > 0 && hierarchyGlyphs > 0)
Padding(
padding: const EdgeInsets.only(right: 6.0),
child: TabDepthIndicator(
depth: item.depth,
height: 24.0,
iconSize: 14.0,
horizontalPadding: 4.0,
maxInlineGlyphs: hierarchyGlyphs,
),
),
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),
),
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
)
else if (item.tabMode is PrivateTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
),
if (item.isSandbox)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.archiveLockOutline,
color: Theme.of(context).colorScheme.tertiary,
size: 20,
),
),
if (item.isPinned)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.pin,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
if (item.isHistory)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(MdiIcons.history, size: 20),
),
],
);
return item.color.mapNotNull(
(color) => DefaultTextStyle.merge(
style: TextStyle(
color: isSelected
? ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedForegroundColor
: ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).foregroundColor,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
),
child: row,
),
) ??
row;
}
}
/// FilterChip matching `SelectableChips`' visual contract, used in the
/// reorderable render path. Stateless wrapper so the parent
/// `ReorderableListView` can attach its drag-handle gesture recognizer.
class _ReorderableSwitcherChip extends StatelessWidget {
final QuickTabSwitcherItem item;
final bool isSelected;
final bool showTitles;
final bool showIsolatedTabUi;
final Color selectedBorderColor;
final SelectableChipDecoration<QuickTabSwitcherItem> decoration;
final Widget label;
final Future<void> Function() onTap;
const _ReorderableSwitcherChip({
required this.item,
required this.isSelected,
required this.showTitles,
required this.showIsolatedTabUi,
required this.selectedBorderColor,
required this.decoration,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final itemColor = decoration.color?.call(item, isSelected);
final side =
decoration.side?.call(item, isSelected) ??
(isSelected
? BorderSide(color: selectedBorderColor, width: 2.0)
: null);
final labelPadding = decoration.labelPadding?.call(item);
return Padding(
padding: const EdgeInsets.only(right: 8.0, top: 4.0),
child: FilterChip(
color: itemColor != null ? WidgetStatePropertyAll(itemColor) : null,
selected: false,
showCheckmark: false,
labelPadding: labelPadding,
onSelected: (_) {
unawaited(onTap());
},
label: label,
side: side,
),
return buildQuickTabSwitcherChipLabel(
context,
item,
isSelected: isSelected,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
hierarchyGlyphs: hierarchyGlyphs,
titleMaxWidth: titleMaxWidth,
);
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
/// Tints the status-bar (top) and navigation-bar (bottom) inset regions with
/// the active container's surface color, mirroring how the tab bar
/// ([BrowserBottomAppBar]) tints itself, and drives the system bar icon
/// brightness so the icons stay legible against the tint.
///
/// The app runs edge-to-edge, so on modern Android the native
/// `statusBarColor` / `navigationBarColor` window attributes are ignored — the
/// system bars are transparent and content draws behind them. The inset
/// regions are therefore filled in Flutter, and only the icon brightness is
/// forwarded to the platform through [SystemUiOverlayStyle].
///
/// Designed to be placed as a full-bleed ([Positioned.fill]) layer in the
/// browser [Stack], above the browser content but below the toolbars. The tab
/// bar's safe-area padding is transparent, so the bottom strip shows through
/// behind a visible bottom toolbar and remains visible when it is hidden.
class BrowserSystemBars extends HookConsumerWidget {
final double topInset;
final double bottomInset;
const BrowserSystemBars({
super.key,
required this.topInset,
required this.bottomInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
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 palette = effectiveContainerColor != null
? ContainerColors.palette(
context,
effectiveContainerColor,
useCustomColor: useCustomColor,
)
: null;
// Mirror the tab bar's surfaceContainer fallback so the inset regions stay
// visually attached to the toolbar even when no container is active.
final tintColor = palette?.surfaceColor ?? colorScheme.surfaceContainer;
// Icons must contrast with whatever fills the inset region.
final iconBrightness =
ThemeData.estimateBrightnessForColor(tintColor) == Brightness.dark
? Brightness.light
: Brightness.dark;
return IgnorePointer(
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarIconBrightness: iconBrightness,
systemNavigationBarIconBrightness: iconBrightness,
// iOS reports the bar's own brightness rather than the icons'.
statusBarBrightness: iconBrightness == Brightness.light
? Brightness.dark
: Brightness.light,
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: topInset,
child: ColoredBox(color: tintColor),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: bottomInset,
child: ColoredBox(color: tintColor),
),
],
),
),
);
}
}
@@ -0,0 +1,555 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
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/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/close_tab_helper.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/quick_tab_switcher_chip.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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/presentation/widgets/container_chip_content.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/presentation/hooks/scroll_to_active_chip.dart';
import 'package:weblibre/presentation/widgets/inline_count_badge.dart';
/// Accordion stacking mode for the quick tab switcher bar: every available
/// container renders as a header chip and the selected container is
/// "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});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrollController = useScrollController();
final activeChipKey = useRef(GlobalKey());
final isUserScrolling = useRef(false);
final userScrollTimer = useRef<Timer?>(null);
useEffect(() {
return userScrollTimer.value?.cancel;
}, []);
final showTitles = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
final showIsolatedTabUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showIsolatedTabUi),
);
final hierarchyGlyphs = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherHierarchyGlyphs,
),
);
final titleMaxWidth = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherTitleWidth,
),
);
final showCloseButtonOnAllTabs = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
),
);
final containers =
ref.watch(
watchContainersWithCountProvider.select((value) => value.value),
) ??
const <ContainerDataWithCount>[];
final selectedContainerId = ref.watch(selectedContainerProvider);
final selectedTabId = ref.watch(selectedTabProvider);
final unassignedTabCount = ref.watch(
containerTabCountProvider(
// ignore: provider_parameters
ContainerFilterById(containerId: null),
).select((value) => value.value ?? 0),
);
final expandedTabStates = ref.watch(
selectedContainerTabStatesWithContainerProvider,
);
final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select(
(value) => value.value ?? const <String>{},
),
);
final sandboxCaptureMap =
ref.watch(sandboxCaptureMapProvider).value ?? const {};
final restoreComplete = ref.watch(browserRestoreCompleteProvider);
final nativeTabIds = ref
.watch(
tabStatesProvider.select(
(states) => EquatableValue(states.keys.toSet()),
),
)
.value;
final tabDepthById = ref
.watch(
groupedTabListItemsProvider(containerId: selectedContainerId).select((
value,
) {
return EquatableValue(<String, int>{
if (hierarchyGlyphs > 0)
for (final item in value.value)
if (item is TabListChildItem) item.tabId: item.depth,
});
}),
)
.value;
final expandedItems = expandedTabStates.value
.map(
(state) => QuickTabSwitcherItem.tab(
state,
selectedTabId: selectedTabId,
pinnedTabIds: pinnedTabIds,
tabDepthById: tabDepthById,
sandboxSourceUri: parseSandboxSource(
sandboxCaptureMap[state.$1.id],
),
isPlaceholder:
!restoreComplete && !nativeTabIds.contains(state.$1.id),
),
)
.toList();
final decoration = buildQuickTabSwitcherChipDecoration(
context,
showTitles: showTitles,
hierarchyGlyphs: hierarchyGlyphs,
// 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.
thickContainerSelectedBorder: true,
);
Future<void> selectContainer(String? containerId) async {
if (containerId != null) {
await ref
.read(selectedContainerProvider.notifier)
.setContainerId(containerId);
} else {
ref.read(selectedContainerProvider.notifier).clearContainer();
}
}
Widget buildTabChip(QuickTabSwitcherItem item) {
final isSelected = item.isActive;
final canClose =
!item.isPlaceholder && (showCloseButtonOnAllTabs || item.isActive);
final chip = QuickTabSwitcherChip(
item: item,
isSelected: isSelected,
selectedBorderColor: Theme.of(context).colorScheme.primary,
decoration: decoration,
label: buildQuickTabSwitcherChipLabel(
context,
item,
isSelected: isSelected,
showTitles: showTitles,
showIsolatedTabUi: showIsolatedTabUi,
hierarchyGlyphs: hierarchyGlyphs,
titleMaxWidth: titleMaxWidth,
),
// Spacing inside the expanded group is owned by the surrounding tray
// slice so the slices abut into one continuous background.
padding: EdgeInsets.zero,
onTap: () async {
if (item.isActive) {
return;
}
await ref.read(tabRepositoryProvider.notifier).selectTab(item.id);
},
onDelete: canClose
? () => closeTabWithConfirmationAndUndo(context, ref, item.id)
: null,
);
return wrapQuickTabSwitcherChipWithMenu(
itemId: item.id,
enabled: !item.isPlaceholder,
enablePinTab: true,
child: chip,
);
}
final showUnassignedGroup =
unassignedTabCount > 0 || selectedContainerId == null;
final entries = <_AccordionEntry>[
if (showUnassignedGroup)
_AccordionEntry.header(
container: null,
tabCount: unassignedTabCount,
isExpanded: selectedContainerId == null,
),
if (selectedContainerId == null)
...expandedItems.map(_AccordionEntry.tab),
for (final container in containers) ...[
_AccordionEntry.header(
container: container,
tabCount: container.tabCount ?? 0,
isExpanded: container.id == selectedContainerId,
),
if (container.id == selectedContainerId)
...expandedItems.map(_AccordionEntry.tab),
],
];
// The expanded container header plus its tabs form one contiguous run that
// is wrapped in a shared "tray" background so the group reads as a unit and
// its members are visually distinct from the standalone container headers.
// Each entry only knows which slice of that tray it paints; the slices abut
// into one continuous rounded surface.
final trayPositions = <_TrayPosition>[
for (var i = 0; i < entries.length; i++)
switch (entries[i]) {
_AccordionHeaderEntry(:final isExpanded) =>
!isExpanded
? _TrayPosition.none
: (i + 1 < entries.length &&
entries[i + 1] is _AccordionTabEntry
? _TrayPosition.start
: _TrayPosition.solo),
_AccordionTabEntry() =>
(i + 1 >= entries.length || entries[i + 1] is _AccordionHeaderEntry)
? _TrayPosition.end
: _TrayPosition.middle,
},
];
// The expanded group's tray is filled with the container's color so the
// whole group reads as "this container". The unassigned group has no color
// and falls back to a neutral surface.
ContainerDataWithCount? expandedContainer;
for (final container in containers) {
if (container.id == selectedContainerId) {
expandedContainer = container;
break;
}
}
final scheme = Theme.of(context).colorScheme;
final trayPalette = expandedContainer != null
? ContainerColors.palette(
context,
expandedContainer.color,
useCustomColor: expandedContainer.metadata.useCustomColor,
)
: null;
final trayFill = trayPalette?.containerColor ?? scheme.surfaceContainerHigh;
// The chip to keep centered: the active tab when it is part of the
// expanded group, otherwise the expanded container header as a fallback.
final activeTabEntryId = 'tab-$selectedTabId';
final hasActiveTab = entries.any((entry) => entry.id == activeTabEntryId);
String? expandedHeaderId;
for (final entry in entries) {
if (entry is _AccordionHeaderEntry && entry.isExpanded) {
expandedHeaderId = entry.id;
break;
}
}
final activeEntryId = hasActiveTab ? activeTabEntryId : expandedHeaderId;
// Keep the active chip (or expanded header fallback) centered when the
// selection or ordering changes, unless the user is scrolling themselves.
useScrollToActiveChip<String>(
controller: scrollController,
activeChipKey: activeChipKey.value,
activeId: activeEntryId,
orderedIds: [for (final entry in entries) entry.id],
isUserScrolling: () => isUserScrolling.value,
);
if (entries.isEmpty) {
// Hold the 48px row slot; the bar visibility is decided upstream by
// quickTabSwitcherRowCountProvider.
return const SizedBox(height: 48);
}
return NotificationListener<UserScrollNotification>(
onNotification: (notification) {
userScrollTimer.value?.cancel();
isUserScrolling.value = true;
userScrollTimer.value = Timer(const Duration(milliseconds: 1500), () {
isUserScrolling.value = false;
});
return false;
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: SizedBox(
height: 48,
width: double.maxFinite,
child: FadingScroll(
controller: scrollController,
fadingSize: 15,
builder: (context, controller) {
return ListView.builder(
key: const PageStorageKey('quick_tab_switcher_accordion'),
controller: controller,
scrollDirection: Axis.horizontal,
scrollCacheExtent: const ScrollCacheExtent.pixels(500),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final child = switch (entry) {
_AccordionHeaderEntry() => _AccordionHeaderChip(
entry: entry,
onSelected: () => selectContainer(entry.container?.id),
),
_AccordionTabEntry(:final item) => buildTabChip(item),
};
return KeyedSubtree(
key: entry.id == activeEntryId
? activeChipKey.value
: ValueKey(entry.id),
child: _TraySlice(
position: trayPositions[index],
fill: trayFill,
child: child,
),
);
},
);
},
),
),
),
);
}
}
sealed class _AccordionEntry {
const _AccordionEntry();
factory _AccordionEntry.header({
required ContainerDataWithCount? container,
required int tabCount,
required bool isExpanded,
}) = _AccordionHeaderEntry;
factory _AccordionEntry.tab(QuickTabSwitcherItem item) = _AccordionTabEntry;
String get id;
}
/// A container group header chip; [container] is null for the pseudo-group
/// of tabs without a container.
class _AccordionHeaderEntry extends _AccordionEntry {
final ContainerDataWithCount? container;
final int tabCount;
final bool isExpanded;
const _AccordionHeaderEntry({
required this.container,
required this.tabCount,
required this.isExpanded,
});
@override
String get id => 'container-${container?.id}';
}
class _AccordionTabEntry extends _AccordionEntry {
final QuickTabSwitcherItem item;
const _AccordionTabEntry(this.item);
@override
String get id => 'tab-${item.id}';
}
/// Container group header, rendered as a solid container-colored box. The fill
/// is the same whether the container is selected (expanded) or not — selection
/// only adds the surrounding tray and the inline tabs, it never recolors the
/// header chip itself.
class _AccordionHeaderChip extends StatelessWidget {
final _AccordionHeaderEntry entry;
final VoidCallback onSelected;
const _AccordionHeaderChip({required this.entry, required this.onSelected});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final container = entry.container;
// The header content sits on the container color, so it always uses the
// on-container foreground.
final Color fill;
final Color nullForeground;
final Color badgeBackground;
final Color badgeForeground;
if (container != null) {
final palette = ContainerColors.palette(
context,
container.color,
useCustomColor: container.metadata.useCustomColor,
);
fill = palette.containerColor;
nullForeground = palette.onContainerColor;
badgeBackground = palette.badgeBackgroundColor;
badgeForeground = palette.badgeForegroundColor;
} else {
fill = scheme.surfaceContainerHigh;
nullForeground = scheme.onSurfaceVariant;
badgeBackground = scheme.secondaryContainer;
badgeForeground = scheme.onSecondaryContainer;
}
final countBadge = entry.tabCount > 0
? InlineCountBadge(
count: entry.tabCount,
backgroundColor: badgeBackground,
foregroundColor: badgeForeground,
)
: null;
return FilterChip(
avatar: container != null
? buildContainerChipAvatar(context, container, true)
: Icon(MdiIcons.folderHidden, color: nullForeground),
label: container != null
? buildContainerChipLabel(
context,
container,
true,
trailing: countBadge,
)
: SizedBox(
height: 20,
child: Center(
child:
countBadge ??
DefaultTextStyle.merge(
style: TextStyle(color: nullForeground),
child: const SizedBox.shrink(),
),
),
),
// 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,
onSelected: (value) {
if (value) {
onSelected();
}
},
side: BorderSide(width: 2, color: fill),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(width: 2, color: fill),
),
);
}
}
/// Where a chip sits within the expanded container's tray, controlling which
/// rounded corners and edge padding its [_TraySlice] paints. [none] is a
/// standalone (collapsed) header that carries no tray.
enum _TrayPosition { none, solo, start, middle, end }
/// One slice of the shared tray behind an expanded group's chips. Adjacent
/// slices abut with matching height and seam padding so their fill merges into
/// a single continuous rounded, container-colored surface spanning the header
/// and its tabs.
class _TraySlice extends StatelessWidget {
final _TrayPosition position;
final Color fill;
final Widget child;
const _TraySlice({
required this.position,
required this.fill,
required this.child,
});
/// Corner radius of the chips, matched by the tray so it hugs the first and
/// last chip's edges exactly.
static const Radius _radius = Radius.circular(8.0);
@override
Widget build(BuildContext context) {
if (position == _TrayPosition.none) {
// Standalone container header: regular inter-chip spacing, vertically
// centered to line up with the tray slices.
return Padding(
padding: 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 isRightEdge =
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,
),
child: SizedBox(
height: 44.0,
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),
child: Center(child: child),
),
),
);
}
}
@@ -0,0 +1,404 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
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_depth_indicator.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class QuickTabSwitcherItem with FastEquatable {
final Color? color;
final bool useCustomColor;
final String id;
final bool isActive;
final TabMode tabMode;
final bool isHistory;
final bool isPinned;
final bool isSandbox;
final int depth;
final String title;
final Uri url;
final Widget avatar;
/// True while the tab is only known from the local DB cache and the native
/// session restore hasn't delivered its state yet. Placeholders cannot be
/// closed or reordered.
final bool isPlaceholder;
QuickTabSwitcherItem({
required this.color,
required this.id,
required this.isActive,
required this.tabMode,
required this.isHistory,
required this.isPinned,
required this.title,
required this.url,
required this.avatar,
this.useCustomColor = false,
this.isSandbox = false,
this.depth = 0,
this.isPlaceholder = false,
});
/// Builds a switcher entry for an open tab. [sandboxSourceUri] is the
/// canonical source URL when the tab is a sandbox capture (otherwise null),
/// so the bar shows the real site instead of the loopback capture URL.
factory QuickTabSwitcherItem.tab(
TabStateWithContainer state, {
required String? selectedTabId,
required Set<String> pinnedTabIds,
required Map<String, int> tabDepthById,
required Uri? sandboxSourceUri,
bool isPlaceholder = false,
}) {
final (tab, container) = state;
return QuickTabSwitcherItem(
color: container?.color,
useCustomColor: container?.metadata.useCustomColor ?? false,
id: tab.id,
isActive: tab.id == selectedTabId,
title: sandboxSourceUri != null && tab.title.isEmpty
? sandboxSourceUri.authority
: tab.titleOrAuthority,
tabMode: tab.tabMode,
isHistory: false,
isPinned: pinnedTabIds.contains(tab.id),
isSandbox: sandboxSourceUri != null,
depth: tabDepthById[tab.id] ?? 0,
url: sandboxSourceUri ?? tab.url,
avatar: TabIcon(tabState: tab, iconSize: 20),
isPlaceholder: isPlaceholder,
);
}
/// Builds a switcher entry for a history suggestion (shown only when there
/// are no open tabs in the active mode).
factory QuickTabSwitcherItem.history({
required String url,
required String? title,
}) {
final parsedUrl = Uri.parse(url);
return QuickTabSwitcherItem(
color: null,
id: url,
isActive: false,
title: title ?? parsedUrl.authority,
tabMode: TabMode.regular,
isHistory: true,
isPinned: false,
url: parsedUrl,
avatar: UrlIcon([parsedUrl], iconSize: 20),
);
}
@override
List<Object?> get hashParameters => [
color,
useCustomColor,
id,
isActive,
tabMode,
isHistory,
isPinned,
isSandbox,
depth,
title,
url,
avatar,
isPlaceholder,
];
}
/// Visual contract shared by every quick tab switcher render path
/// ([SelectableChips], the reorderable list, and the accordion view).
SelectableChipDecoration<QuickTabSwitcherItem>
buildQuickTabSwitcherChipDecoration(
BuildContext context, {
required bool showTitles,
required int hierarchyGlyphs,
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
// accordion uses this so the active tab stays visible against its
// container-colored tray while matching the unselected chips' border color.
bool thickContainerSelectedBorder = false,
}) {
return SelectableChipDecoration(
color: (item, isSelected) => switch (item.color) {
final color? when isSelected => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedBackgroundColor,
final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).backgroundColor,
null => null,
},
side: (item, isSelected) => switch (item.color) {
final color? when isSelected => thickContainerSelectedBorder
? BorderSide(
color: ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).outlineColor,
width: 2.0,
)
: ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedBorderSide,
final color? => ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).borderSide,
null => null,
},
labelPadding: (item) =>
(!showTitles &&
!item.isHistory &&
!item.isPinned &&
!item.isSandbox &&
(item.depth == 0 || hierarchyGlyphs == 0) &&
item.tabMode is! PrivateTabMode &&
item.tabMode is! IsolatedTabMode)
? EdgeInsets.zero
: null,
canDelete: canDelete,
deleteIcon: (_) => const Icon(Icons.close, size: 18),
);
}
Widget buildQuickTabSwitcherChipLabel(
BuildContext context,
QuickTabSwitcherItem item, {
required bool isSelected,
required bool showTitles,
required bool showIsolatedTabUi,
required int hierarchyGlyphs,
required double titleMaxWidth,
}) {
final appColors = AppColors.of(context);
final hasTitle = item.isHistory || showTitles;
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.depth > 0 && hierarchyGlyphs > 0)
Padding(
padding: const EdgeInsets.only(right: 6.0),
child: TabDepthIndicator(
depth: item.depth,
height: 20.0,
iconSize: 14.0,
horizontalPadding: 4.0,
maxInlineGlyphs: hierarchyGlyphs,
),
),
Padding(
padding: EdgeInsets.only(right: hasTitle ? 6.0 : 0.0),
child: item.avatar,
),
if (hasTitle)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: titleMaxWidth),
child: Text(item.title),
),
if (showIsolatedTabUi && item.tabMode is IsolatedTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.snowflake,
color: appColors.isolatedTabTeal,
size: 20,
),
)
else if (item.tabMode is PrivateTabMode)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.dominoMask,
color: appColors.privateTabPurple,
size: 20,
),
),
if (item.isSandbox)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.archiveLockOutline,
color: Theme.of(context).colorScheme.tertiary,
size: 20,
),
),
if (item.isPinned)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
MdiIcons.pin,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
if (item.isHistory)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(MdiIcons.history, size: 20),
),
],
);
return item.color.mapNotNull(
(color) => DefaultTextStyle.merge(
style: TextStyle(
color: isSelected
? ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).selectedForegroundColor
: ContainerColors.palette(
context,
color,
useCustomColor: item.useCustomColor,
).foregroundColor,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
),
child: row,
),
) ??
row;
}
/// 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
/// `ReorderableListView` can attach its drag-handle gesture recognizer.
class QuickTabSwitcherChip extends StatelessWidget {
final QuickTabSwitcherItem item;
final bool isSelected;
final Color selectedBorderColor;
final SelectableChipDecoration<QuickTabSwitcherItem> decoration;
final Widget label;
final Future<void> Function() onTap;
final Future<void> Function()? onDelete;
/// Outer spacing around the chip. Defaults to the standard inter-chip gap;
/// render paths that manage their own spacing (e.g. the accordion tray)
/// pass [EdgeInsets.zero].
final EdgeInsetsGeometry padding;
const QuickTabSwitcherChip({
super.key,
required this.item,
required this.isSelected,
required this.selectedBorderColor,
required this.decoration,
required this.label,
required this.onTap,
this.onDelete,
this.padding = const EdgeInsets.only(right: 8.0, top: 4.0),
});
@override
Widget build(BuildContext context) {
final itemColor = decoration.color?.call(item, isSelected);
final side =
decoration.side?.call(item, isSelected) ??
(isSelected
? BorderSide(color: selectedBorderColor, width: 2.0)
: null);
final labelPadding = decoration.labelPadding?.call(item);
return Padding(
padding: padding,
child: FilterChip(
color: itemColor != null ? WidgetStatePropertyAll(itemColor) : null,
selected: false,
showCheckmark: false,
labelPadding: labelPadding,
onSelected: (_) {
unawaited(onTap());
},
deleteIcon: decoration.deleteIcon?.call(item),
onDeleted: onDelete != null
? () {
unawaited(onDelete!());
}
: null,
label: label,
side: side,
),
);
}
}
/// Wraps a tab chip in the long-press [TabMenu] used across switcher views.
Widget wrapQuickTabSwitcherChipWithMenu({
required String itemId,
required bool enabled,
required bool enablePinTab,
required Widget child,
}) {
if (!enabled) {
return child;
}
return TabMenu(
selectedTabId: itemId,
enableFindInPage: false,
enableFetchFeeds: false,
enableDesktopMode: false,
enableReaderMode: false,
enableReloadButton: false,
enableNavigationButtons: false,
enableAddToHomeScreen: false,
enablePinTab: enablePinTab,
builder: (context, controller, _) {
return InkWell(
onLongPress: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: child,
);
},
);
}
@@ -60,23 +60,33 @@ class TabIcon extends HookConsumerWidget {
return UrlIcon([sandboxSourceUri], iconSize: iconSize, cacheOnly: true);
}
return Skeletonizer(
enabled: icon.connectionState != ConnectionState.done,
child: Skeleton.replace(
replacement: Bone.icon(size: iconSize),
child: RepaintBoundary(
child:
icon.data.mapNotNull(
(image) => SafeRawImage(
image: image,
height: iconSize,
width: iconSize,
fallback: Icon(MdiIcons.web, size: iconSize),
),
) ??
Icon(MdiIcons.web, size: iconSize),
// While the icon future is still resolving, show the skeleton bone. This is
// the only state that needs the (comparatively expensive) Skeletonizer +
// Skeleton.replace machinery.
if (icon.connectionState != ConnectionState.done) {
return Skeletonizer(
enabled: true,
child: Skeleton.replace(
replacement: Bone.icon(size: iconSize),
child: SizedBox.square(dimension: iconSize),
),
),
);
}
// Resolved case (hit on virtually every rebuild once the favicon is known):
// render the image directly without wrapping it in Skeletonizer, which the
// rebuild profiler flagged as the most-rebuilt widget in the tab bar.
return RepaintBoundary(
child:
icon.data.mapNotNull(
(image) => SafeRawImage(
image: image,
height: iconSize,
width: iconSize,
fallback: Icon(MdiIcons.web, size: iconSize),
),
) ??
Icon(MdiIcons.web, size: iconSize),
);
}
}
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
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/close_tab_helper.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/dialogs/tab_parent_picker.dart';
@@ -764,36 +765,8 @@ class TabMenu extends HookConsumerWidget {
),
if (enableCloseTab)
MenuItemButton(
onPressed: () async {
// Confirm before closing the last tab in an isolation group
final tabState = ref.read(tabStateProvider(selectedTabId));
if (tabState != null && tabState.tabMode is IsolatedTabMode) {
final allStates = ref.read(tabStatesProvider);
final groupCount = allStates.values
.where(
(s) =>
s.isolationContextId == tabState.isolationContextId,
)
.length;
if (groupCount <= 1 && context.mounted) {
final confirmed = await ui_helper.confirmIsolatedTabClose(
context,
);
if (!confirmed) return;
}
}
await ref
.read(tabRepositoryProvider.notifier)
.closeTab(selectedTabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
onPressed: () =>
closeTabWithConfirmationAndUndo(context, ref, selectedTabId),
leadingIcon: const Icon(MdiIcons.tabMinus),
child: const Text('Close Tab'),
),
@@ -67,6 +67,7 @@ class RecentTabsSection extends ConsumerWidget {
iconSize: UrlListTile.iconSize,
),
containerColor: containerData?.color,
containerIcon: containerData?.metadata.iconData,
useCustomColor:
containerData?.metadata.useCustomColor ?? false,
onTap: () => onTabSelected(tabState.id),
@@ -568,10 +568,13 @@ class _ShowContainerUiTile extends HookConsumerWidget {
) {
var updated = currentSettings.copyWith.showContainerUi(value);
if (!value &&
updated.quickTabSwitcherMode ==
QuickTabSwitcherMode.containerTabs) {
updated = updated.copyWith.quickTabSwitcherMode(
QuickTabSwitcherMode.lastUsedTabs,
const {
TabBarStackingMode.containerTabs,
TabBarStackingMode.accordion,
TabBarStackingMode.twoLevel,
}.contains(updated.tabBarStackingMode)) {
updated = updated.copyWith.tabBarStackingMode(
TabBarStackingMode.lastUsedTabs,
);
}
return updated;
@@ -78,16 +78,25 @@ const List<SettingsSectionDefinition> toolbarLayoutSettingsSections = [
title: 'Quick Tab Switcher',
entries: [
SettingsEntryDefinition(
title: 'Show Quick Tab Switcher Bar',
subtitle: 'Show a bar for switching to recent tabs',
keywords: ['recent tabs'],
child: _ShowQuickTabSwitcherBarTile(),
title: 'Tab Stacking',
subtitle: 'Choose how the quick tab switcher bar arranges tabs',
keywords: [
'recent tabs',
'recently used',
'container tabs',
'accordion',
'two level',
'rows',
'stacking',
'disabled',
],
child: _TabBarStackingModeSection(),
),
SettingsEntryDefinition(
title: 'Quick Tab Switcher Mode',
subtitle: 'Choose how the switcher orders and groups tabs',
keywords: ['recently used', 'container tabs'],
child: _QuickTabSwitcherModeSection(),
title: 'Close Buttons on All Tabs',
subtitle: 'Show a close button on every switcher chip',
keywords: ['close', 'x button'],
child: _QuickTabSwitcherCloseButtonsTile(),
),
SettingsEntryDefinition(
title: 'History Fallback in Quick Tab Switcher',
@@ -101,6 +110,12 @@ const List<SettingsSectionDefinition> toolbarLayoutSettingsSections = [
keywords: ['page titles'],
child: _QuickTabSwitcherShowTitlesTile(),
),
SettingsEntryDefinition(
title: 'Title Width in Quick Tab Switcher',
subtitle: 'Maximum width of tab titles on switcher chips',
keywords: ['width', 'title', 'chip', 'length'],
child: _QuickTabSwitcherTitleWidthTile(),
),
SettingsEntryDefinition(
title: 'Hierarchy Depth in Quick Tab Switcher',
subtitle: 'How many nesting chevrons to show on switcher chips',
@@ -303,43 +318,13 @@ class _CustomizeToolbarButtonsTile extends HookConsumerWidget {
}
}
class _ShowQuickTabSwitcherBarTile extends HookConsumerWidget {
const _ShowQuickTabSwitcherBarTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabBarShowQuickTabSwitcherBar = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
),
);
return SwitchListTile.adaptive(
title: const Text('Show Quick Tab Switcher Bar'),
subtitle: const Text(
'Show additional toolbar to quickly switch to recently used tabs',
),
secondary: const Icon(MdiIcons.dockBottom),
value: tabBarShowQuickTabSwitcherBar,
onChanged: (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.tabBarShowQuickTabSwitcherBar(value),
);
},
);
}
}
class _QuickTabSwitcherModeSection extends HookConsumerWidget {
const _QuickTabSwitcherModeSection();
class _TabBarStackingModeSection extends HookConsumerWidget {
const _TabBarStackingModeSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(generalSettingsWithDefaultsProvider);
final quickTabSwitcherMode = settings.effectiveUiQuickTabSwitcherMode();
final stackingMode = settings.effectiveTabBarStackingMode();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
@@ -348,35 +333,60 @@ class _QuickTabSwitcherModeSection extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
title: Text('Quick Tab Switcher Mode'),
title: Text('Tab Stacking'),
subtitle: Text(
'How the quick tab switcher bar arranges its tabs',
),
leading: Icon(MdiIcons.folderSettings),
contentPadding: EdgeInsets.zero,
),
RadioGroup(
groupValue: quickTabSwitcherMode,
groupValue: stackingMode,
onChanged: (value) async {
if (value != null) {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) =>
currentSettings.copyWith.quickTabSwitcherMode(value),
currentSettings.copyWith.tabBarStackingMode(value),
);
}
},
child: Column(
children: [
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.lastUsedTabs,
value: TabBarStackingMode.lastUsedTabs,
title: Text('Recently Used Tabs'),
subtitle: Text('Recently used tabs across all containers'),
),
if (settings.showContainerUi)
const RadioListTile.adaptive(
value: QuickTabSwitcherMode.containerTabs,
if (settings.showContainerUi) ...const [
RadioListTile.adaptive(
value: TabBarStackingMode.containerTabs,
title: Text('Container Tabs'),
subtitle: Text('Ordered tabs of the selected container'),
),
RadioListTile.adaptive(
value: TabBarStackingMode.accordion,
title: Text('Accordion'),
subtitle: Text(
"All containers as chips, with the selected "
"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',
),
),
],
const RadioListTile.adaptive(
value: TabBarStackingMode.disabled,
title: Text('Disabled'),
subtitle: Text('Hide the quick tab switcher bar'),
),
],
),
),
@@ -386,6 +396,143 @@ class _QuickTabSwitcherModeSection extends HookConsumerWidget {
}
}
class _QuickTabSwitcherCloseButtonsTile extends HookConsumerWidget {
const _QuickTabSwitcherCloseButtonsTile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showCloseButtonOnAllTabs = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowCloseButtonOnAllTabs,
),
);
final switcherEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
),
);
return SwitchListTile.adaptive(
title: const Text('Close Buttons on All Tabs'),
subtitle: const Text(
"Show a close button on every switcher chip; the active tab's chip "
"always has one",
),
secondary: const Icon(MdiIcons.closeCircleOutline),
value: showCloseButtonOnAllTabs,
onChanged: switcherEnabled
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherShowCloseButtonOnAllTabs(value),
);
}
: null,
);
}
}
class _QuickTabSwitcherTitleWidthTile extends HookConsumerWidget {
const _QuickTabSwitcherTitleWidthTile();
static final _divisions =
((maxQuickTabSwitcherTitleWidth - minQuickTabSwitcherTitleWidth) /
quickTabSwitcherTitleWidthStep)
.round();
static String _label(double width) => '${width.round()} px';
@override
Widget build(BuildContext context, WidgetRef ref) {
final titleWidth = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherTitleWidth,
),
);
final showTitles = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.quickTabSwitcherShowTitles,
),
);
final switcherEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
),
);
final sliderValue = useState(titleWidth);
useEffect(() {
sliderValue.value = titleWidth;
return null;
}, [titleWidth]);
final enabled = switcherEnabled && showTitles;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: const Text('Title Width in Quick Tab Switcher'),
subtitle: const Text(
'Maximum width of tab titles on switcher chips',
),
leading: const Icon(MdiIcons.arrowExpandHorizontal),
contentPadding: EdgeInsets.zero,
enabled: enabled,
),
Row(
children: [
Expanded(
child: Slider(
min: minQuickTabSwitcherTitleWidth,
max: maxQuickTabSwitcherTitleWidth,
divisions: _divisions,
label: _label(sliderValue.value),
value: sliderValue.value.clamp(
minQuickTabSwitcherTitleWidth,
maxQuickTabSwitcherTitleWidth,
),
onChanged: enabled
? (value) {
sliderValue.value = value;
}
: null,
onChangeEnd: enabled
? (value) async {
final normalized =
(value / quickTabSwitcherTitleWidthStep)
.round() *
quickTabSwitcherTitleWidthStep;
sliderValue.value = normalized;
await ref
.read(
saveGeneralSettingsControllerProvider.notifier,
)
.save(
(currentSettings) => currentSettings.copyWith
.quickTabSwitcherTitleWidth(normalized),
);
}
: null,
),
),
Text(
_label(sliderValue.value),
style: Theme.of(context).textTheme.titleMedium,
),
],
),
],
),
);
}
}
class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
const _QuickTabSwitcherHistorySuggestionsTile();
@@ -396,9 +543,9 @@ class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
(s) => s.quickTabSwitcherShowHistorySuggestions,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
final switcherEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
),
);
@@ -409,7 +556,7 @@ class _QuickTabSwitcherHistorySuggestionsTile extends HookConsumerWidget {
),
secondary: const Icon(MdiIcons.history),
value: showHistorySuggestions,
onChanged: tabBarShowQuickTabSwitcherBar
onChanged: switcherEnabled
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
@@ -433,9 +580,9 @@ class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
(s) => s.quickTabSwitcherShowTitles,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
final switcherEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
),
);
@@ -446,7 +593,7 @@ class _QuickTabSwitcherShowTitlesTile extends HookConsumerWidget {
),
secondary: const Icon(MdiIcons.textRecognition),
value: quickTabSwitcherShowTitles,
onChanged: tabBarShowQuickTabSwitcherBar
onChanged: switcherEnabled
? (value) async {
await ref
.read(saveGeneralSettingsControllerProvider.notifier)
@@ -476,9 +623,9 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget {
(s) => s.quickTabSwitcherHierarchyGlyphs,
),
);
final tabBarShowQuickTabSwitcherBar = ref.watch(
final switcherEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(s) => s.tabBarShowQuickTabSwitcherBar,
(s) => s.effectiveTabBarStackingMode() != TabBarStackingMode.disabled,
),
);
@@ -489,7 +636,7 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget {
}, [hierarchyGlyphs]);
final currentGlyphs = sliderValue.value.round();
final enabled = tabBarShowQuickTabSwitcherBar;
final enabled = switcherEnabled;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
@@ -57,11 +57,15 @@ class TabBarPreviewHeaderDelegate extends SliverPersistentHeaderDelegate {
height += BrowserTabBar.contextualToolabarHeight;
}
if (settings.tabBarShowQuickTabSwitcherBar) {
height += BrowserTabBar.quickTabSwitcherHeight;
}
final quickTabSwitcherRows = switch (settings
.effectiveTabBarStackingMode()) {
TabBarStackingMode.disabled => 0,
TabBarStackingMode.twoLevel => 2,
_ => 1,
};
return height;
return height +
BrowserTabBar.quickTabSwitcherHeight * quickTabSwitcherRows;
}
double get _baseHeight =>
@@ -114,6 +118,7 @@ class TabBarPreviewCard extends HookWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final quickTabsController = useScrollController();
final quickTabsSecondRowController = useScrollController();
final showMainToolbarActionButtons = !settings.tabBarShowContextualBar;
final previewTabState = TabState.$default('preview-tab').copyWith(
@@ -134,8 +139,8 @@ class TabBarPreviewCard extends HookWidget {
tabMode: TabMode.regular,
isHistory: false,
isPinned:
settings.effectiveUiQuickTabSwitcherMode() ==
QuickTabSwitcherMode.containerTabs,
settings.effectiveTabBarStackingMode() ==
TabBarStackingMode.containerTabs,
url: Uri.parse('https://example.com/news'),
color: settings.showContainerUi ? colorScheme.primary : null,
avatar: const Icon(MdiIcons.web, size: 20),
@@ -196,19 +201,39 @@ class TabBarPreviewCard extends HookWidget {
},
);
Widget buildQuickTabSwitcher() {
Widget buildQuickTabSwitcherRow(ScrollController scrollController) {
return QuickTabSwitcherView(
availableItems: previewQuickItems,
activeItem: previewQuickItems.firstWhere((item) => item.isActive),
scrollController: quickTabsController,
scrollController: scrollController,
showTitles: settings.quickTabSwitcherShowTitles,
showIsolatedTabUi: settings.showIsolatedTabUi,
hierarchyGlyphs: settings.quickTabSwitcherHierarchyGlyphs,
titleMaxWidth: settings.quickTabSwitcherTitleWidth,
showCloseButtonOnAllTabs:
settings.quickTabSwitcherShowCloseButtonOnAllTabs,
enablePinTabInMenu: false,
onSelected: (_) async {},
onCloseItem: (_) async {},
);
}
Widget buildQuickTabSwitcher() {
// 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),
],
);
}
return buildQuickTabSwitcherRow(quickTabsController);
}
Widget buildContextualToolbar() {
return ContextualToolbarView(
buttons: [
@@ -235,10 +260,13 @@ class TabBarPreviewCard extends HookWidget {
if (showMainToolbarActionButtons) NavigationMenuButtonView(onTap: () {}),
];
final showQuickTabSwitcherBar =
settings.effectiveTabBarStackingMode() != TabBarStackingMode.disabled;
final bottomCombinedToolbar = BrowserTabBarView(
showMainToolbar: true,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
displayAppBar: true,
displayQuickTabSwitcher: true,
backgroundColor:
@@ -270,7 +298,7 @@ class TabBarPreviewCard extends HookWidget {
final topBottomToolbar = BrowserTabBarView(
showMainToolbar: false,
showContextualToolbar: settings.tabBarShowContextualBar,
showQuickTabSwitcherBar: settings.tabBarShowQuickTabSwitcherBar,
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
displayAppBar: false,
displayQuickTabSwitcher: true,
backgroundColor: colorScheme.surfaceContainer,
@@ -47,6 +47,13 @@ const defaultQuickTabSwitcherHierarchyGlyphs = 2;
const minQuickTabSwitcherHierarchyGlyphs = 0;
const maxQuickTabSwitcherHierarchyGlyphs = 4;
/// Max width (logical px) of the title text on a quick tab switcher chip.
/// The default of 64 sits at the 1/3 position of the slider scale.
const defaultQuickTabSwitcherTitleWidth = 64.0;
const minQuickTabSwitcherTitleWidth = 32.0;
const maxQuickTabSwitcherTitleWidth = 128.0;
const quickTabSwitcherTitleWidthStep = 8.0;
/// Controls the Android display refresh rate the app requests at startup.
///
/// Flutter does not request a high refresh rate by default, so on many devices
@@ -58,8 +65,24 @@ enum RefreshRateMode { system, high, low }
enum TabBarSwipeAction { switchLastOpened, navigateOrderedTabs }
/// Row kind rendered inside the quick tab switcher bar. [TabBarStackingMode]
/// decides which row(s) are shown; this enum identifies a single row.
enum QuickTabSwitcherMode { lastUsedTabs, containerTabs }
/// Layout of the quick tab switcher bar. Merges the former
/// "show tab switcher bar" toggle and [QuickTabSwitcherMode] selection.
///
/// [accordion] renders all containers as header chips with the selected
/// container's tabs expanded inline. [twoLevel] stacks a container-tabs row
/// on top of a recently-used row. [disabled] hides the bar entirely.
enum TabBarStackingMode {
lastUsedTabs,
containerTabs,
accordion,
twoLevel,
disabled,
}
enum TabIntentOpenSetting { regular, private, isolated, ask }
enum TabDirection { newestFirst, oldestFirst }
@@ -118,10 +141,9 @@ class GeneralSettings with FastEquatable {
final Duration historyAutoCleanInterval;
final bool tabViewBottomSheet;
final bool tabBarShowContextualBar;
final bool tabBarShowQuickTabSwitcherBar;
final TabBarPosition tabBarPosition;
final TabBarLayout tabBarLayout;
final QuickTabSwitcherMode quickTabSwitcherMode;
final TabBarStackingMode tabBarStackingMode;
final bool pullToRefreshEnabled;
final bool useExternalDownloadManager;
final bool doubleBackCloseTab;
@@ -132,6 +154,13 @@ class GeneralSettings with FastEquatable {
final bool quickTabSwitcherShowTitles;
final int quickTabSwitcherHierarchyGlyphs;
final bool quickTabSwitcherShowHistorySuggestions;
/// Max width (logical px) for chip titles in the quick tab switcher.
final double quickTabSwitcherTitleWidth;
/// Whether every tab chip in the quick tab switcher shows a close button.
/// The selected tab's chip always shows one regardless of this setting.
final bool quickTabSwitcherShowCloseButtonOnAllTabs;
final String syncServerOverride;
final String syncTokenServerOverride;
final bool urlCleanerEnabled;
@@ -202,10 +231,9 @@ class GeneralSettings with FastEquatable {
required this.historyAutoCleanInterval,
required this.tabViewBottomSheet,
required this.tabBarShowContextualBar,
required this.tabBarShowQuickTabSwitcherBar,
required this.tabBarPosition,
required this.tabBarLayout,
required this.quickTabSwitcherMode,
required this.tabBarStackingMode,
required this.pullToRefreshEnabled,
required this.useExternalDownloadManager,
required this.doubleBackCloseTab,
@@ -216,6 +244,8 @@ class GeneralSettings with FastEquatable {
required this.quickTabSwitcherShowTitles,
required this.quickTabSwitcherHierarchyGlyphs,
required this.quickTabSwitcherShowHistorySuggestions,
required this.quickTabSwitcherTitleWidth,
required this.quickTabSwitcherShowCloseButtonOnAllTabs,
required this.syncServerOverride,
required this.syncTokenServerOverride,
required this.urlCleanerEnabled,
@@ -267,10 +297,9 @@ class GeneralSettings with FastEquatable {
Duration? historyAutoCleanInterval,
bool? tabViewBottomSheet,
bool? tabBarShowContextualBar,
bool? tabBarShowQuickTabSwitcherBar,
TabBarPosition? tabBarPosition,
TabBarLayout? tabBarLayout,
QuickTabSwitcherMode? quickTabSwitcherMode,
TabBarStackingMode? tabBarStackingMode,
bool? pullToRefreshEnabled,
bool? useExternalDownloadManager,
bool? doubleBackCloseTab,
@@ -281,6 +310,8 @@ class GeneralSettings with FastEquatable {
bool? quickTabSwitcherShowTitles,
int? quickTabSwitcherHierarchyGlyphs,
bool? quickTabSwitcherShowHistorySuggestions,
double? quickTabSwitcherTitleWidth,
bool? quickTabSwitcherShowCloseButtonOnAllTabs,
String? syncServerOverride,
String? syncTokenServerOverride,
bool? urlCleanerEnabled,
@@ -332,11 +363,9 @@ class GeneralSettings with FastEquatable {
historyAutoCleanInterval ?? const Duration(days: 90),
tabViewBottomSheet = tabViewBottomSheet ?? false,
tabBarShowContextualBar = tabBarShowContextualBar ?? true,
tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true,
tabBarPosition = tabBarPosition ?? TabBarPosition.bottom,
tabBarLayout = tabBarLayout ?? TabBarLayout.compact,
quickTabSwitcherMode =
quickTabSwitcherMode ?? QuickTabSwitcherMode.lastUsedTabs,
tabBarStackingMode = tabBarStackingMode ?? TabBarStackingMode.accordion,
pullToRefreshEnabled = pullToRefreshEnabled ?? true,
useExternalDownloadManager = useExternalDownloadManager ?? false,
doubleBackCloseTab = doubleBackCloseTab ?? true,
@@ -351,6 +380,10 @@ class GeneralSettings with FastEquatable {
defaultQuickTabSwitcherHierarchyGlyphs,
quickTabSwitcherShowHistorySuggestions =
quickTabSwitcherShowHistorySuggestions ?? true,
quickTabSwitcherTitleWidth =
quickTabSwitcherTitleWidth ?? defaultQuickTabSwitcherTitleWidth,
quickTabSwitcherShowCloseButtonOnAllTabs =
quickTabSwitcherShowCloseButtonOnAllTabs ?? false,
syncServerOverride = syncServerOverride ?? '',
syncTokenServerOverride = syncTokenServerOverride ?? '',
urlCleanerEnabled = urlCleanerEnabled ?? true,
@@ -394,6 +427,24 @@ class GeneralSettings with FastEquatable {
json.putIfAbsent('tabListDirection', () => mapped);
json.putIfAbsent('tabBarDirection', () => mapped);
}
// Migrate the legacy `tabBarShowQuickTabSwitcherBar` toggle and
// `quickTabSwitcherMode` selection to the merged `tabBarStackingMode`.
// The legacy mode names are a subset of the new enum's, so values map
// verbatim.
// TODO: Drop this fallback (and the legacy rows in the user settings DB)
// once enough releases have shipped that rolling back to a version
// without `tabBarStackingMode` is no longer a concern.
final legacyShowSwitcherBar = json['tabBarShowQuickTabSwitcherBar'];
final legacySwitcherMode = json['quickTabSwitcherMode'];
if (json['tabBarStackingMode'] == null) {
if (legacyShowSwitcherBar == false) {
json['tabBarStackingMode'] = 'disabled';
} else if (legacySwitcherMode != null) {
json['tabBarStackingMode'] = legacySwitcherMode;
}
}
return _$GeneralSettingsFromJson(json);
}
@@ -421,12 +472,18 @@ class GeneralSettings with FastEquatable {
return tabIntentOpenSetting;
}
QuickTabSwitcherMode effectiveUiQuickTabSwitcherMode() {
/// Container-dependent stacking modes degrade to a single recently-used
/// row when the container UI is disabled.
TabBarStackingMode effectiveTabBarStackingMode() {
if (!showContainerUi &&
quickTabSwitcherMode == QuickTabSwitcherMode.containerTabs) {
return QuickTabSwitcherMode.lastUsedTabs;
const {
TabBarStackingMode.containerTabs,
TabBarStackingMode.accordion,
TabBarStackingMode.twoLevel,
}.contains(tabBarStackingMode)) {
return TabBarStackingMode.lastUsedTabs;
}
return quickTabSwitcherMode;
return tabBarStackingMode;
}
@override
@@ -456,10 +513,9 @@ class GeneralSettings with FastEquatable {
historyAutoCleanInterval,
tabViewBottomSheet,
tabBarShowContextualBar,
tabBarShowQuickTabSwitcherBar,
tabBarPosition,
tabBarLayout,
quickTabSwitcherMode,
tabBarStackingMode,
pullToRefreshEnabled,
useExternalDownloadManager,
doubleBackCloseTab,
@@ -470,6 +526,8 @@ class GeneralSettings with FastEquatable {
quickTabSwitcherShowTitles,
quickTabSwitcherHierarchyGlyphs,
quickTabSwitcherShowHistorySuggestions,
quickTabSwitcherTitleWidth,
quickTabSwitcherShowCloseButtonOnAllTabs,
syncServerOverride,
syncTokenServerOverride,
urlCleanerEnabled,
@@ -65,17 +65,11 @@ abstract class _$GeneralSettingsCWProxy {
GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar);
GeneralSettings tabBarShowQuickTabSwitcherBar(
bool tabBarShowQuickTabSwitcherBar,
);
GeneralSettings tabBarPosition(TabBarPosition tabBarPosition);
GeneralSettings tabBarLayout(TabBarLayout tabBarLayout);
GeneralSettings quickTabSwitcherMode(
QuickTabSwitcherMode quickTabSwitcherMode,
);
GeneralSettings tabBarStackingMode(TabBarStackingMode tabBarStackingMode);
GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled);
@@ -103,6 +97,12 @@ abstract class _$GeneralSettingsCWProxy {
bool quickTabSwitcherShowHistorySuggestions,
);
GeneralSettings quickTabSwitcherTitleWidth(double quickTabSwitcherTitleWidth);
GeneralSettings quickTabSwitcherShowCloseButtonOnAllTabs(
bool quickTabSwitcherShowCloseButtonOnAllTabs,
);
GeneralSettings syncServerOverride(String syncServerOverride);
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
@@ -186,10 +186,9 @@ abstract class _$GeneralSettingsCWProxy {
Duration historyAutoCleanInterval,
bool tabViewBottomSheet,
bool tabBarShowContextualBar,
bool tabBarShowQuickTabSwitcherBar,
TabBarPosition tabBarPosition,
TabBarLayout tabBarLayout,
QuickTabSwitcherMode quickTabSwitcherMode,
TabBarStackingMode tabBarStackingMode,
bool pullToRefreshEnabled,
bool useExternalDownloadManager,
bool doubleBackCloseTab,
@@ -200,6 +199,8 @@ abstract class _$GeneralSettingsCWProxy {
bool quickTabSwitcherShowTitles,
int quickTabSwitcherHierarchyGlyphs,
bool quickTabSwitcherShowHistorySuggestions,
double quickTabSwitcherTitleWidth,
bool quickTabSwitcherShowCloseButtonOnAllTabs,
String syncServerOverride,
String syncTokenServerOverride,
bool urlCleanerEnabled,
@@ -337,11 +338,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar) =>
call(tabBarShowContextualBar: tabBarShowContextualBar);
@override
GeneralSettings tabBarShowQuickTabSwitcherBar(
bool tabBarShowQuickTabSwitcherBar,
) => call(tabBarShowQuickTabSwitcherBar: tabBarShowQuickTabSwitcherBar);
@override
GeneralSettings tabBarPosition(TabBarPosition tabBarPosition) =>
call(tabBarPosition: tabBarPosition);
@@ -351,9 +347,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
call(tabBarLayout: tabBarLayout);
@override
GeneralSettings quickTabSwitcherMode(
QuickTabSwitcherMode quickTabSwitcherMode,
) => call(quickTabSwitcherMode: quickTabSwitcherMode);
GeneralSettings tabBarStackingMode(TabBarStackingMode tabBarStackingMode) =>
call(tabBarStackingMode: tabBarStackingMode);
@override
GeneralSettings pullToRefreshEnabled(bool pullToRefreshEnabled) =>
@@ -401,6 +396,19 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
quickTabSwitcherShowHistorySuggestions,
);
@override
GeneralSettings quickTabSwitcherTitleWidth(
double quickTabSwitcherTitleWidth,
) => call(quickTabSwitcherTitleWidth: quickTabSwitcherTitleWidth);
@override
GeneralSettings quickTabSwitcherShowCloseButtonOnAllTabs(
bool quickTabSwitcherShowCloseButtonOnAllTabs,
) => call(
quickTabSwitcherShowCloseButtonOnAllTabs:
quickTabSwitcherShowCloseButtonOnAllTabs,
);
@override
GeneralSettings syncServerOverride(String syncServerOverride) =>
call(syncServerOverride: syncServerOverride);
@@ -529,10 +537,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? historyAutoCleanInterval = const $CopyWithPlaceholder(),
Object? tabViewBottomSheet = const $CopyWithPlaceholder(),
Object? tabBarShowContextualBar = const $CopyWithPlaceholder(),
Object? tabBarShowQuickTabSwitcherBar = const $CopyWithPlaceholder(),
Object? tabBarPosition = const $CopyWithPlaceholder(),
Object? tabBarLayout = const $CopyWithPlaceholder(),
Object? quickTabSwitcherMode = const $CopyWithPlaceholder(),
Object? tabBarStackingMode = const $CopyWithPlaceholder(),
Object? pullToRefreshEnabled = const $CopyWithPlaceholder(),
Object? useExternalDownloadManager = const $CopyWithPlaceholder(),
Object? doubleBackCloseTab = const $CopyWithPlaceholder(),
@@ -544,6 +551,9 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? quickTabSwitcherHierarchyGlyphs = const $CopyWithPlaceholder(),
Object? quickTabSwitcherShowHistorySuggestions =
const $CopyWithPlaceholder(),
Object? quickTabSwitcherTitleWidth = const $CopyWithPlaceholder(),
Object? quickTabSwitcherShowCloseButtonOnAllTabs =
const $CopyWithPlaceholder(),
Object? syncServerOverride = const $CopyWithPlaceholder(),
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
Object? urlCleanerEnabled = const $CopyWithPlaceholder(),
@@ -714,12 +724,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.tabBarShowContextualBar
// ignore: cast_nullable_to_non_nullable
: tabBarShowContextualBar as bool,
tabBarShowQuickTabSwitcherBar:
tabBarShowQuickTabSwitcherBar == const $CopyWithPlaceholder() ||
tabBarShowQuickTabSwitcherBar == null
? _value.tabBarShowQuickTabSwitcherBar
// ignore: cast_nullable_to_non_nullable
: tabBarShowQuickTabSwitcherBar as bool,
tabBarPosition:
tabBarPosition == const $CopyWithPlaceholder() ||
tabBarPosition == null
@@ -731,12 +735,12 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.tabBarLayout
// ignore: cast_nullable_to_non_nullable
: tabBarLayout as TabBarLayout,
quickTabSwitcherMode:
quickTabSwitcherMode == const $CopyWithPlaceholder() ||
quickTabSwitcherMode == null
? _value.quickTabSwitcherMode
tabBarStackingMode:
tabBarStackingMode == const $CopyWithPlaceholder() ||
tabBarStackingMode == null
? _value.tabBarStackingMode
// ignore: cast_nullable_to_non_nullable
: quickTabSwitcherMode as QuickTabSwitcherMode,
: tabBarStackingMode as TabBarStackingMode,
pullToRefreshEnabled:
pullToRefreshEnabled == const $CopyWithPlaceholder() ||
pullToRefreshEnabled == null
@@ -798,6 +802,19 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.quickTabSwitcherShowHistorySuggestions
// ignore: cast_nullable_to_non_nullable
: quickTabSwitcherShowHistorySuggestions as bool,
quickTabSwitcherTitleWidth:
quickTabSwitcherTitleWidth == const $CopyWithPlaceholder() ||
quickTabSwitcherTitleWidth == null
? _value.quickTabSwitcherTitleWidth
// ignore: cast_nullable_to_non_nullable
: quickTabSwitcherTitleWidth as double,
quickTabSwitcherShowCloseButtonOnAllTabs:
quickTabSwitcherShowCloseButtonOnAllTabs ==
const $CopyWithPlaceholder() ||
quickTabSwitcherShowCloseButtonOnAllTabs == null
? _value.quickTabSwitcherShowCloseButtonOnAllTabs
// ignore: cast_nullable_to_non_nullable
: quickTabSwitcherShowCloseButtonOnAllTabs as bool,
syncServerOverride:
syncServerOverride == const $CopyWithPlaceholder() ||
syncServerOverride == null
@@ -1005,7 +1022,6 @@ GeneralSettings _$GeneralSettingsFromJson(
),
tabViewBottomSheet: json['tabViewBottomSheet'] as bool?,
tabBarShowContextualBar: json['tabBarShowContextualBar'] as bool?,
tabBarShowQuickTabSwitcherBar: json['tabBarShowQuickTabSwitcherBar'] as bool?,
tabBarPosition: $enumDecodeNullable(
_$TabBarPositionEnumMap,
json['tabBarPosition'],
@@ -1014,9 +1030,9 @@ GeneralSettings _$GeneralSettingsFromJson(
_$TabBarLayoutEnumMap,
json['tabBarLayout'],
),
quickTabSwitcherMode: $enumDecodeNullable(
_$QuickTabSwitcherModeEnumMap,
json['quickTabSwitcherMode'],
tabBarStackingMode: $enumDecodeNullable(
_$TabBarStackingModeEnumMap,
json['tabBarStackingMode'],
),
pullToRefreshEnabled: json['pullToRefreshEnabled'] as bool?,
useExternalDownloadManager: json['useExternalDownloadManager'] as bool?,
@@ -1036,6 +1052,10 @@ GeneralSettings _$GeneralSettingsFromJson(
(json['quickTabSwitcherHierarchyGlyphs'] as num?)?.toInt(),
quickTabSwitcherShowHistorySuggestions:
json['quickTabSwitcherShowHistorySuggestions'] as bool?,
quickTabSwitcherTitleWidth: (json['quickTabSwitcherTitleWidth'] as num?)
?.toDouble(),
quickTabSwitcherShowCloseButtonOnAllTabs:
json['quickTabSwitcherShowCloseButtonOnAllTabs'] as bool?,
syncServerOverride: json['syncServerOverride'] as String?,
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
urlCleanerEnabled: json['urlCleanerEnabled'] as bool?,
@@ -1107,11 +1127,10 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'historyAutoCleanInterval': instance.historyAutoCleanInterval.inMicroseconds,
'tabViewBottomSheet': instance.tabViewBottomSheet,
'tabBarShowContextualBar': instance.tabBarShowContextualBar,
'tabBarShowQuickTabSwitcherBar': instance.tabBarShowQuickTabSwitcherBar,
'tabBarPosition': _$TabBarPositionEnumMap[instance.tabBarPosition]!,
'tabBarLayout': _$TabBarLayoutEnumMap[instance.tabBarLayout]!,
'quickTabSwitcherMode':
_$QuickTabSwitcherModeEnumMap[instance.quickTabSwitcherMode]!,
'tabBarStackingMode':
_$TabBarStackingModeEnumMap[instance.tabBarStackingMode]!,
'pullToRefreshEnabled': instance.pullToRefreshEnabled,
'useExternalDownloadManager': instance.useExternalDownloadManager,
'doubleBackCloseTab': instance.doubleBackCloseTab,
@@ -1124,6 +1143,9 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'quickTabSwitcherHierarchyGlyphs': instance.quickTabSwitcherHierarchyGlyphs,
'quickTabSwitcherShowHistorySuggestions':
instance.quickTabSwitcherShowHistorySuggestions,
'quickTabSwitcherTitleWidth': instance.quickTabSwitcherTitleWidth,
'quickTabSwitcherShowCloseButtonOnAllTabs':
instance.quickTabSwitcherShowCloseButtonOnAllTabs,
'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride,
'urlCleanerEnabled': instance.urlCleanerEnabled,
@@ -1214,9 +1236,12 @@ const _$TabBarLayoutEnumMap = {
TabBarLayout.compact: 'compact',
};
const _$QuickTabSwitcherModeEnumMap = {
QuickTabSwitcherMode.lastUsedTabs: 'lastUsedTabs',
QuickTabSwitcherMode.containerTabs: 'containerTabs',
const _$TabBarStackingModeEnumMap = {
TabBarStackingMode.lastUsedTabs: 'lastUsedTabs',
TabBarStackingMode.containerTabs: 'containerTabs',
TabBarStackingMode.accordion: 'accordion',
TabBarStackingMode.twoLevel: 'twoLevel',
TabBarStackingMode.disabled: 'disabled',
};
const _$IntentSourcePolicyEnumMap = {
@@ -156,6 +156,10 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.string,
db.typeMapping,
),
'tabBarStackingMode': settings['tabBarStackingMode']?.readAs(
DriftSqlType.string,
db.typeMapping,
),
'pullToRefreshEnabled': settings['pullToRefreshEnabled']?.readAs(
DriftSqlType.bool,
db.typeMapping,
@@ -195,6 +199,13 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool,
db.typeMapping,
),
'quickTabSwitcherTitleWidth': settings['quickTabSwitcherTitleWidth']
?.readAs(DriftSqlType.double, db.typeMapping),
'quickTabSwitcherShowCloseButtonOnAllTabs':
settings['quickTabSwitcherShowCloseButtonOnAllTabs']?.readAs(
DriftSqlType.bool,
db.typeMapping,
),
'syncServerOverride': settings['syncServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'eb369cd699e336cd5ca51709f116883ae8c6dbc4';
r'ad70d95bd7f57b9ba06b0500a651b4e41ade572a';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {
@@ -19,6 +19,7 @@
*/
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/features/web_feed/data/models/feed_category.dart';
@@ -61,10 +62,9 @@ class TagsHorizontalList extends StatelessWidget {
fadingSize: 15,
builder: (context, controller) {
return ListView.builder(
scrollCacheExtent: const ScrollCacheExtent.pixels(0),
itemCount: _tags.length,
controller: controller,
//Improve list performance by not rendering outside screen at all
cacheExtent: 0,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => _tags[index],
);
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Keeps the "active" chip of a horizontally-scrolling chip row centered in
/// its viewport, reliably even when that chip is far outside the currently
/// built (lazy) range.
///
/// A plain [Scrollable.ensureVisible] is not enough for these rows: the chips
/// live in a horizontal `ListView.builder`, so an active chip outside the
/// build/cache range has no `BuildContext` and there is nothing to scroll to.
/// Their widths also vary (titles, badges, hierarchy glyphs), so a single
/// index-proportional jump usually lands the target close but not exactly.
///
/// This hook runs a short, bounded converge loop instead. Each pass either:
/// * centers the chip precisely with [Scrollable.ensureVisible] (when the
/// chip is built and therefore has a context), finishing the loop; or
/// * jumps to an index-proportional estimate of the chip's centered offset
/// to pull it into the build/cache range, then retries on the next frame.
///
/// When the estimate is too far off for the chip to build (most likely in the
/// accordion, whose rows mix wide container headers with narrow tab chips),
/// later passes fan out from the estimate in alternating viewport-sized steps
/// so the search window sweeps across the error instead of stalling. The
/// target enters the cache range within a few passes and the final
/// `ensureVisible` snaps it to the exact center — handling the "calculation is
/// a bit off" (variable widths), "doesn't work at all" (metrics not ready on
/// the first frame, e.g. two stacked rows), and far-off-estimate cases.
///
/// The routine re-runs whenever [activeId] changes or its position within
/// [orderedIds] changes (reorders, insertions, container switches), and is
/// suppressed while [isUserScrolling] returns true so it never fights a manual
/// scroll.
void useScrollToActiveChip<K>({
required ScrollController controller,
required GlobalKey activeChipKey,
required K? activeId,
required List<K> orderedIds,
required bool Function() isUserScrolling,
Duration animationDuration = const Duration(milliseconds: 200),
int maxPasses = 8,
}) {
// Index of the active chip drives both the estimate and the effect's
// re-run trigger: a selection change, a reorder, or an insertion that
// shifts the active chip all change this value.
final activeIndex = activeId == null ? -1 : orderedIds.indexOf(activeId);
final totalCount = orderedIds.length;
useEffect(() {
if (activeIndex < 0) return null;
if (isUserScrolling()) return null;
var cancelled = false;
void runPass(int pass) {
if (cancelled || isUserScrolling()) return;
void scheduleNext() {
if (pass >= maxPasses) return;
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(pass + 1));
}
if (!controller.hasClients) {
// Viewport not attached yet (common on the first frame, and worse for
// two stacked rows); wait for it to come up.
scheduleNext();
return;
}
final chipContext = activeChipKey.currentContext;
if (chipContext != null) {
// The chip is built: snap it to the exact center and stop. Any late
// layout shift (image/badge resize) is small enough to ignore.
unawaited(
Scrollable.ensureVisible(
chipContext,
alignment: 0.5,
duration: animationDuration,
curve: Curves.easeInOut,
),
);
return;
}
// The chip is outside the build/cache range, so there is no context to
// center yet. Jump to an estimate of its offset to pull it into range,
// then refine on the next pass once it has been built.
final position = controller.position;
final maxExtent = position.maxScrollExtent;
final viewport = position.viewportDimension;
if (maxExtent <= 0 || totalCount <= 0) {
// Metrics not ready (or everything fits): retry until they settle.
scheduleNext();
return;
}
// Index-proportional estimate of the chip's centered offset, assuming
// roughly uniform widths. Total content spans [0, maxExtent + viewport].
final contentExtent = maxExtent + viewport;
final estimatedCenter = (activeIndex + 0.5) / totalCount * contentExtent;
final estimate = estimatedCenter - viewport / 2;
// Uniform widths are only an approximation — worst for the accordion,
// whose rows mix wide container headers with tab chips. When the first
// estimate doesn't surface the chip, fan out from it in alternating
// directions (pass 2: +1 viewport, pass 3: -1, pass 4: +2, ...) so the
// search window sweeps across the mis-estimate instead of stalling on a
// repeated identical jump. Each step overlaps the lazy cache, so the
// chip is guaranteed to build within a few passes.
final step = pass ~/ 2;
final direction = pass.isOdd ? 1 : -1;
final target = (estimate + direction * step * viewport).clamp(
0.0,
maxExtent,
);
controller.jumpTo(target);
scheduleNext();
}
WidgetsBinding.instance.addPostFrameCallback((_) => runPass(1));
return () => cancelled = true;
}, [activeId, activeIndex, totalCount]);
}
@@ -19,6 +19,7 @@
*/
import 'package:flutter/material.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -28,6 +29,7 @@ class UrlListTile extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
final Color? containerColor;
final IconData? containerIcon;
final bool useCustomColor;
final bool showHttpScheme;
final VoidCallback? onTap;
@@ -39,12 +41,14 @@ class UrlListTile extends StatelessWidget {
this.leading,
this.trailing,
this.containerColor,
this.containerIcon,
this.useCustomColor = false,
this.showHttpScheme = true,
this.onTap,
});
static const iconSize = 32.0;
static const _badgeWidth = 56.0;
static const _borderRadius = BorderRadius.all(Radius.circular(12.0));
@override
@@ -75,51 +79,91 @@ class UrlListTile extends StatelessWidget {
child: InkWell(
borderRadius: _borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
right: 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(child: UrlIcon([uri], iconSize: iconSize)),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
showHttpScheme: showHttpScheme,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
child: Stack(
children: [
Padding(
padding: EdgeInsets.only(
left: 12.0,
top: 10.0,
bottom: 10.0,
// Reserve room for the trailing badge so content never
// slides underneath it.
right: containerPalette != null ? _badgeWidth + 12.0 : 12.0,
),
child: Row(
children: [
leading ??
RepaintBoundary(
child: UrlIcon([uri], iconSize: iconSize),
),
const SizedBox(width: 14.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3.0),
UriBreadcrumb(
uri: uri,
showHttpScheme: showHttpScheme,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
),
// Stretches to the card height set by the content above without
// the extra layout pass an IntrinsicHeight Row would cost.
if (containerPalette != null)
Positioned(
top: 0.0,
bottom: 0.0,
right: 0.0,
width: _badgeWidth,
child: _ContainerBadge(
palette: containerPalette,
icon: resolveContainerIcon(containerIcon),
),
),
if (trailing != null) ...[
const SizedBox(width: 8.0),
trailing!,
],
],
),
],
),
),
),
);
}
}
/// Trailing accent-coloured strip flush against the card's right edge that
/// surfaces the owning container's identity (its colour and icon).
class _ContainerBadge extends StatelessWidget {
final ContainerColorPalette palette;
final IconData icon;
const _ContainerBadge({required this.palette, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
width: UrlListTile._badgeWidth,
alignment: Alignment.center,
color: palette.accentColor,
child: Icon(icon, size: 22.0, color: palette.onAccentColor),
);
}
}
@@ -92,12 +92,12 @@ void main() {
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
child: const MaterialApp(
home: SettingsDetailScaffold(
title: 'Appearance',
subtitle: 'Configure app appearance',
icon: Icons.palette,
sections: const [
sections: [
SettingsSectionDefinition(
title: 'Display',
entries: [