performance improvements

This commit is contained in:
Fabian Freund
2026-07-31 05:14:27 +02:00
parent c2c7cc0729
commit bdfc851681
36 changed files with 1988 additions and 608 deletions
@@ -19,26 +19,42 @@
*/ */
import 'dart:ui'; import 'dart:ui';
/// A wrapper around [Image] that compares by a precomputed byte-hash. /// What makes one decoded image the *same* image as another.
/// ///
/// Instances are byte-hash deduped via the decoder's LRU, so the same /// Deliberately a record rather than a single combined `int`: [digest] is a
/// `EquatableImage` may be shared across many holders (multiple tabs of /// 64-bit content hash, and folding it together with the decode options via
/// the same site, the icon cache, widget closures). To make that safe, /// `Object.hash` would truncate the whole thing to 30 bits (Dart caps hash
/// the underlying `ui.Image` is disposed via a [Finalizer] when this /// codes at `0x3fffffff`) and leave the result standing in as the identity
/// wrapper becomes unreachable — never on cache eviction or state /// with no exact comparison behind it. As a record, hash codes only pick the
/// transitions, where another holder might still be using it. /// bucket and structural `==` decides equality, so the full digest and the
/// exact options both count.
typedef ImageIdentity = ({
int digest,
int? targetWidth,
int? targetHeight,
bool allowUpscaling,
});
/// A wrapper around [Image] that compares by a precomputed [ImageIdentity].
///
/// Instances are deduped via the decoder's LRU, so the same `EquatableImage`
/// may be shared across many holders (multiple tabs of the same site, the icon
/// cache, widget closures). To make that safe, the underlying `ui.Image` is
/// disposed via a [Finalizer] when this wrapper becomes unreachable — never on
/// cache eviction or state transitions, where another holder might still be
/// using it.
class EquatableImage { class EquatableImage {
static final Finalizer<Image> _finalizer = Finalizer<Image>( static final Finalizer<Image> _finalizer = Finalizer<Image>(
(image) => image.dispose(), (image) => image.dispose(),
); );
Image? _value; Image? _value;
final int _imageHash; final ImageIdentity _identity;
bool _isDisposed = false; bool _isDisposed = false;
EquatableImage(Image value, {required int hash}) EquatableImage(Image value, {required ImageIdentity identity})
: _value = value, : _value = value,
_imageHash = hash { _identity = identity {
_finalizer.attach(this, value, detach: this); _finalizer.attach(this, value, detach: this);
} }
@@ -63,10 +79,10 @@ class EquatableImage {
} }
@override @override
int get hashCode => _imageHash.hashCode; int get hashCode => _identity.hashCode;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is EquatableImage && other._imageHash == _imageHash; return other is EquatableImage && other._identity == _identity;
} }
} }
@@ -23,15 +23,20 @@ import 'package:nullability/nullability.dart';
import 'package:weblibre/data/models/web_page_info.dart'; import 'package:weblibre/data/models/web_page_info.dart';
import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart'; import 'package:weblibre/features/geckoview/domain/entities/browser_icon.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/security.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/security.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
part 'tab.g.dart'; part 'tab.g.dart';
/// Per-tab state the browser chrome and tab ordering depend on.
///
/// Deliberately narrow: the whole `Map<String, TabState>` is watched by the
/// providers feeding the always-visible quick tab switcher and the grouped tab
/// list, so any field added here rebuilds every visible chip whenever it
/// changes on *any* tab. High-churn fields (load progress, thumbnails, session
/// history, find-in-page results, translation) live in per-tab notifiers in
/// `providers/tab_detail_state.dart` instead.
@CopyWith(constructor: '_') @CopyWith(constructor: '_')
class TabState extends WebPageInfo { class TabState extends WebPageInfo {
static final defaultUrl = Uri.parse('about:blank'); static final defaultUrl = Uri.parse('about:blank');
@@ -59,10 +64,6 @@ class TabState extends WebPageInfo {
), ),
); );
final EquatableImage? thumbnail;
final int progress;
final TabMode tabMode; final TabMode tabMode;
String? get isolationContextId => tabMode.isolationContextId; String? get isolationContextId => tabMode.isolationContextId;
@@ -70,13 +71,8 @@ class TabState extends WebPageInfo {
final bool isLoading; final bool isLoading;
final bool showToolbarAsExpanded; final bool showToolbarAsExpanded;
bool get isFinishedLoading => !isLoading && progress == 100;
final SecurityState securityInfoState; final SecurityState securityInfoState;
final HistoryState historyState;
final ReaderableState readerableState; final ReaderableState readerableState;
final FindResultState findResultState;
final TranslationState translationState;
TabState({ TabState({
required this.id, required this.id,
@@ -85,17 +81,12 @@ class TabState extends WebPageInfo {
required super.url, required super.url,
required String title, required String title,
required this.icon, required this.icon,
required this.thumbnail,
required this.progress,
this.tabMode = TabMode.regular, this.tabMode = TabMode.regular,
required this.isFullScreen, required this.isFullScreen,
required this.isLoading, required this.isLoading,
required this.showToolbarAsExpanded, required this.showToolbarAsExpanded,
required this.securityInfoState, required this.securityInfoState,
required this.historyState,
required this.readerableState, required this.readerableState,
required this.findResultState,
required this.translationState,
}) : super(title: title.trim()); }) : super(title: title.trim());
TabState._({ TabState._({
@@ -105,17 +96,12 @@ class TabState extends WebPageInfo {
required super.url, required super.url,
required super.title, required super.title,
required this.icon, required this.icon,
required this.thumbnail,
required this.progress,
required this.tabMode, required this.tabMode,
required this.isFullScreen, required this.isFullScreen,
required this.isLoading, required this.isLoading,
required this.showToolbarAsExpanded, required this.showToolbarAsExpanded,
required this.securityInfoState, required this.securityInfoState,
required this.historyState,
required this.readerableState, required this.readerableState,
required this.findResultState,
required this.translationState,
}); });
factory TabState.$default(String tabId) => TabState( factory TabState.$default(String tabId) => TabState(
@@ -125,16 +111,11 @@ class TabState extends WebPageInfo {
url: defaultUrl, url: defaultUrl,
title: "", title: "",
icon: null, icon: null,
thumbnail: null,
progress: 0,
isFullScreen: false, isFullScreen: false,
isLoading: false, isLoading: false,
showToolbarAsExpanded: false, showToolbarAsExpanded: false,
securityInfoState: SecurityState.$default(), securityInfoState: SecurityState.$default(),
historyState: HistoryState.$default(),
readerableState: ReaderableState.$default(), readerableState: ReaderableState.$default(),
findResultState: FindResultState.$default(),
translationState: TranslationState.$default(),
); );
@override @override
@@ -144,16 +125,11 @@ class TabState extends WebPageInfo {
parentId, parentId,
contextId, contextId,
icon, icon,
thumbnail,
progress,
tabMode, tabMode,
isFullScreen, isFullScreen,
isLoading, isLoading,
showToolbarAsExpanded, showToolbarAsExpanded,
securityInfoState, securityInfoState,
historyState,
readerableState, readerableState,
findResultState,
translationState,
]; ];
} }
@@ -17,10 +17,6 @@ abstract class _$TabStateCWProxy {
TabState icon(EquatableImage? icon); TabState icon(EquatableImage? icon);
TabState thumbnail(EquatableImage? thumbnail);
TabState progress(int progress);
TabState tabMode(TabMode tabMode); TabState tabMode(TabMode tabMode);
TabState isFullScreen(bool isFullScreen); TabState isFullScreen(bool isFullScreen);
@@ -31,14 +27,8 @@ abstract class _$TabStateCWProxy {
TabState securityInfoState(SecurityState securityInfoState); TabState securityInfoState(SecurityState securityInfoState);
TabState historyState(HistoryState historyState);
TabState readerableState(ReaderableState readerableState); TabState readerableState(ReaderableState readerableState);
TabState findResultState(FindResultState findResultState);
TabState translationState(TranslationState translationState);
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`.
/// ///
@@ -52,17 +42,12 @@ abstract class _$TabStateCWProxy {
Uri url, Uri url,
String? title, String? title,
EquatableImage? icon, EquatableImage? icon,
EquatableImage? thumbnail,
int progress,
TabMode tabMode, TabMode tabMode,
bool isFullScreen, bool isFullScreen,
bool isLoading, bool isLoading,
bool showToolbarAsExpanded, bool showToolbarAsExpanded,
SecurityState securityInfoState, SecurityState securityInfoState,
HistoryState historyState,
ReaderableState readerableState, ReaderableState readerableState,
FindResultState findResultState,
TranslationState translationState,
}); });
} }
@@ -88,12 +73,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
@override @override
TabState icon(EquatableImage? icon) => call(icon: icon); TabState icon(EquatableImage? icon) => call(icon: icon);
@override
TabState thumbnail(EquatableImage? thumbnail) => call(thumbnail: thumbnail);
@override
TabState progress(int progress) => call(progress: progress);
@override @override
TabState tabMode(TabMode tabMode) => call(tabMode: tabMode); TabState tabMode(TabMode tabMode) => call(tabMode: tabMode);
@@ -111,22 +90,10 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
TabState securityInfoState(SecurityState securityInfoState) => TabState securityInfoState(SecurityState securityInfoState) =>
call(securityInfoState: securityInfoState); call(securityInfoState: securityInfoState);
@override
TabState historyState(HistoryState historyState) =>
call(historyState: historyState);
@override @override
TabState readerableState(ReaderableState readerableState) => TabState readerableState(ReaderableState readerableState) =>
call(readerableState: readerableState); call(readerableState: readerableState);
@override
TabState findResultState(FindResultState findResultState) =>
call(findResultState: findResultState);
@override
TabState translationState(TranslationState translationState) =>
call(translationState: translationState);
@override @override
/// Creates a new instance with the provided field values. /// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `TabState(...).copyWith.fieldName(value)`.
@@ -141,17 +108,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
Object? url = const $CopyWithPlaceholder(), Object? url = const $CopyWithPlaceholder(),
Object? title = const $CopyWithPlaceholder(), Object? title = const $CopyWithPlaceholder(),
Object? icon = const $CopyWithPlaceholder(), Object? icon = const $CopyWithPlaceholder(),
Object? thumbnail = const $CopyWithPlaceholder(),
Object? progress = const $CopyWithPlaceholder(),
Object? tabMode = const $CopyWithPlaceholder(), Object? tabMode = const $CopyWithPlaceholder(),
Object? isFullScreen = const $CopyWithPlaceholder(), Object? isFullScreen = const $CopyWithPlaceholder(),
Object? isLoading = const $CopyWithPlaceholder(), Object? isLoading = const $CopyWithPlaceholder(),
Object? showToolbarAsExpanded = const $CopyWithPlaceholder(), Object? showToolbarAsExpanded = const $CopyWithPlaceholder(),
Object? securityInfoState = const $CopyWithPlaceholder(), Object? securityInfoState = const $CopyWithPlaceholder(),
Object? historyState = const $CopyWithPlaceholder(),
Object? readerableState = const $CopyWithPlaceholder(), Object? readerableState = const $CopyWithPlaceholder(),
Object? findResultState = const $CopyWithPlaceholder(),
Object? translationState = const $CopyWithPlaceholder(),
}) { }) {
return TabState._( return TabState._(
id: _value.id, id: _value.id,
@@ -175,14 +137,6 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
? _value.icon ? _value.icon
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: icon as EquatableImage?, : icon as EquatableImage?,
thumbnail: thumbnail == const $CopyWithPlaceholder()
? _value.thumbnail
// ignore: cast_nullable_to_non_nullable
: thumbnail as EquatableImage?,
progress: progress == const $CopyWithPlaceholder() || progress == null
? _value.progress
// ignore: cast_nullable_to_non_nullable
: progress as int,
tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null tabMode: tabMode == const $CopyWithPlaceholder() || tabMode == null
? _value.tabMode ? _value.tabMode
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
@@ -208,29 +162,12 @@ class _$TabStateCWProxyImpl implements _$TabStateCWProxy {
? _value.securityInfoState ? _value.securityInfoState
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: securityInfoState as SecurityState, : securityInfoState as SecurityState,
historyState:
historyState == const $CopyWithPlaceholder() || historyState == null
? _value.historyState
// ignore: cast_nullable_to_non_nullable
: historyState as HistoryState,
readerableState: readerableState:
readerableState == const $CopyWithPlaceholder() || readerableState == const $CopyWithPlaceholder() ||
readerableState == null readerableState == null
? _value.readerableState ? _value.readerableState
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: readerableState as ReaderableState, : readerableState as ReaderableState,
findResultState:
findResultState == const $CopyWithPlaceholder() ||
findResultState == null
? _value.findResultState
// ignore: cast_nullable_to_non_nullable
: findResultState as FindResultState,
translationState:
translationState == const $CopyWithPlaceholder() ||
translationState == null
? _value.translationState
// ignore: cast_nullable_to_non_nullable
: translationState as TranslationState,
); );
} }
} }
@@ -0,0 +1,186 @@
/*
* 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/>.
*/
/// High-churn per-tab state, deliberately kept *out* of [TabState].
///
/// [TabState] is fanned out to the always-visible browser chrome — the quick
/// tab switcher chips, the grouped tab list, the contextual toolbar — through
/// providers that watch the whole `Map<String, TabState>`. Any field that ticks
/// during a page load or on a background timer therefore rebuilt every chip on
/// screen, even though no chip renders that field.
///
/// The fields here are each consumed by exactly one or two widgets, so they
/// live in their own keyed notifiers and are read through per-tab selectors.
/// A progress tick or a background tab's screenshot now invalidates only the
/// widget that actually shows it.
library;
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/find_result.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
part 'tab_detail_state.g.dart';
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
@Riverpod(keepAlive: true)
class TabProgressStates extends _$TabProgressStates {
@override
Map<String, int> build() => const {};
void update(String tabId, int progress) {
if (state[tabId] == progress) {
return;
}
state = {...state}..[tabId] = progress;
}
}
@Riverpod()
int tabProgress(Ref ref, String? tabId) {
if (tabId == null) {
return 0;
}
return ref.watch(tabProgressStatesProvider.select((s) => s[tabId] ?? 0));
}
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
/// consumed only by the tab tray previews.
@Riverpod(keepAlive: true)
class TabThumbnails extends _$TabThumbnails {
@override
Map<String, EquatableImage> build() => const {};
void update(String tabId, EquatableImage? thumbnail) {
if (thumbnail == null) {
if (!state.containsKey(tabId)) {
return;
}
state = {...state}..remove(tabId);
return;
}
if (state[tabId] == thumbnail) {
return;
}
state = {...state}..[tabId] = thumbnail;
}
}
@Riverpod()
EquatableImage? tabThumbnail(Ref ref, String? tabId) {
if (tabId == null) {
return null;
}
return ref.watch(tabThumbnailsProvider.select((s) => s[tabId]));
}
/// Session history (back/forward stack) per tab.
@Riverpod(keepAlive: true)
class TabHistoryStates extends _$TabHistoryStates {
@override
Map<String, HistoryState> build() => const {};
void update(String tabId, HistoryState history) {
if (state[tabId] == history) {
return;
}
state = {...state}..[tabId] = history;
}
}
@Riverpod()
HistoryState tabHistoryState(Ref ref, String? tabId) {
if (tabId == null) {
return HistoryState.$default();
}
return ref.watch(
tabHistoryStatesProvider.select((s) => s[tabId] ?? HistoryState.$default()),
);
}
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
/// a search is running.
@Riverpod(keepAlive: true)
class TabFindResultStates extends _$TabFindResultStates {
@override
Map<String, FindResultState> build() => const {};
void update(String tabId, FindResultState result) {
if (state[tabId] == result) {
return;
}
state = {...state}..[tabId] = result;
}
FindResultState resultFor(String tabId) =>
state[tabId] ?? FindResultState.$default();
}
@Riverpod()
FindResultState tabFindResultState(Ref ref, String? tabId) {
if (tabId == null) {
return FindResultState.$default();
}
return ref.watch(
tabFindResultStatesProvider.select(
(s) => s[tabId] ?? FindResultState.$default(),
),
);
}
/// Translation progress/result per tab.
@Riverpod(keepAlive: true)
class TabTranslationStates extends _$TabTranslationStates {
@override
Map<String, TranslationState> build() => const {};
void update(String tabId, TranslationState translation) {
if (state[tabId] == translation) {
return;
}
state = {...state}..[tabId] = translation;
}
}
@Riverpod()
TranslationState tabTranslationState(Ref ref, String? tabId) {
if (tabId == null) {
return TranslationState.$default();
}
return ref.watch(
tabTranslationStatesProvider.select(
(s) => s[tabId] ?? TranslationState.$default(),
),
);
}
@@ -0,0 +1,727 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_detail_state.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
@ProviderFor(TabProgressStates)
final tabProgressStatesProvider = TabProgressStatesProvider._();
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
final class TabProgressStatesProvider
extends $NotifierProvider<TabProgressStates, Map<String, int>> {
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
TabProgressStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabProgressStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabProgressStatesHash();
@$internal
@override
TabProgressStates create() => TabProgressStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, int> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, int>>(value),
);
}
}
String _$tabProgressStatesHash() => r'60bb03e33e4322eb2869b90b5cc5802d3cdc225d';
/// Load progress (0-100) per tab. Ticks continuously while a page loads.
abstract class _$TabProgressStates extends $Notifier<Map<String, int>> {
Map<String, int> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<Map<String, int>, Map<String, int>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, int>, Map<String, int>>,
Map<String, int>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(tabProgress)
final tabProgressProvider = TabProgressFamily._();
final class TabProgressProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
TabProgressProvider._({
required TabProgressFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabProgressProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabProgressHash();
@override
String toString() {
return r'tabProgressProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
final argument = this.argument as String?;
return tabProgress(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabProgressProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabProgressHash() => r'83c4990d96f2160e919ee99882905c09872e8bb7';
final class TabProgressFamily extends $Family
with $FunctionalFamilyOverride<int, String?> {
TabProgressFamily._()
: super(
retry: null,
name: r'tabProgressProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabProgressProvider call(String? tabId) =>
TabProgressProvider._(argument: tabId, from: this);
@override
String toString() => r'tabProgressProvider';
}
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
/// consumed only by the tab tray previews.
@ProviderFor(TabThumbnails)
final tabThumbnailsProvider = TabThumbnailsProvider._();
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
/// consumed only by the tab tray previews.
final class TabThumbnailsProvider
extends $NotifierProvider<TabThumbnails, Map<String, EquatableImage>> {
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
/// consumed only by the tab tray previews.
TabThumbnailsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabThumbnailsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabThumbnailsHash();
@$internal
@override
TabThumbnails create() => TabThumbnails();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, EquatableImage> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, EquatableImage>>(value),
);
}
}
String _$tabThumbnailsHash() => r'16c4bb6edff2e6413100d9ce02c1ae7948740cfe';
/// Page screenshots per tab. Refreshed on a 10s timer for the selected tab and
/// consumed only by the tab tray previews.
abstract class _$TabThumbnails extends $Notifier<Map<String, EquatableImage>> {
Map<String, EquatableImage> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<Map<String, EquatableImage>, Map<String, EquatableImage>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<String, EquatableImage>,
Map<String, EquatableImage>
>,
Map<String, EquatableImage>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(tabThumbnail)
final tabThumbnailProvider = TabThumbnailFamily._();
final class TabThumbnailProvider
extends
$FunctionalProvider<EquatableImage?, EquatableImage?, EquatableImage?>
with $Provider<EquatableImage?> {
TabThumbnailProvider._({
required TabThumbnailFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabThumbnailProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabThumbnailHash();
@override
String toString() {
return r'tabThumbnailProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<EquatableImage?> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
EquatableImage? create(Ref ref) {
final argument = this.argument as String?;
return tabThumbnail(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableImage? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableImage?>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabThumbnailProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabThumbnailHash() => r'7c8b591abdb5e8cc914ab8e44247a4bca6bddd15';
final class TabThumbnailFamily extends $Family
with $FunctionalFamilyOverride<EquatableImage?, String?> {
TabThumbnailFamily._()
: super(
retry: null,
name: r'tabThumbnailProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabThumbnailProvider call(String? tabId) =>
TabThumbnailProvider._(argument: tabId, from: this);
@override
String toString() => r'tabThumbnailProvider';
}
/// Session history (back/forward stack) per tab.
@ProviderFor(TabHistoryStates)
final tabHistoryStatesProvider = TabHistoryStatesProvider._();
/// Session history (back/forward stack) per tab.
final class TabHistoryStatesProvider
extends $NotifierProvider<TabHistoryStates, Map<String, HistoryState>> {
/// Session history (back/forward stack) per tab.
TabHistoryStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabHistoryStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabHistoryStatesHash();
@$internal
@override
TabHistoryStates create() => TabHistoryStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, HistoryState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, HistoryState>>(value),
);
}
}
String _$tabHistoryStatesHash() => r'3920410334c4354ca66da605d9bfe85b4853f3bf';
/// Session history (back/forward stack) per tab.
abstract class _$TabHistoryStates extends $Notifier<Map<String, HistoryState>> {
Map<String, HistoryState> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref as $Ref<Map<String, HistoryState>, Map<String, HistoryState>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Map<String, HistoryState>, Map<String, HistoryState>>,
Map<String, HistoryState>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(tabHistoryState)
final tabHistoryStateProvider = TabHistoryStateFamily._();
final class TabHistoryStateProvider
extends $FunctionalProvider<HistoryState, HistoryState, HistoryState>
with $Provider<HistoryState> {
TabHistoryStateProvider._({
required TabHistoryStateFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabHistoryStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabHistoryStateHash();
@override
String toString() {
return r'tabHistoryStateProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<HistoryState> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
HistoryState create(Ref ref) {
final argument = this.argument as String?;
return tabHistoryState(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(HistoryState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<HistoryState>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabHistoryStateProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabHistoryStateHash() => r'5bca69fddb6fc2f3db6caf5c84bd1a7f5728e1d9';
final class TabHistoryStateFamily extends $Family
with $FunctionalFamilyOverride<HistoryState, String?> {
TabHistoryStateFamily._()
: super(
retry: null,
name: r'tabHistoryStateProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabHistoryStateProvider call(String? tabId) =>
TabHistoryStateProvider._(argument: tabId, from: this);
@override
String toString() => r'tabHistoryStateProvider';
}
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
/// a search is running.
@ProviderFor(TabFindResultStates)
final tabFindResultStatesProvider = TabFindResultStatesProvider._();
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
/// a search is running.
final class TabFindResultStatesProvider
extends
$NotifierProvider<TabFindResultStates, Map<String, FindResultState>> {
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
/// a search is running.
TabFindResultStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabFindResultStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabFindResultStatesHash();
@$internal
@override
TabFindResultStates create() => TabFindResultStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, FindResultState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, FindResultState>>(value),
);
}
}
String _$tabFindResultStatesHash() =>
r'f57dee1658eae789002ac3cff15808cbc96b7883';
/// Find-in-page match counters per tab. Emitted at a high rate by Gecko while
/// a search is running.
abstract class _$TabFindResultStates
extends $Notifier<Map<String, FindResultState>> {
Map<String, FindResultState> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<Map<String, FindResultState>, Map<String, FindResultState>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<String, FindResultState>,
Map<String, FindResultState>
>,
Map<String, FindResultState>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(tabFindResultState)
final tabFindResultStateProvider = TabFindResultStateFamily._();
final class TabFindResultStateProvider
extends
$FunctionalProvider<FindResultState, FindResultState, FindResultState>
with $Provider<FindResultState> {
TabFindResultStateProvider._({
required TabFindResultStateFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabFindResultStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabFindResultStateHash();
@override
String toString() {
return r'tabFindResultStateProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<FindResultState> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
FindResultState create(Ref ref) {
final argument = this.argument as String?;
return tabFindResultState(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(FindResultState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<FindResultState>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabFindResultStateProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabFindResultStateHash() =>
r'0f58040111bb43d830e14ae44569265b9ae804d9';
final class TabFindResultStateFamily extends $Family
with $FunctionalFamilyOverride<FindResultState, String?> {
TabFindResultStateFamily._()
: super(
retry: null,
name: r'tabFindResultStateProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabFindResultStateProvider call(String? tabId) =>
TabFindResultStateProvider._(argument: tabId, from: this);
@override
String toString() => r'tabFindResultStateProvider';
}
/// Translation progress/result per tab.
@ProviderFor(TabTranslationStates)
final tabTranslationStatesProvider = TabTranslationStatesProvider._();
/// Translation progress/result per tab.
final class TabTranslationStatesProvider
extends
$NotifierProvider<TabTranslationStates, Map<String, TranslationState>> {
/// Translation progress/result per tab.
TabTranslationStatesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabTranslationStatesProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabTranslationStatesHash();
@$internal
@override
TabTranslationStates create() => TabTranslationStates();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Map<String, TranslationState> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Map<String, TranslationState>>(
value,
),
);
}
}
String _$tabTranslationStatesHash() =>
r'f04004026dc5fd638319528a55e2f10e0f64c9b3';
/// Translation progress/result per tab.
abstract class _$TabTranslationStates
extends $Notifier<Map<String, TranslationState>> {
Map<String, TranslationState> build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref
as $Ref<
Map<String, TranslationState>,
Map<String, TranslationState>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
Map<String, TranslationState>,
Map<String, TranslationState>
>,
Map<String, TranslationState>,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}
@ProviderFor(tabTranslationState)
final tabTranslationStateProvider = TabTranslationStateFamily._();
final class TabTranslationStateProvider
extends
$FunctionalProvider<
TranslationState,
TranslationState,
TranslationState
>
with $Provider<TranslationState> {
TabTranslationStateProvider._({
required TabTranslationStateFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'tabTranslationStateProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabTranslationStateHash();
@override
String toString() {
return r'tabTranslationStateProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<TranslationState> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
TranslationState create(Ref ref) {
final argument = this.argument as String?;
return tabTranslationState(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(TranslationState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<TranslationState>(value),
);
}
@override
bool operator ==(Object other) {
return other is TabTranslationStateProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$tabTranslationStateHash() =>
r'886365e36167542b2e4dbdbeb97c942a414a752e';
final class TabTranslationStateFamily extends $Family
with $FunctionalFamilyOverride<TranslationState, String?> {
TabTranslationStateFamily._()
: super(
retry: null,
name: r'tabTranslationStateProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TabTranslationStateProvider call(String? tabId) =>
TabTranslationStateProvider._(argument: tabId, from: this);
@override
String toString() => r'tabTranslationStateProvider';
}
@@ -19,6 +19,7 @@
*/ */
import 'dart:async'; import 'dart:async';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart'; import 'package:riverpod/riverpod.dart';
@@ -34,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/translation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolation_context.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
@@ -46,8 +48,33 @@ import 'package:weblibre/features/user/domain/repositories/proxy_routing_setting
part 'tab_state.g.dart'; part 'tab_state.g.dart';
/// Decode width for tab thumbnails. The screenshots arrive at full device
/// resolution but are only ever shown in tab-tray previews a few hundred
/// logical pixels wide, so decoding them at native size burns main-isolate
/// time and GPU memory for detail nobody sees. Only the width is constrained
/// so the decoder keeps the aspect ratio; upscaling is disabled so smaller
/// sources are left alone.
const thumbnailDecodeWidth = 720;
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class TabStates extends _$TabStates { class TabStates extends _$TabStates {
/// Replaces the entry for [tabId] — but only when [next] actually differs.
///
/// Every write here allocates a new map, and `Map` compares by identity, so
/// an unconditional assignment notifies *all* whole-map watchers (the quick
/// tab switcher, the grouped tab list, the FIFO list) even when nothing
/// changed. Gecko re-emits the full content state on every progress tick, so
/// without this guard a single page load pushed dozens of no-op rebuilds
/// through the entire browser chrome. [TabState] is `FastEquatable`, so the
/// comparison is a cached-hash check.
void _put(String tabId, TabState next) {
if (state[tabId] == next) {
return;
}
state = {...state}..[tabId] = next;
}
Future<void> _onTabContentStateChange(TabContentState contentState) async { Future<void> _onTabContentStateChange(TabContentState contentState) async {
final current = await patchedState(contentState.id); final current = await patchedState(contentState.id);
@@ -82,14 +109,19 @@ class TabStates extends _$TabStates {
contextId: contentState.contextId, contextId: contentState.contextId,
url: url, url: url,
title: resolvedTitle, title: resolvedTitle,
progress: contentState.progress,
tabMode: inferredTabMode, tabMode: inferredTabMode,
isFullScreen: contentState.isFullScreen, isFullScreen: contentState.isFullScreen,
isLoading: contentState.isLoading, isLoading: contentState.isLoading,
showToolbarAsExpanded: contentState.showToolbarAsExpanded, showToolbarAsExpanded: contentState.showToolbarAsExpanded,
); );
state = {...state}..[contentState.id] = newState; _put(contentState.id, newState);
// Progress lives outside [TabState] so a load tick doesn't invalidate the
// whole map (and with it every chip in the quick tab switcher).
ref
.read(tabProgressStatesProvider.notifier)
.update(contentState.id, contentState.progress);
// Only reconcile DB hierarchy when the engine parent link actually changes. // Only reconcile DB hierarchy when the engine parent link actually changes.
// Content-state events also fire on every progress/title tick, and seeding // Content-state events also fire on every progress/title tick, and seeding
@@ -106,7 +138,7 @@ class TabStates extends _$TabStates {
); );
} }
if (newState.isFinishedLoading) { if (!contentState.isLoading && contentState.progress == 100) {
ref ref
.read(geckoInferenceRepositoryProvider.notifier) .read(geckoInferenceRepositoryProvider.notifier)
.markInitialLoadComplete(); .markInitialLoadComplete();
@@ -138,97 +170,111 @@ class TabStates extends _$TabStates {
final IconChangeEvent(:tabId, :bytes) = event; final IconChangeEvent(:tabId, :bytes) = event;
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
final current = state[tabId] ?? TabState.$default(tabId);
state = {...state}..[tabId] = current.copyWith.icon(image); if (!ref.mounted) {
return;
}
final current = state[tabId] ?? TabState.$default(tabId);
_put(tabId, current.copyWith.icon(image));
} }
Future<void> _onThumbnailChange(ThumbnailEvent event) async { Future<void> _onThumbnailChange(ThumbnailEvent event) async {
final ThumbnailEvent(:tabId, :bytes) = event; final ThumbnailEvent(:tabId, :bytes) = event;
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes)); final image = await bytes.mapNotNull(
final current = state[tabId] ?? TabState.$default(tabId); (bytes) => tryDecodeImage(
bytes,
targetWidth: thumbnailDecodeWidth,
allowUpscaling: false,
),
);
state = {...state}..[tabId] = current.copyWith.thumbnail(image); if (!ref.mounted) {
return;
}
ref.read(tabThumbnailsProvider.notifier).update(tabId, image);
} }
void _onSecurityInfoStateChange(SecurityInfoEvent event) { void _onSecurityInfoStateChange(SecurityInfoEvent event) {
final SecurityInfoEvent(:tabId, :securityInfo) = event; final SecurityInfoEvent(:tabId, :securityInfo) = event;
final current = state[tabId] ?? TabState.$default(tabId); final current = state[tabId] ?? TabState.$default(tabId);
state = {...state} _put(
..[tabId] = current.copyWith.securityInfoState( tabId,
current.copyWith.securityInfoState(
SecurityState( SecurityState(
secure: securityInfo.secure, secure: securityInfo.secure,
host: securityInfo.host, host: securityInfo.host,
issuer: securityInfo.issuer, issuer: securityInfo.issuer,
), ),
); ),
);
} }
void _onHistoryStateChange(HistoryEvent event) { void _onHistoryStateChange(HistoryEvent event) {
final HistoryEvent(:tabId, :history) = event; final HistoryEvent(:tabId, :history) = event;
final current = state[tabId] ?? TabState.$default(tabId); ref
state = {...state} .read(tabHistoryStatesProvider.notifier)
..[tabId] = current.copyWith.historyState( .update(
HistoryState( tabId,
items: history.items.nonNulls HistoryState(
.map( items: history.items.nonNulls
(item) => .map(
HistoryItem(url: Uri.parse(item.url), title: item.title), (item) =>
) HistoryItem(url: Uri.parse(item.url), title: item.title),
.toList(), )
currentIndex: history.currentIndex, .toList(),
canGoBack: history.canGoBack, currentIndex: history.currentIndex,
canGoForward: history.canGoForward, canGoBack: history.canGoBack,
), canGoForward: history.canGoForward,
); ),
);
} }
void _onReaderableStateChange(ReaderableEvent event) { void _onReaderableStateChange(ReaderableEvent event) {
final ReaderableEvent(:tabId, :readerable) = event; final ReaderableEvent(:tabId, :readerable) = event;
final current = state[tabId] ?? TabState.$default(tabId); final current = state[tabId] ?? TabState.$default(tabId);
state = {...state} _put(
..[tabId] = current.copyWith.readerableState( tabId,
current.copyWith.readerableState(
ReaderableState( ReaderableState(
readerable: readerable.readerable, readerable: readerable.readerable,
active: readerable.active, active: readerable.active,
), ),
); ),
);
} }
void _onTabTranslationStateChange(TabTranslationEvent event) { void _onTabTranslationStateChange(TabTranslationEvent event) {
final TabTranslationEvent(:tabId, :state) = event; final TabTranslationEvent(:tabId, :state) = event;
final current = this.state[tabId] ?? TabState.$default(tabId); ref
this.state = {...this.state} .read(tabTranslationStatesProvider.notifier)
..[tabId] = current.copyWith.translationState( .update(tabId, TranslationState.fromData(state));
TranslationState.fromData(state),
);
} }
void _onFindResultsChange(FindResultsEvent event) { void _onFindResultsChange(FindResultsEvent event) {
final FindResultsEvent(:tabId, :results) = event; final FindResultsEvent(:tabId, :results) = event;
final current = state[tabId] ?? TabState.$default(tabId); final findResults = ref.read(tabFindResultStatesProvider.notifier);
if (results.isNotEmpty) { if (results.isNotEmpty) {
final result = results.last; final result = results.last;
state = {...state} findResults.update(
..[tabId] = current.copyWith.findResultState( tabId,
FindResultState( FindResultState(
lastSearchText: ref.read(findInPageRepositoryProvider(tabId)), lastSearchText: ref.read(findInPageRepositoryProvider(tabId)),
activeMatchOrdinal: result.activeMatchOrdinal, activeMatchOrdinal: result.activeMatchOrdinal,
numberOfMatches: result.numberOfMatches, numberOfMatches: result.numberOfMatches,
isDoneCounting: result.isDoneCounting, isDoneCounting: result.isDoneCounting,
), ),
); );
} else if (current.findResultState.hasMatches) { } else if (findResults.resultFor(tabId).hasMatches) {
state = { findResults.update(tabId, FindResultState.$default());
...state,
}..[tabId] = current.copyWith.findResultState(FindResultState.$default());
} }
} }
@@ -309,8 +355,15 @@ class TabStates extends _$TabStates {
); );
}, },
), ),
// Gecko emits match counters at ~40/s while a find is running. 25ms let
// essentially every one of those through; 100ms is still well under the
// threshold where the counter feels laggy. Debounced *per tab* so a
// background tab still counting can't starve the foreground one.
eventService.findResultsEvent eventService.findResultsEvent
.debounceTime(const Duration(milliseconds: 25)) .groupBy((event) => event.tabId)
.flatMap(
(group) => group.debounceTime(const Duration(milliseconds: 100)),
)
.listen( .listen(
(event) { (event) {
_onFindResultsChange(event); _onFindResultsChange(event);
@@ -381,6 +434,42 @@ TabState? tabState(Ref ref, String? tabId) {
return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId])); return ref.watch(tabStatesProvider.select((tabs) => tabs[tabId]));
} }
/// The only fields of [TabState] that tab filtering/sorting depends on.
class TabSortKeys with FastEquatable {
final TabMode tabMode;
final String titleOrAuthority;
final String url;
TabSortKeys({
required this.tabMode,
required this.titleOrAuthority,
required this.url,
});
@override
List<Object?> get hashParameters => [tabMode, titleOrAuthority, url];
}
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
/// but render nothing from the state itself. Watching this instead of the full
/// map keeps the (expensive) grouping/sorting passes off the path of every
/// icon, security-info and readerable event.
@Riverpod(keepAlive: true)
EquatableValue<Map<String, TabSortKeys>> tabSortKeys(Ref ref) {
return ref.watch(
tabStatesProvider.select(
(states) => EquatableValue({
for (final MapEntry(:key, :value) in states.entries)
key: TabSortKeys(
tabMode: value.tabMode,
titleOrAuthority: value.titleOrAuthority,
url: value.url.toString(),
),
}),
),
);
}
@Riverpod() @Riverpod()
Future<TabState> tabStateWithFallback(Ref ref, String tabId) async { Future<TabState> tabStateWithFallback(Ref ref, String tabId) async {
final state = ref.watch(tabStateProvider(tabId)); final state = ref.watch(tabStateProvider(tabId));
@@ -41,7 +41,7 @@ final class TabStatesProvider
} }
} }
String _$tabStatesHash() => r'04b7f1ea704575e368a0f1186fc1c58f317620b1'; String _$tabStatesHash() => r'1ae4e941890116eabf1803809014b3e575c33e73';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> { abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build(); Map<String, TabState> build();
@@ -138,6 +138,68 @@ final class TabStateFamily extends $Family
String toString() => r'tabStateProvider'; String toString() => r'tabStateProvider';
} }
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
/// but render nothing from the state itself. Watching this instead of the full
/// map keeps the (expensive) grouping/sorting passes off the path of every
/// icon, security-info and readerable event.
@ProviderFor(tabSortKeys)
final tabSortKeysProvider = TabSortKeysProvider._();
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
/// but render nothing from the state itself. Watching this instead of the full
/// map keeps the (expensive) grouping/sorting passes off the path of every
/// icon, security-info and readerable event.
final class TabSortKeysProvider
extends
$FunctionalProvider<
EquatableValue<Map<String, TabSortKeys>>,
EquatableValue<Map<String, TabSortKeys>>,
EquatableValue<Map<String, TabSortKeys>>
>
with $Provider<EquatableValue<Map<String, TabSortKeys>>> {
/// Projection of [tabStatesProvider] for consumers that order or filter tabs
/// but render nothing from the state itself. Watching this instead of the full
/// map keeps the (expensive) grouping/sorting passes off the path of every
/// icon, security-info and readerable event.
TabSortKeysProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'tabSortKeysProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$tabSortKeysHash();
@$internal
@override
$ProviderElement<EquatableValue<Map<String, TabSortKeys>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<Map<String, TabSortKeys>> create(Ref ref) {
return tabSortKeys(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<Map<String, TabSortKeys>> value) {
return $ProviderOverride(
origin: this,
providerOverride:
$SyncValueProvider<EquatableValue<Map<String, TabSortKeys>>>(value),
);
}
}
String _$tabSortKeysHash() => r'7bf660099d006b6df9846594d2a5d78a32fa3bdb';
@ProviderFor(tabStateWithFallback) @ProviderFor(tabStateWithFallback)
final tabStateWithFallbackProvider = TabStateWithFallbackFamily._(); final tabStateWithFallbackProvider = TabStateWithFallbackFamily._();
@@ -35,6 +35,7 @@ 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/pending_tab_selection.dart';
import 'package:weblibre/features/geckoview/domain/providers/restore_complete.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/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_data.dart';
@@ -957,9 +958,15 @@ class TabRepository extends _$TabRepository {
return; return;
} }
final historyIsEmpty =
ref
.read(tabHistoryStatesProvider)[currentTabState.id]
?.items
.isEmpty ??
true;
final tabIsEmpty = final tabIsEmpty =
currentTabState.url == TabState.defaultUrl && currentTabState.url == TabState.defaultUrl && historyIsEmpty;
currentTabState.historyState.items.isEmpty;
if (event.blocked || tabIsEmpty) { if (event.blocked || tabIsEmpty) {
final newTabId = await addTab( final newTabId = await addTab(
@@ -976,7 +983,7 @@ class TabRepository extends _$TabRepository {
return; return;
} }
if (currentTabState.historyState.items.isEmpty) { if (historyIsEmpty) {
await closeTab(currentTabState.id); await closeTab(currentTabState.id);
if (!ref.mounted) { if (!ref.mounted) {
return; return;
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
} }
} }
String _$tabRepositoryHash() => r'e80c972b0d1a328c170354c123d2730028a774b2'; String _$tabRepositoryHash() => r'8abe7686d937434e2970675c143f3891ec34cce2';
abstract class _$TabRepository extends $Notifier<void> { abstract class _$TabRepository extends $Notifier<void> {
void build(); void build();
@@ -881,17 +881,14 @@ EquatableValue<List<TabPreview>> filteredTabPreviews(
return EquatableValue([]); return EquatableValue([]);
} }
final sandboxCaptureMap = final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value;
ref.watch(sandboxCaptureMapProvider).value ?? const {};
return EquatableValue( return EquatableValue(
tabSearchResults.results tabSearchResults.results
.where((tab) => availableTabStates.value.containsKey(tab.id)) .where((tab) => availableTabStates.value.containsKey(tab.id))
.map((tab) { .map((tab) {
final tabState = availableTabStates.value[tab.id]!; final tabState = availableTabStates.value[tab.id]!;
final sandboxSourceUri = parseSandboxSource( final sandboxSourceUri = sandboxSourceUris[tab.id];
sandboxCaptureMap[tab.id],
);
return TabPreview( return TabPreview(
id: tab.id, id: tab.id,
@@ -933,7 +930,12 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
} }
final tabList = ref.watch(tabListProvider); final tabList = ref.watch(tabListProvider);
final tabStates = ref.watch(tabStatesProvider); // Narrow projection instead of the whole `Map<String, TabState>`: this
// provider does a graph walk plus several sorts, and it feeds the always
// visible quick tab switcher's depth map. It only reads the tab mode (for
// filtering) and the title/url (for sorting) — none of which change more
// often than once per navigation.
final tabSortKeys = ref.watch(tabSortKeysProvider).value;
final filterOptions = ref.watch(tabViewFilterControllerProvider); final filterOptions = ref.watch(tabViewFilterControllerProvider);
final pinnedTabIds = ref.watch( final pinnedTabIds = ref.watch(
watchPinnedTabIdsProvider.select( watchPinnedTabIdsProvider.select(
@@ -963,7 +965,7 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
.where((row) => tabList.value.contains(row.id)) .where((row) => tabList.value.contains(row.id))
.where( .where(
(row) => filterOptions.matchesTab( (row) => filterOptions.matchesTab(
tabStates[row.id]?.tabMode, tabSortKeys[row.id]?.tabMode,
tabTimestamps?[row.id], tabTimestamps?[row.id],
), ),
) )
@@ -1032,7 +1034,7 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
for (final entry in byRoot.entries) { for (final entry in byRoot.entries) {
final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key); final rootMember = entry.value.firstWhere((r) => r.row.id == entry.key);
final root = rootMember.row; final root = rootMember.row;
final state = tabStates[root.id]; final sortKeys = tabSortKeys[root.id];
final timestamp = tabTimestamps?[root.id]; final timestamp = tabTimestamps?[root.id];
groupRecords.add( groupRecords.add(
_TabGroupRecord( _TabGroupRecord(
@@ -1043,10 +1045,10 @@ EquatableValue<List<TabListItemEntity>> groupedTabListItems(
isPinned: pinnedTabIds.contains(root.id), isPinned: pinnedTabIds.contains(root.id),
titleKey: titleKey:
sortField == SortField.titleAsc || sortField == SortField.titleDesc sortField == SortField.titleAsc || sortField == SortField.titleDesc
? (state?.titleOrAuthority ?? '').toLowerCase() ? (sortKeys?.titleOrAuthority ?? '').toLowerCase()
: null, : null,
urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc urlKey: sortField == SortField.urlAsc || sortField == SortField.urlDesc
? (state?.url.toString() ?? '') ? (sortKeys?.url ?? '')
: null, : null,
dateKey: dateKey:
sortField == SortField.dateAsc || sortField == SortField.dateDesc sortField == SortField.dateAsc || sortField == SortField.dateDesc
@@ -1092,7 +1092,7 @@ final class FilteredTabPreviewsProvider
} }
String _$filteredTabPreviewsHash() => String _$filteredTabPreviewsHash() =>
r'074df0d2000ae325fbd1db775abe4836491b32dd'; r'e291cdb7848c9607d388f8fdfb640f25265fca03';
final class FilteredTabPreviewsFamily extends $Family final class FilteredTabPreviewsFamily extends $Family
with with
@@ -1212,7 +1212,7 @@ final class GroupedTabListItemsProvider
} }
String _$groupedTabListItemsHash() => String _$groupedTabListItemsHash() =>
r'dbb510f22c00858fcbadfb94547d46841221fca4'; r'd7158c50ca34014d63044e7fb52a7197245c59ad';
/// Grouped flat-list rendering for the list and grid views. /// Grouped flat-list rendering for the list and grid views.
/// ///
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:fast_equatable/fast_equatable.dart'; import 'package:fast_equatable/fast_equatable.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/domain/entities/toolbar_config_location.dart';
@@ -26,6 +27,12 @@ class ContextualToolbarScope with FastEquatable {
final String? selectedTabId; final String? selectedTabId;
final Sheet? displayedSheet; final Sheet? displayedSheet;
final TabState? tabState; final TabState? tabState;
/// Back/forward availability. Lives outside [TabState] (see
/// `providers/tab_detail_state.dart`) but the navigation buttons need it, so
/// the toolbar resolves it once and passes it down with the scope.
final HistoryState historyState;
final bool isPreview; final bool isPreview;
/// Which configuration set this scope renders for, so shared registry /// Which configuration set this scope renders for, so shared registry
@@ -38,14 +45,16 @@ class ContextualToolbarScope with FastEquatable {
required this.displayedSheet, required this.displayedSheet,
required this.tabState, required this.tabState,
required this.isPreview, required this.isPreview,
HistoryState? historyState,
this.location = ToolbarConfigLocation.contextual, this.location = ToolbarConfigLocation.contextual,
}); }) : historyState = historyState ?? HistoryState.$default();
@override @override
List<Object?> get hashParameters => [ List<Object?> get hashParameters => [
selectedTabId, selectedTabId,
displayedSheet, displayedSheet,
tabState, tabState,
historyState,
isPreview, isPreview,
location, location,
]; ];
@@ -26,6 +26,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
@@ -104,7 +105,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
label: 'Back', label: 'Back',
icon: Icons.arrow_back, icon: Icons.arrow_back,
isPrimaryAvailable: (scope, ref) { isPrimaryAvailable: (scope, ref) {
final canGoBack = scope.tabState?.historyState.canGoBack == true; final canGoBack = scope.historyState.canGoBack;
final isLoading = scope.tabState?.isLoading == true; final isLoading = scope.tabState?.isLoading == true;
// The back button only doubles as a stop-loading control when no // The back button only doubles as a stop-loading control when no
// dedicated reload button is present to take over that role. // dedicated reload button is present to take over that role.
@@ -132,8 +133,7 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
spec: forwardToolbarButtonSpec, spec: forwardToolbarButtonSpec,
label: 'Forward', label: 'Forward',
icon: Icons.arrow_forward, icon: Icons.arrow_forward,
isPrimaryAvailable: (scope, ref) => isPrimaryAvailable: (scope, ref) => scope.historyState.canGoForward,
scope.tabState?.historyState.canGoForward == true,
longPressActions: ['History Menu (Forward pages)'], longPressActions: ['History Menu (Forward pages)'],
builder: (scope, context, ref) { builder: (scope, context, ref) {
if (scope.isPreview) { if (scope.isPreview) {
@@ -1026,9 +1026,7 @@ class _TranslateToolbarButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isTranslated = ref.watch( final isTranslated = ref.watch(
tabStateProvider( tabTranslationStateProvider(selectedTabId).select((s) => s.isTranslated),
selectedTabId,
).select((s) => s?.translationState.isTranslated ?? false),
); );
return IconButton( return IconButton(
@@ -21,6 +21,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
@@ -48,6 +49,7 @@ class ContextualToolbar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId)); final tabState = ref.watch(tabStateProvider(selectedTabId));
final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
final configs = ref.watch( final configs = ref.watch(
effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual), effectiveToolbarButtonConfigsProvider(ToolbarConfigLocation.contextual),
); );
@@ -56,6 +58,7 @@ class ContextualToolbar extends HookConsumerWidget {
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
displayedSheet: displayedSheet, displayedSheet: displayedSheet,
tabState: tabState, tabState: tabState,
historyState: historyState,
isPreview: false, isPreview: false,
); );
@@ -21,6 +21,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart'; import 'package:weblibre/features/geckoview/features/browser/features/contextual_toolbar/data/providers/toolbar_button_configs.dart';
@@ -62,6 +63,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabState = ref.watch(tabStateProvider(selectedTabId)); final tabState = ref.watch(tabStateProvider(selectedTabId));
final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
final configs = ref.watch( final configs = ref.watch(
effectiveToolbarButtonConfigsProvider( effectiveToolbarButtonConfigsProvider(
ToolbarConfigLocation.quickSwitcher, ToolbarConfigLocation.quickSwitcher,
@@ -72,6 +74,7 @@ class QuickSwitcherButtonRow extends HookConsumerWidget {
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
displayedSheet: displayedSheet, displayedSheet: displayedSheet,
tabState: tabState, tabState: tabState,
historyState: historyState,
isPreview: false, isPreview: false,
location: ToolbarConfigLocation.quickSwitcher, location: ToolbarConfigLocation.quickSwitcher,
); );
@@ -20,6 +20,7 @@
import 'package:riverpod/riverpod.dart'; import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
@@ -45,17 +46,17 @@ class ToolbarVisibilityController extends _$ToolbarVisibilityController {
}); });
// Show toolbar on navigation (history state change) // Show toolbar on navigation (history state change)
ref.listen(tabStatesProvider.select((tabs) => tabs[tabId]?.historyState), ( ref.listen(
previous, tabHistoryStatesProvider.select((histories) => histories[tabId]),
next, (previous, next) {
) { if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) {
if (!ref.read(generalSettingsWithDefaultsProvider).autoHideTabBar) { return;
return; }
} if (next != null && previous != null && previous != next) {
if (next != null && previous != null && previous != next) { show();
show(); }
} },
}); );
// Force-show when GeckoView requests toolbar expansion // Force-show when GeckoView requests toolbar expansion
// (e.g. touch on form input) // (e.g. touch on form input)
@@ -61,7 +61,7 @@ final class ToolbarVisibilityControllerProvider
} }
String _$toolbarVisibilityControllerHash() => String _$toolbarVisibilityControllerHash() =>
r'e947508c351d4cbbe8c63289171a6d0a34f1a61a'; r'9d88ca83964d84acfb23f400d02ae277d6866b3f';
final class ToolbarVisibilityControllerFamily extends $Family final class ToolbarVisibilityControllerFamily extends $Family
with with
@@ -35,6 +35,7 @@ import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
@@ -492,9 +493,14 @@ class _BrowserContentPositioned extends ConsumerWidget {
final toolbarState = ref.watch( final toolbarState = ref.watch(
toolbarVisibilityControllerProvider(selectedTabId), toolbarVisibilityControllerProvider(selectedTabId),
); );
// Deliberately does NOT include `sheetDisplayed` (unlike the layers that
// render chrome above the page). A sheet is painted over the browser, so
// letting it force the toolbar "visible" here would move the Positioned
// that wraps the platform view — a native re-layout, and under hybrid
// composition a surface recreation, in the same frame as the sheet's
// slide-in. The geometry the page sees stays frozen while a sheet is up.
final toolbarVisible = final toolbarVisible =
sheetDisplayed || !tabInFullScreen && toolbarState == ToolbarVisibility.visible;
(!tabInFullScreen && toolbarState == ToolbarVisibility.visible);
// When auto-hide is disabled, constrain browser above toolbar // When auto-hide is disabled, constrain browser above toolbar
// (unless toolbar is manually dismissed via swipe gesture). // (unless toolbar is manually dismissed via swipe gesture).
@@ -527,11 +533,14 @@ class _BrowserContentPositioned extends ConsumerWidget {
// hidden state is still handled by GeckoView's dynamic // hidden state is still handled by GeckoView's dynamic
// toolbar/clipping logic. On the rail there is never a bottom // toolbar/clipping logic. On the rail there is never a bottom
// bar, so the browser always needs the bottom safe inset. // bar, so the browser always needs the bottom safe inset.
//
// `sheetDisplayed` is likewise excluded here: toggling the safe-area
// padding on sheet open/close would resize the platform view for content
// the sheet covers anyway.
final applyBottomSafeArea = isRail final applyBottomSafeArea = isRail
? (!tabInFullScreen && !isSmallWebActive && !sheetDisplayed) ? (!tabInFullScreen && !isSmallWebActive)
: (!tabInFullScreen && : (!tabInFullScreen &&
!isSmallWebActive && !isSmallWebActive &&
!sheetDisplayed &&
bottomOffset == 0 && bottomOffset == 0 &&
toolbarState == ToolbarVisibility.dismissed); toolbarState == ToolbarVisibility.dismissed);
@@ -812,17 +821,14 @@ class _ProgressIndicatorBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch( final tabId = ref.watch(selectedTabProvider);
selectedTabStateProvider.select((state) { final isLoading = ref.watch(
if (state?.isLoading == true) { tabStateProvider(tabId).select((state) => state?.isLoading == true),
return state?.progress ?? 100;
}
//When not loading we assumed finished
return 100;
}),
); );
//When not loading we assumed finished
final value = isLoading ? ref.watch(tabProgressProvider(tabId)) : 100;
return Visibility( return Visibility(
visible: value < 100, visible: value < 100,
child: LinearProgressIndicator(value: value / 100), child: LinearProgressIndicator(value: value / 100),
@@ -1182,13 +1188,26 @@ class BrowserScreen extends HookConsumerWidget {
// Calculate bottom toolbar size for FAB and sheet positioning // Calculate bottom toolbar size for FAB and sheet positioning
final Size bottomAppBarContentSize; final Size bottomAppBarContentSize;
// The same size computed as if no sheet were displayed. `ViewTabsSheet`
// hides the main toolbar and the quick tab switcher, which shrinks the bar
// by 48-152px — and that value feeds the browser's viewport geometry
// (dynamic toolbar inset, vertical clipping and the Positioned that wraps
// the platform view). Resizing the Gecko viewport when a sheet opens costs
// a page reflow plus, under hybrid composition, a native surface
// recreation — all in the same frame as the sheet's slide-in animation.
// The sheet is drawn *on top* of the page, so the page's viewport has no
// reason to change: the inset math below uses this frozen value while the
// widgets that actually render the bar keep the real one.
final Size viewportBottomAppBarContentSize;
if (isSmallWebActive) { if (isSmallWebActive) {
bottomAppBarContentSize = const Size.fromHeight( bottomAppBarContentSize = const Size.fromHeight(
SmallWebBrowserOverlay.barHeight, SmallWebBrowserOverlay.barHeight,
); );
viewportBottomAppBarContentSize = bottomAppBarContentSize;
} else if (isRail) { } else if (isRail) {
// The rail occupies a side, not the bottom; no bottom bar is rendered. // The rail occupies a side, not the bottom; no bottom bar is rendered.
bottomAppBarContentSize = Size.zero; bottomAppBarContentSize = Size.zero;
viewportBottomAppBarContentSize = bottomAppBarContentSize;
} else { } else {
// Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar // Pass actual displayedSheet to get correct height when ViewTabsSheet hides main toolbar
bottomAppBarContentSize = BrowserBottomAppBar( bottomAppBarContentSize = BrowserBottomAppBar(
@@ -1198,10 +1217,21 @@ class BrowserScreen extends HookConsumerWidget {
isSmallWebMode: false, isSmallWebMode: false,
displayedSheet: displayedSheet, displayedSheet: displayedSheet,
).preferredSize; ).preferredSize;
viewportBottomAppBarContentSize = displayedSheet == null
? bottomAppBarContentSize
: BrowserBottomAppBar(
showMainToolbar: tabBarPosition == TabBarPosition.bottom,
showContextualToolbar: showContextualToolbar,
quickTabSwitcherRowCount: quickTabSwitcherRowCount,
isSmallWebMode: false,
displayedSheet: null,
).preferredSize;
} }
// Total height includes safe area padding // Total height includes safe area padding
final bottomAppBarTotalHeight = final bottomAppBarTotalHeight =
bottomAppBarContentSize.height + bottomSafeArea; bottomAppBarContentSize.height + bottomSafeArea;
final viewportBottomAppBarTotalHeight =
viewportBottomAppBarContentSize.height + bottomSafeArea;
// Side rail width reservation (vertical positions only): the fixed content // Side rail width reservation (vertical positions only): the fixed content
// width plus the system safe-area inset on the rail's outer edge. // width plus the system safe-area inset on the rail's outer edge.
@@ -1262,7 +1292,8 @@ class BrowserScreen extends HookConsumerWidget {
final autoHideToolbarHeight = switch (tabBarPosition) { final autoHideToolbarHeight = switch (tabBarPosition) {
// Gecko's dynamic toolbar value is consumed as a bottom inset by Dart and // Gecko's dynamic toolbar value is consumed as a bottom inset by Dart and
// native UI. The top toolbar itself is positioned with Flutter's topOffset. // native UI. The top toolbar itself is positioned with Flutter's topOffset.
TabBarPosition.top || TabBarPosition.bottom => bottomAppBarTotalHeight, TabBarPosition.top ||
TabBarPosition.bottom => viewportBottomAppBarTotalHeight,
TabBarPosition.left || TabBarPosition.right => 0.0, TabBarPosition.left || TabBarPosition.right => 0.0,
}; };
@@ -1348,7 +1379,7 @@ class BrowserScreen extends HookConsumerWidget {
// When visible/dismissed or overridden by keyboard/loading: no clipping. // When visible/dismissed or overridden by keyboard/loading: no clipping.
final hiddenToolbarClippingPx = switch (tabBarPosition) { final hiddenToolbarClippingPx = switch (tabBarPosition) {
TabBarPosition.top || TabBarPosition.bottom => TabBarPosition.top || TabBarPosition.bottom =>
-(bottomAppBarTotalHeight * pixelRatio).round(), -(viewportBottomAppBarTotalHeight * pixelRatio).round(),
TabBarPosition.left || TabBarPosition.right => 0, TabBarPosition.left || TabBarPosition.right => 0,
}; };
final targetClippingPx = final targetClippingPx =
@@ -1402,7 +1433,7 @@ class BrowserScreen extends HookConsumerWidget {
keyboardVisible, keyboardVisible,
tabIsLoading, tabIsLoading,
tabInFullScreen, tabInFullScreen,
bottomAppBarTotalHeight, viewportBottomAppBarTotalHeight,
pixelRatio, pixelRatio,
selectedTabId, selectedTabId,
tabBarPosition, tabBarPosition,
@@ -1601,6 +1632,12 @@ class _SheetContainer extends HookConsumerWidget {
required this.bottomAppBarHeight, required this.bottomAppBarHeight,
}); });
/// How far the scrim is allowed to extend *under* the sheet. The sheets round
/// their top corners (radius 28), and the page shows through those cutouts —
/// so the scrim has to reach a little past the sheet's top edge or the
/// corners would reveal unscrimmed content.
static const _sheetCornerOverlap = 32.0;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -1608,7 +1645,29 @@ class _SheetContainer extends HookConsumerWidget {
Theme.of(context).bottomSheetTheme.modalBarrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor ??
colorScheme.scrim.withValues(alpha: 0.5); colorScheme.scrim.withValues(alpha: 0.5);
bool dismissOnThreshold(DraggableScrollableNotification notification) { final (sheet, initialExtent) = switch (displayedSheet) {
ViewTabsSheet() => (
_ViewTabsSheet(maxChildSize: relativeSafeArea),
_ViewTabsSheet.initialHeight,
),
final SiteSettingsSheet parameter => (
_SiteSettingsSheet(
initialTabState: parameter.tabState,
maxChildSize: relativeSafeArea,
bottomAppBarHeight: bottomAppBarHeight,
),
_SiteSettingsSheet.initialHeight,
),
};
// Drives the scrim's height. Seeded with the sheet's initial extent so the
// very first frame is already clipped correctly, then tracked from the
// sheet's own drag notifications.
final sheetExtent = useValueNotifier(initialExtent);
bool onSheetNotification(DraggableScrollableNotification notification) {
sheetExtent.value = notification.extent;
if (notification.extent <= 0.1) { if (notification.extent <= 0.1) {
logger.i('Dismissing sheet, reached min extend'); logger.i('Dismissing sheet, reached min extend');
ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
@@ -1617,36 +1676,61 @@ class _SheetContainer extends HookConsumerWidget {
return false; return false;
} }
return GestureDetector( return LayoutBuilder(
onTap: () { builder: (context, constraints) {
// Dismiss sheet when tapping outside final available = constraints.maxHeight;
ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
return Stack(
children: [
// Scrim. Hit-testing stays full-area (opaque) so tapping anywhere
// the sheet doesn't occupy still dismisses — including beside a
// width-constrained sheet — but the *paint* is clipped to the band
// above the sheet. Under hybrid composition every pixel painted
// over the platform view goes through an overlay surface, and once
// the tab sheet settles near full extent almost all of a
// full-screen scrim is hidden behind it anyway.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
},
child: ValueListenableBuilder<double>(
valueListenable: sheetExtent,
builder: (context, extent, _) {
final scrimHeight =
(available * (1.0 - extent) + _sheetCornerOverlap)
.clamp(0.0, available);
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: scrimHeight,
width: double.infinity,
child: ColoredBox(color: modalBarrierColor),
),
);
},
),
),
),
// The sheet itself sits above the scrim, so taps on it hit its own
// widgets and taps above it fall through to the scrim's detector.
Positioned.fill(
child: Align(
alignment: Alignment.bottomCenter,
child: NotificationListener<DraggableScrollableNotification>(
onNotification: onSheetNotification,
child: sheet,
),
),
),
],
);
}, },
child: ColoredBox(
color: modalBarrierColor,
child: GestureDetector(
onTap: () {}, // Prevent tap from propagating to parent
child: Align(
alignment: Alignment.bottomCenter,
child: switch (displayedSheet) {
ViewTabsSheet() =>
NotificationListener<DraggableScrollableNotification>(
onNotification: dismissOnThreshold,
child: _ViewTabsSheet(maxChildSize: relativeSafeArea),
),
final SiteSettingsSheet parameter =>
NotificationListener<DraggableScrollableNotification>(
onNotification: dismissOnThreshold,
child: _SiteSettingsSheet(
initialTabState: parameter.tabState,
maxChildSize: relativeSafeArea,
bottomAppBarHeight: bottomAppBarHeight,
),
),
},
),
),
),
); );
} }
} }
@@ -1810,7 +1894,9 @@ class _Browser extends HookConsumerWidget {
.toggleReaderView(false); .toggleReaderView(false);
return true; return true;
} else if (tabState?.historyState.canGoBack == true) { } else if (ref
.read(tabHistoryStateProvider(tabState?.id))
.canGoBack) {
lastBackButtonPress.value = null; lastBackButtonPress.value = null;
final controller = ref.read(selectedTabSessionProvider); final controller = ref.read(selectedTabSessionProvider);
@@ -2036,6 +2122,10 @@ class _SiteSettingsSheet extends HookConsumerWidget {
class _ViewTabsSheet extends HookConsumerWidget { class _ViewTabsSheet extends HookConsumerWidget {
final double maxChildSize; final double maxChildSize;
/// Matches [DraggableScrollableSheet]'s default, made explicit so the
/// scrim in [_SheetContainer] can seed its clip from it.
static const initialHeight = 0.5;
const _ViewTabsSheet({this.maxChildSize = 1.0}); const _ViewTabsSheet({this.maxChildSize = 1.0});
@override @override
@@ -2061,6 +2151,7 @@ class _ViewTabsSheet extends HookConsumerWidget {
key: ValueKey(tabsReorderable), key: ValueKey(tabsReorderable),
controller: draggableScrollableController, controller: draggableScrollableController,
expand: false, expand: false,
initialChildSize: initialHeight,
minChildSize: 0.1, minChildSize: 0.1,
maxChildSize: maxChildSize, maxChildSize: maxChildSize,
builder: (context, scrollController) { builder: (context, scrollController) {
@@ -20,7 +20,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:ui' as ui;
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart'; import 'package:fast_equatable/fast_equatable.dart';
@@ -46,6 +45,7 @@ import 'package:weblibre/features/geckoview/domain/entities/tab_container_select
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
@@ -70,6 +70,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_relation_visibility.dart';
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/features/gestures/data/models/gesture_settings.dart'; import 'package:weblibre/features/gestures/data/models/gesture_settings.dart';
import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart'; import 'package:weblibre/features/gestures/domain/repositories/gesture_settings.dart';
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart'; import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
@@ -248,9 +249,7 @@ class _NavigationRow extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch( final history = ref.watch(tabHistoryStateProvider(selectedTabId));
tabStateProvider(selectedTabId).select((value) => value?.historyState),
);
final host = ref.watch( final host = ref.watch(
tabStateProvider(selectedTabId).select((value) => value?.url.host), tabStateProvider(selectedTabId).select((value) => value?.url.host),
); );
@@ -280,7 +279,7 @@ class _NavigationRow extends HookConsumerWidget {
child: _buildNavIcon( child: _buildNavIcon(
icon: isLoading ? Icons.close : Icons.arrow_back, icon: isLoading ? Icons.close : Icons.arrow_back,
label: isLoading ? 'Stop' : 'Back', label: isLoading ? 'Stop' : 'Back',
disabled: !isLoading && history?.canGoBack != true, disabled: !isLoading && !history.canGoBack,
onTap: () async { onTap: () async {
final controller = ref.read( final controller = ref.read(
tabSessionProvider(tabId: selectedTabId).notifier, tabSessionProvider(tabId: selectedTabId).notifier,
@@ -296,7 +295,7 @@ class _NavigationRow extends HookConsumerWidget {
} }
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
onLongPress: isLoading || history?.canGoBack != true onLongPress: isLoading || !history.canGoBack
? null ? null
: () { : () {
if (backMenuController.isOpen) { if (backMenuController.isOpen) {
@@ -314,14 +313,14 @@ class _NavigationRow extends HookConsumerWidget {
child: _buildNavIcon( child: _buildNavIcon(
icon: Icons.arrow_forward, icon: Icons.arrow_forward,
label: 'Forward', label: 'Forward',
disabled: history?.canGoForward != true, disabled: !history.canGoForward,
onTap: () async { onTap: () async {
await ref await ref
.read(tabSessionProvider(tabId: selectedTabId).notifier) .read(tabSessionProvider(tabId: selectedTabId).notifier)
.goForward(); .goForward();
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
onLongPress: history?.canGoForward != true onLongPress: !history.canGoForward
? null ? null
: () { : () {
if (forwardMenuController.isOpen) { if (forwardMenuController.isOpen) {
@@ -899,7 +898,7 @@ class _TranslatePageTile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final engineState = ref.watch(translationEngineStateProvider); final engineState = ref.watch(translationEngineStateProvider);
final translationState = ref.watch( final translationState = ref.watch(
tabStateProvider(selectedTabId).select((s) => s?.translationState), tabTranslationStateProvider(selectedTabId),
); );
final readerActive = ref.watch( final readerActive = ref.watch(
tabStateProvider( tabStateProvider(
@@ -912,7 +911,7 @@ class _TranslatePageTile extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isTranslated = translationState?.isTranslated ?? false; final isTranslated = translationState.isTranslated;
return Column( return Column(
children: [ children: [
@@ -1470,29 +1469,15 @@ class _ShareExpansion extends HookConsumerWidget {
final ts = ref.read(tabStateProvider(selectedTabId))!; final ts = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { final png = await encodeScreenshotAsPng(screenshot);
try {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) { if (png != null) {
final file = XFile.fromData( final file = XFile.fromData(png, mimeType: 'image/png');
png.buffer.asUint8List(),
mimeType: 'image/png',
);
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams( ShareParams(files: [file], subject: ts.titleOrAuthority),
files: [file], );
subject: ts.titleOrAuthority, }
),
);
}
} finally {
result.dispose();
}
});
} }
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
@@ -1771,24 +1756,16 @@ class _ExportExpansion extends ConsumerWidget {
final ts = ref.read(tabStateProvider(selectedTabId))!; final ts = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { final png = await encodeScreenshotAsPng(screenshot);
try {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) { if (png != null) {
await FilePicker.saveFile( await FilePicker.saveFile(
fileName: '${ts.titleOrAuthority}.png', fileName: '${ts.titleOrAuthority}.png',
type: FileType.custom, type: FileType.custom,
allowedExtensions: ['png'], allowedExtensions: ['png'],
bytes: png.buffer.asUint8List(), bytes: png,
); );
} }
} finally {
result.dispose();
}
});
} }
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
@@ -815,8 +815,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
) )
.value .value
: null; : null;
final sandboxCaptureMap = final sandboxSourceUris = ref.watch(sandboxSourceUrisProvider).value;
ref.watch(sandboxCaptureMapProvider).value ?? const {};
// Reorder is only meaningful when the bar renders the user's actual tab // Reorder is only meaningful when the bar renders the user's actual tab
// order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency, // order (containerTabs). Other modes (lastUsedTabs / MRU) sort by recency,
// so dragging would just snap back on the next tab switch. // so dragging would just snap back on the next tab switch.
@@ -870,9 +869,7 @@ class QuickTabSwitcher extends HookConsumerWidget {
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
pinnedTabIds: pinnedTabIds, pinnedTabIds: pinnedTabIds,
tabDepthById: tabDepthById, tabDepthById: tabDepthById,
sandboxSourceUri: parseSandboxSource( sandboxSourceUri: sandboxSourceUris[state.$1.id],
sandboxCaptureMap[state.$1.id],
),
isPlaceholder: isPlaceholder:
!restoreComplete && !nativeTabIds.contains(state.$1.id), !restoreComplete && !nativeTabIds.contains(state.$1.id),
), ),
@@ -34,6 +34,7 @@ import 'package:weblibre/features/app_links/domain/services/app_link_policy_repl
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart'; import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart'; import 'package:weblibre/features/bangs/domain/services/search_history_cleanup.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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.dart';
import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart'; import 'package:weblibre/features/geckoview/domain/providers/browser_extension.dart';
@@ -122,6 +123,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
return; return;
} }
// In-tree sheets (tab tray, site settings, …) are not routes — they are
// Stack layers driven by [bottomSheetControllerProvider] — so the route
// check above does not catch them. They occlude the browser just the same,
// and the resulting thumbnail event would rebuild the tray that is on
// screen, so skip the capture while one is displayed.
if (ref.read(bottomSheetControllerProvider) != null) {
return;
}
await ref await ref
.read(selectedTabSessionProvider) .read(selectedTabSessionProvider)
.requestScreenshot(requireImageResult: false) .requestScreenshot(requireImageResult: false)
@@ -21,6 +21,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/history.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/history.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/history_menu_item.dart';
@@ -42,9 +43,7 @@ class HistoryMenu extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final historyState = ref.watch( final historyState = ref.watch(tabHistoryStateProvider(selectedTabId));
tabStateProvider(selectedTabId).select((state) => state?.historyState),
);
final menuItems = useMemoized(() => _buildMenuItems(historyState), [ final menuItems = useMemoized(() => _buildMenuItems(historyState), [
historyState, historyState,
@@ -58,8 +57,8 @@ class HistoryMenu extends HookConsumerWidget {
); );
} }
List<Widget> _buildMenuItems(HistoryState? historyState) { List<Widget> _buildMenuItems(HistoryState historyState) {
if (historyState == null || historyState.items.isEmpty) { if (historyState.items.isEmpty) {
return [ return [
MenuItemButton( MenuItemButton(
child: Text( child: Text(
@@ -17,7 +17,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -35,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/dialog
import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart'; import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
@@ -235,29 +235,15 @@ class ShareScreenshotMenuItemButton extends HookConsumerWidget {
final tabState = ref.read(tabStateProvider(selectedTabId))!; final tabState = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { final png = await encodeScreenshotAsPng(screenshot);
try {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) { if (png != null) {
final file = XFile.fromData( final file = XFile.fromData(png, mimeType: 'image/png');
png.buffer.asUint8List(),
mimeType: 'image/png',
);
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams( ShareParams(files: [file], subject: tabState.titleOrAuthority),
files: [file], );
subject: tabState.titleOrAuthority, }
),
);
}
} finally {
result.dispose();
}
});
} }
if (context.mounted) { if (context.mounted) {
@@ -290,24 +276,16 @@ class ExportScreenshotMenuItemButton extends HookConsumerWidget {
final tabState = ref.read(tabStateProvider(selectedTabId))!; final tabState = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { final png = await encodeScreenshotAsPng(screenshot);
try {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) { if (png != null) {
await FilePicker.saveFile( await FilePicker.saveFile(
fileName: '${tabState.titleOrAuthority}.png', fileName: '${tabState.titleOrAuthority}.png',
type: FileType.custom, type: FileType.custom,
allowedExtensions: ['png'], allowedExtensions: ['png'],
bytes: png.buffer.asUint8List(), bytes: png,
); );
} }
} finally {
result.dispose();
}
});
} }
if (context.mounted) { if (context.mounted) {
@@ -19,7 +19,6 @@
*/ */
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -37,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart';
import 'package:weblibre/features/user/domain/repositories/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/features/web_search/domain/controllers/sandbox_capture_controller.dart';
@@ -164,29 +164,15 @@ class ShareBottomSheet extends HookConsumerWidget {
final ts = ref.read(tabStateProvider(selectedTabId))!; final ts = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { final png = await encodeScreenshotAsPng(screenshot);
try {
final png = await result.toByteData(
format: ui.ImageByteFormat.png,
);
if (png != null) { if (png != null) {
final file = XFile.fromData( final file = XFile.fromData(png, mimeType: 'image/png');
png.buffer.asUint8List(),
mimeType: 'image/png',
);
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams( ShareParams(files: [file], subject: ts.titleOrAuthority),
files: [file], );
subject: ts.titleOrAuthority, }
),
);
}
} finally {
result.dispose();
}
});
} }
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
@@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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.dart';
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -757,7 +758,7 @@ class _TranslatePageMenuItem extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final engineState = ref.watch(translationEngineStateProvider); final engineState = ref.watch(translationEngineStateProvider);
final translationState = ref.watch( final translationState = ref.watch(
tabStateProvider(selectedTabId).select((s) => s?.translationState), tabTranslationStateProvider(selectedTabId),
); );
final readerActive = ref.watch( final readerActive = ref.watch(
tabStateProvider( tabStateProvider(
@@ -770,7 +771,7 @@ class _TranslatePageMenuItem extends ConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isTranslated = translationState?.isTranslated ?? false; final isTranslated = translationState.isTranslated;
return MenuItemButton( return MenuItemButton(
closeOnActivate: false, closeOnActivate: false,
@@ -833,9 +834,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch( final history = ref.watch(tabHistoryStateProvider(selectedTabId));
tabStateProvider(selectedTabId).select((value) => value?.historyState),
);
final isLoading = ref.watch( final isLoading = ref.watch(
selectedTabStateProvider.select((state) => state?.isLoading ?? false), selectedTabStateProvider.select((state) => state?.isLoading ?? false),
@@ -848,7 +847,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
isLoading: isLoading, isLoading: isLoading,
menuControllerToClose: controller, menuControllerToClose: controller,
canGoBack: history?.canGoBack == true, canGoBack: history.canGoBack,
), ),
), ),
const SizedBox(height: 48, child: VerticalDivider()), const SizedBox(height: 48, child: VerticalDivider()),
@@ -856,7 +855,7 @@ class _NavigationButtonsRow extends ConsumerWidget {
child: NavigateForwardButton( child: NavigateForwardButton(
selectedTabId: selectedTabId, selectedTabId: selectedTabId,
menuControllerToClose: controller, menuControllerToClose: controller,
canGoForward: history?.canGoForward == true, canGoForward: history.canGoForward,
), ),
), ),
], ],
@@ -25,6 +25,7 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/utils/tab_close_confirmation.dart';
@@ -174,6 +175,8 @@ class GridTabPreview extends HookConsumerWidget {
) ?? ) ??
TabState.$default(tabId); TabState.$default(tabId);
final thumbnail = ref.watch(tabThumbnailProvider(tabId));
final sandboxSourceUri = ref.watch( final sandboxSourceUri = ref.watch(
sandboxSourceUriForTabProvider(tabId: tabId), sandboxSourceUriForTabProvider(tabId: tabId),
); );
@@ -233,13 +236,9 @@ class GridTabPreview extends HookConsumerWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (tabState.thumbnail != null && if (thumbnail != null && !thumbnail.isDisposed)
!tabState.thumbnail!.isDisposed)
RepaintBoundary( RepaintBoundary(
child: SafeRawImage( child: SafeRawImage(image: thumbnail, fit: BoxFit.cover),
image: tabState.thumbnail,
fit: BoxFit.cover,
),
) )
else else
Center(child: TabIcon(tabState: tabState, iconSize: 48)), Center(child: TabIcon(tabState: tabState, iconSize: 48)),
@@ -498,6 +497,12 @@ class ListTabPreview extends HookConsumerWidget {
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons), generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
); );
// Only the leading tile renders the thumbnail, and only when favicons are
// off — don't subscribe to screenshot churn otherwise.
final thumbnail = tabListShowFavicons
? null
: ref.watch(tabThumbnailProvider(tabId));
final extendedDeleteMenuController = useMenuController(); final extendedDeleteMenuController = useMenuController();
// ignore: avoid_bool_literals_in_conditional_expressions // ignore: avoid_bool_literals_in_conditional_expressions
@@ -509,7 +514,7 @@ class ListTabPreview extends HookConsumerWidget {
) )
: false; : false;
final leadingWidget = switch ((tabListShowFavicons, tabState.thumbnail)) { final leadingWidget = switch ((tabListShowFavicons, thumbnail)) {
(false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect( (false, final thumbnail?) when !thumbnail.isDisposed => ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8.0)), borderRadius: const BorderRadius.all(Radius.circular(8.0)),
child: RepaintBoundary( child: RepaintBoundary(
@@ -799,7 +804,104 @@ class SingleGridTabPreview extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final dragStartPosition = useRef(Offset.zero); final dragStartPosition = useRef(Offset.zero);
final draggedDistance = useState(0.0); // A ValueNotifier rather than useState: the drag fires per pointer event,
// and useState would rebuild this whole subtree — thumbnail, favicon,
// title, menus, badges — inside a scrolling list on every one. Only the
// ValueListenableBuilder below reruns now.
final draggedDistance = useValueNotifier(0.0);
// Built once per rebuild of *this* widget and handed to the builder as its
// `child`, so the drag never reconstructs it.
final preview = RepaintBoundary(
child: GridTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
showPinBadge: true,
groupToggle: groupToggle,
depth: depth,
onTap: () async {
if (tabId != activeTabId) {
// Offer to locate the match within the page instead of opening
// Find in Page unprompted (see #421). Shown before onClose so it
// surfaces on the root messenger and survives the tray closing.
final query = sourceSearchQuery;
if (query != null &&
query.isNotEmpty &&
ref.read(findInPageControllerProvider(tabId)) ==
FindInPageState.hidden()) {
final findController = ref.read(
findInPageControllerProvider(tabId).notifier,
);
ui_helper.showFindInPageSuggestion(
context,
query: query,
onFind: () => findController.findAll(text: query),
);
}
//Close first to avoid rebuilds
onClose();
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
} else {
onClose();
}
},
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByHost(containerId, host);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: count,
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
return;
}
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
),
);
return GestureDetector( return GestureDetector(
onHorizontalDragStart: (details) { onHorizontalDragStart: (details) {
@@ -807,12 +909,10 @@ class SingleGridTabPreview extends HookConsumerWidget {
draggedDistance.value = 0.0; draggedDistance.value = 0.0;
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
final cappedDistance = math.min( draggedDistance.value = math.min(
(dragStartPosition.value - details.globalPosition).dx.abs(), (dragStartPosition.value - details.globalPosition).dx.abs(),
deleteThreshold, deleteThreshold,
); );
draggedDistance.value = cappedDistance;
}, },
onHorizontalDragEnd: (details) async { onHorizontalDragEnd: (details) async {
if (draggedDistance.value >= deleteThreshold) { if (draggedDistance.value >= deleteThreshold) {
@@ -833,96 +933,14 @@ class SingleGridTabPreview extends HookConsumerWidget {
draggedDistance.value = 0.0; draggedDistance.value = 0.0;
}, },
child: Opacity( child: ValueListenableBuilder<double>(
opacity: 1.0 - draggedDistance.value / deleteThreshold, valueListenable: draggedDistance,
child: GridTabPreview( // Kept unconditionally (rather than skipping it at rest) so the
tabId: tabId, // element tree doesn't reshape on drag start/end. Opacity at 1.0
isActive: tabId == activeTabId, // pushes no layer, so this costs nothing when not dragging.
showPinBadge: true, builder: (context, distance, child) =>
groupToggle: groupToggle, Opacity(opacity: 1.0 - distance / deleteThreshold, child: child),
depth: depth, child: preview,
onTap: () async {
if (tabId != activeTabId) {
// Offer to locate the match within the page instead of opening
// Find in Page unprompted (see #421). Shown before onClose so it
// surfaces on the root messenger and survives the tray closing.
final query = sourceSearchQuery;
if (query != null &&
query.isNotEmpty &&
ref.read(findInPageControllerProvider(tabId)) ==
FindInPageState.hidden()) {
final findController = ref.read(
findInPageControllerProvider(tabId).notifier,
);
ui_helper.showFindInPageSuggestion(
context,
query: query,
onFind: () => findController.findAll(text: query),
);
}
//Close first to avoid rebuilds
onClose();
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
} else {
onClose();
}
},
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByHost(containerId, host);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: count,
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
return;
}
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
),
), ),
); );
} }
@@ -955,7 +973,100 @@ class SingleListTabPreview extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final dragStartPosition = useRef(Offset.zero); final dragStartPosition = useRef(Offset.zero);
final draggedDistance = useState(0.0); // See [SingleGridTabPreview]: per-pointer-event drag updates must not
// rebuild the row's subtree inside a scrolling list.
final draggedDistance = useValueNotifier(0.0);
final preview = RepaintBoundary(
child: ListTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
showPinBadge: true,
groupToggle: groupToggle,
depth: depth,
onTap: () async {
if (tabId != activeTabId) {
// Offer to locate the match within the page instead of opening
// Find in Page unprompted (see #421). Shown before onClose so it
// surfaces on the root messenger and survives the tray closing.
final query = sourceSearchQuery;
if (query != null &&
query.isNotEmpty &&
ref.read(findInPageControllerProvider(tabId)) ==
FindInPageState.hidden()) {
final findController = ref.read(
findInPageControllerProvider(tabId).notifier,
);
ui_helper.showFindInPageSuggestion(
context,
query: query,
onFind: () => findController.findAll(text: query),
);
}
//Close first to avoid rebuilds
onClose();
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
} else {
onClose();
}
},
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByHost(containerId, host);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: count,
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
return;
}
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
),
);
return GestureDetector( return GestureDetector(
onHorizontalDragStart: (details) { onHorizontalDragStart: (details) {
@@ -963,12 +1074,10 @@ class SingleListTabPreview extends HookConsumerWidget {
draggedDistance.value = 0.0; draggedDistance.value = 0.0;
}, },
onHorizontalDragUpdate: (details) { onHorizontalDragUpdate: (details) {
final cappedDistance = math.min( draggedDistance.value = math.min(
(dragStartPosition.value - details.globalPosition).dx.abs(), (dragStartPosition.value - details.globalPosition).dx.abs(),
deleteThreshold, deleteThreshold,
); );
draggedDistance.value = cappedDistance;
}, },
onHorizontalDragEnd: (details) async { onHorizontalDragEnd: (details) async {
if (draggedDistance.value >= deleteThreshold) { if (draggedDistance.value >= deleteThreshold) {
@@ -989,96 +1098,11 @@ class SingleListTabPreview extends HookConsumerWidget {
draggedDistance.value = 0.0; draggedDistance.value = 0.0;
}, },
child: Opacity( child: ValueListenableBuilder<double>(
opacity: 1.0 - draggedDistance.value / deleteThreshold, valueListenable: draggedDistance,
child: ListTabPreview( builder: (context, distance, child) =>
tabId: tabId, Opacity(opacity: 1.0 - distance / deleteThreshold, child: child),
isActive: tabId == activeTabId, child: preview,
showPinBadge: true,
groupToggle: groupToggle,
depth: depth,
onTap: () async {
if (tabId != activeTabId) {
// Offer to locate the match within the page instead of opening
// Find in Page unprompted (see #421). Shown before onClose so it
// surfaces on the root messenger and survives the tray closing.
final query = sourceSearchQuery;
if (query != null &&
query.isNotEmpty &&
ref.read(findInPageControllerProvider(tabId)) ==
FindInPageState.hidden()) {
final findController = ref.read(
findInPageControllerProvider(tabId).notifier,
);
ui_helper.showFindInPageSuggestion(
context,
query: query,
onFind: () => findController.findAll(text: query),
);
}
//Close first to avoid rebuilds
onClose();
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
} else {
onClose();
}
},
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByHost(containerId, host);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: count,
);
}
},
onCloseSubtree: () async {
final subtreeIds = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(tabId)
.then((descendants) => descendants.keys.toList());
if (!context.mounted) return;
final didClose = await closeTabsWithConfirmation(
context,
ref,
subtreeIds,
);
if (context.mounted && didClose) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: subtreeIds.length,
);
}
},
onDelete: () async {
onBeforeDelete?.call();
if (!await _confirmIsolatedTabCloseIfNeeded(context, ref, tabId)) {
return;
}
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
),
), ),
); );
} }
@@ -26,6 +26,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper; import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
@@ -67,18 +68,19 @@ class TranslationBottomSheet extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final tabState = ref.watch(tabStateProvider(selectedTabId)); final translationState = ref.watch(
final translationState = tabState?.translationState; tabTranslationStateProvider(selectedTabId),
);
final engineState = ref.watch(translationEngineStateProvider); final engineState = ref.watch(translationEngineStateProvider);
final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? []; final fromLanguages = engineState?.fromLanguages?.nonNulls.toList() ?? [];
final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? []; final toLanguages = engineState?.toLanguages?.nonNulls.toList() ?? [];
final suggestedFrom = final suggestedFrom =
translationState?.detectedLanguageCode ?? translationState.detectedLanguageCode ??
translationState?.requestedFromLanguage; translationState.requestedFromLanguage;
final suggestedTo = final suggestedTo =
translationState?.userPreferredLanguageCode ?? translationState.userPreferredLanguageCode ??
translationState?.requestedToLanguage; translationState.requestedToLanguage;
final selectedFrom = useState<String?>(suggestedFrom); final selectedFrom = useState<String?>(suggestedFrom);
final selectedTo = useState<String?>(suggestedTo); final selectedTo = useState<String?>(suggestedTo);
@@ -93,10 +95,10 @@ class TranslationBottomSheet extends HookConsumerWidget {
return null; return null;
}, [suggestedFrom, suggestedTo]); }, [suggestedFrom, suggestedTo]);
final isTranslated = translationState?.isTranslated ?? false; final isTranslated = translationState.isTranslated;
final isProcessing = translationState?.isTranslateProcessing ?? false; final isProcessing = translationState.isTranslateProcessing;
final hasError = translationState?.hasError ?? false; final hasError = translationState.hasError;
final errorName = translationState?.translationErrorName; final errorName = translationState.translationErrorName;
final translatePhase = useState(_TranslatePhase.idle); final translatePhase = useState(_TranslatePhase.idle);
final effectiveProcessing = final effectiveProcessing =
@@ -21,6 +21,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
@@ -49,8 +50,10 @@ class FindInPageController extends _$FindInPageController {
Future<void> findNext({required String fallbackText, bool forward = true}) { Future<void> findNext({required String fallbackText, bool forward = true}) {
final service = ref.read(findInPageRepositoryProvider(tabId).notifier); final service = ref.read(findInPageRepositoryProvider(tabId).notifier);
final hasMatches = final hasMatches = ref
ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true; .read(tabFindResultStatesProvider.notifier)
.resultFor(tabId)
.hasMatches;
state = state.copyWith.visible(true); state = state.copyWith.visible(true);
@@ -59,7 +59,7 @@ final class FindInPageControllerProvider
} }
String _$findInPageControllerHash() => String _$findInPageControllerHash() =>
r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e'; r'd2b90ef0c6096e04ad4067f48fb5e5170268446b';
final class FindInPageControllerFamily extends $Family final class FindInPageControllerFamily extends $Family
with with
@@ -20,6 +20,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_detail_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
@@ -41,15 +42,13 @@ class FindInPageWidget extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final findInPageState = ref.watch(findInPageControllerProvider(tabId)); final findInPageState = ref.watch(findInPageControllerProvider(tabId));
final searchResult = ref.watch( final searchResult = ref.watch(tabFindResultStateProvider(tabId));
selectedTabStateProvider.select((state) => state?.findResultState),
);
final privateTabMode = final privateTabMode =
ref.watch(tabStateProvider(tabId))?.tabMode == TabMode.private; ref.watch(tabStateProvider(tabId))?.tabMode == TabMode.private;
final focusNode = useFocusNode(); final focusNode = useFocusNode();
final textController = useTextEditingController( final textController = useTextEditingController(
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, text: searchResult.lastSearchText ?? findInPageState.lastSearchText,
); );
// Sync text field when the find-in-page query is set externally (e.g., // Sync text field when the find-in-page query is set externally (e.g.,
@@ -88,7 +87,7 @@ class FindInPageWidget extends HookConsumerWidget {
} }
return Visibility( return Visibility(
visible: findInPageState.visible || searchResult?.hasMatches == true, visible: findInPageState.visible || searchResult.hasMatches,
child: Padding( child: Padding(
padding: padding, padding: padding,
child: Material( child: Material(
@@ -125,7 +124,7 @@ class FindInPageWidget extends HookConsumerWidget {
), ),
), ),
Text( Text(
(searchResult != null && searchResult.hasMatches) searchResult.hasMatches
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
: 'Not found', : 'Not found',
), ),
@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
@@ -28,7 +29,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/domain/entities/equatable_image.dart'; import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/utils/lru_cache.dart'; import 'package:weblibre/utils/lru_cache.dart';
final _cache = LRUCache<int, EquatableImage>(100); final _cache = LRUCache<ImageIdentity, EquatableImage>(100);
const _defaultSvgIconSize = 32; const _defaultSvgIconSize = 32;
/// Clears the global image decode cache. /// Clears the global image decode cache.
@@ -42,13 +43,23 @@ Future<EquatableImage?> tryDecodeImage(
int? targetHeight, int? targetHeight,
bool allowUpscaling = true, bool allowUpscaling = true,
}) async { }) async {
final digest = secureHash(bytes); // The decode options are part of the identity of the result, not just of the
// request: the same bytes decoded at a thumbnail's target width and at an
// icon's native size are different images. Keying on the content digest
// alone would both hand back a wrongly-sized image to whichever caller ran
// second, and make two genuinely different decodes compare equal.
final identity = (
digest: secureHash(bytes),
targetWidth: targetWidth,
targetHeight: targetHeight,
allowUpscaling: allowUpscaling,
);
final cached = _cache.get(digest); final cached = _cache.get(identity);
if (cached?.value != null) { if (cached?.value != null) {
return cached; return cached;
} else if (cached != null) { } else if (cached != null) {
_cache.remove(digest); _cache.remove(identity);
} }
try { try {
@@ -60,10 +71,10 @@ Future<EquatableImage?> tryDecodeImage(
); );
final frameInfo = await codec.getNextFrame(); final frameInfo = await codec.getNextFrame();
final image = EquatableImage(frameInfo.image, hash: digest); final image = EquatableImage(frameInfo.image, identity: identity);
if (image.value != null && image.value!.width > 0) { if (image.value != null && image.value!.width > 0) {
_cache.set(digest, image); _cache.set(identity, image);
return image; return image;
} }
} catch (e, s) { } catch (e, s) {
@@ -71,12 +82,12 @@ Future<EquatableImage?> tryDecodeImage(
try { try {
final svgImage = await _tryDecodeSvg( final svgImage = await _tryDecodeSvg(
bytes, bytes,
hash: digest, identity: identity,
targetWidth: targetWidth, targetWidth: targetWidth,
targetHeight: targetHeight, targetHeight: targetHeight,
); );
if (svgImage != null) { if (svgImage != null) {
_cache.set(digest, svgImage); _cache.set(identity, svgImage);
return svgImage; return svgImage;
} }
} catch (svgError, svgStackTrace) { } catch (svgError, svgStackTrace) {
@@ -94,6 +105,46 @@ Future<EquatableImage?> tryDecodeImage(
return null; return null;
} }
/// Transcodes a native screenshot (delivered as WebP) to PNG off the main
/// isolate.
///
/// Decoding a full-resolution screenshot and re-encoding it as PNG costs tens
/// of milliseconds on the UI thread, and every caller does it right as a share
/// sheet or file picker is animating in — so the cost lands as a visible hitch.
/// dart:ui's codec APIs are usable from background isolates, so the whole
/// transcode runs there; if that ever fails we fall back to doing it inline
/// rather than losing the feature.
Future<Uint8List?> encodeScreenshotAsPng(Uint8List screenshot) async {
try {
return await Isolate.run(() => _transcodeToPng(screenshot));
} catch (e, s) {
logger.w(
'Screenshot PNG transcode failed in background isolate, retrying inline',
error: e,
stackTrace: s,
);
return _transcodeToPng(screenshot);
}
}
Future<Uint8List?> _transcodeToPng(Uint8List bytes) async {
final codec = await instantiateImageCodec(bytes);
try {
final frameInfo = await codec.getNextFrame();
try {
final png = await frameInfo.image.toByteData(format: ImageByteFormat.png);
return png?.buffer.asUint8List();
} finally {
frameInfo.image.dispose();
}
} finally {
codec.dispose();
}
}
bool _isLikelySvg(Uint8List bytes) { bool _isLikelySvg(Uint8List bytes) {
final prefix = utf8 final prefix = utf8
.decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true) .decode(bytes.sublist(0, min(bytes.length, 512)), allowMalformed: true)
@@ -110,7 +161,7 @@ bool _isLikelySvg(Uint8List bytes) {
Future<EquatableImage?> _tryDecodeSvg( Future<EquatableImage?> _tryDecodeSvg(
Uint8List bytes, { Uint8List bytes, {
required int hash, required ImageIdentity identity,
int? targetWidth, int? targetWidth,
int? targetHeight, int? targetHeight,
}) async { }) async {
@@ -153,7 +204,7 @@ Future<EquatableImage?> _tryDecodeSvg(
dimensions.width, dimensions.width,
dimensions.height, dimensions.height,
); );
return EquatableImage(rasterized, hash: hash); return EquatableImage(rasterized, identity: identity);
} finally { } finally {
scaledPicture.dispose(); scaledPicture.dispose();
} }
@@ -33,7 +33,20 @@ part 'singbox_proxy_logs.g.dart';
/// is well within budget for a debugging surface. /// is well within budget for a debugging surface.
const int _ringBufferCapacity = 2000; const int _ringBufferCapacity = 2000;
/// Lower bound between two published snapshots. A chatty proxy can emit lines
/// far faster than the screen can render them, and every publication copies the
/// whole ring buffer, so coalescing bursts is free in terms of what the user
/// actually sees.
const _publishInterval = Duration(milliseconds: 100);
/// Snapshot of buffered log entries. Most-recent-last (chronological). /// Snapshot of buffered log entries. Most-recent-last (chronological).
///
/// This notifier is `keepAlive` and subscribed from app start (see
/// `main.dart`) so startup messages are retained even before any UI mounts.
/// Appending and *publishing* are therefore deliberately decoupled: lines
/// always land in [_buffer], but a new immutable snapshot is only produced
/// while the log screen is on screen ([setLivePublishing]) and at most once
/// per [_publishInterval].
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class SingboxProxyLogs extends _$SingboxProxyLogs { class SingboxProxyLogs extends _$SingboxProxyLogs {
final _buffer = Queue<ProxyLogMessage>(); final _buffer = Queue<ProxyLogMessage>();
@@ -41,6 +54,28 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription; StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
StreamSubscription<TorLogMessage>? _torSubscription; StreamSubscription<TorLogMessage>? _torSubscription;
Timer? _publishTimer;
bool _livePublishing = false;
/// Buffer contents materialized on demand, regardless of publication state.
/// Lets a freshly mounted screen paint the backlog without waiting for the
/// first throttled snapshot.
List<ProxyLogMessage> get snapshot => List.unmodifiable(_buffer);
/// Enables/disables snapshot publication. Called by the log screen as it
/// mounts and unmounts; flushes on both edges so the state left behind is
/// always complete.
void setLivePublishing(bool enabled) {
if (_livePublishing == enabled) {
return;
}
_livePublishing = enabled;
_publishTimer?.cancel();
_publishTimer = null;
_publish();
}
void _append(ProxyLogMessage message) { void _append(ProxyLogMessage message) {
_buffer.add(message); _buffer.add(message);
@@ -48,11 +83,27 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
_buffer.removeFirst(); _buffer.removeFirst();
} }
state = List.unmodifiable(_buffer); if (!_livePublishing || (_publishTimer?.isActive ?? false)) {
return;
}
_publishTimer = Timer(_publishInterval, _publish);
}
void _publish() {
_publishTimer = null;
if (!ref.mounted) {
return;
}
state = snapshot;
} }
void clear() { void clear() {
_buffer.clear(); _buffer.clear();
_publishTimer?.cancel();
_publishTimer = null;
state = const []; state = const [];
} }
@@ -69,10 +120,11 @@ class SingboxProxyLogs extends _$SingboxProxyLogs {
); );
ref.onDispose(() { ref.onDispose(() {
_publishTimer?.cancel();
unawaited(_singboxSubscription?.cancel()); unawaited(_singboxSubscription?.cancel());
unawaited(_torSubscription?.cancel()); unawaited(_torSubscription?.cancel());
}); });
return List.unmodifiable(_buffer); return snapshot;
} }
} }
@@ -33,7 +33,26 @@ class SingboxProxyLogsScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final logs = ref.watch(singboxProxyLogsProvider); // The ring buffer fills from app start but only publishes snapshots while
// this screen is up, so opt in for as long as we're mounted. Seeded from
// the notifier's live buffer during the first build, so the backlog is
// painted immediately rather than after the first published snapshot.
final logsState = useState(
useMemoized(() => ref.read(singboxProxyLogsProvider.notifier).snapshot),
);
useEffect(() {
final notifier = ref.read(singboxProxyLogsProvider.notifier);
notifier.setLivePublishing(true);
return () => notifier.setLivePublishing(false);
}, []);
ref.listen(singboxProxyLogsProvider, (previous, next) {
logsState.value = next;
});
final logs = logsState.value;
final filter = useState<String?>(null); final filter = useState<String?>(null);
final autoScroll = useState(true); final autoScroll = useState(true);
final scrollController = useScrollController(); final scrollController = useScrollController();
@@ -71,9 +90,14 @@ class SingboxProxyLogsScreen extends HookConsumerWidget {
return () => scrollController.removeListener(onScroll); return () => scrollController.removeListener(onScroll);
}, [scrollController]); }, [scrollController]);
final filtered = filter.value == null // Memoized so scroll-driven rebuilds (autoScroll flipping) don't re-scan
? logs // up to 2000 entries; only a new snapshot or a filter change does.
: logs.where((m) => m.level.toLowerCase() == filter.value).toList(); final filtered = useMemoized(
() => filter.value == null
? logs
: logs.where((m) => m.level.toLowerCase() == filter.value).toList(),
[logs, filter.value],
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
as fmc; as fmc;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -76,6 +77,26 @@ CaptureTabData? sandboxCaptureForTab(Ref ref, {required String? tabId}) {
return map?[tabId]; return map?[tabId];
} }
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
///
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
/// any write to that table — including ones that touch columns nothing on
/// screen renders. Consumers that build a tab-keyed list and only need the
/// source URL watch this instead, so an unrelated write can't rebuild the
/// always-visible quick tab switcher or the tab-preview list.
@Riverpod(keepAlive: true)
EquatableValue<Map<String, Uri>> sandboxSourceUris(Ref ref) {
final rows = ref.watch(sandboxCaptureMapProvider).value ?? const {};
// Equatable result, so a re-emit that doesn't change any source URL stops
// here instead of propagating.
return EquatableValue({
for (final MapEntry(:key, :value) in rows.entries)
if (parseSandboxSource(value) case final uri?) key: uri,
});
}
/// The canonical source URL of a sandbox-captured tab, or `null` when the /// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in /// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case). /// that case).
@@ -188,6 +188,81 @@ final class SandboxCaptureForTabFamily extends $Family
String toString() => r'sandboxCaptureForTabProvider'; String toString() => r'sandboxCaptureForTabProvider';
} }
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
///
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
/// any write to that table — including ones that touch columns nothing on
/// screen renders. Consumers that build a tab-keyed list and only need the
/// source URL watch this instead, so an unrelated write can't rebuild the
/// always-visible quick tab switcher or the tab-preview list.
@ProviderFor(sandboxSourceUris)
final sandboxSourceUrisProvider = SandboxSourceUrisProvider._();
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
///
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
/// any write to that table — including ones that touch columns nothing on
/// screen renders. Consumers that build a tab-keyed list and only need the
/// source URL watch this instead, so an unrelated write can't rebuild the
/// always-visible quick tab switcher or the tab-preview list.
final class SandboxSourceUrisProvider
extends
$FunctionalProvider<
EquatableValue<Map<String, Uri>>,
EquatableValue<Map<String, Uri>>,
EquatableValue<Map<String, Uri>>
>
with $Provider<EquatableValue<Map<String, Uri>>> {
/// Canonical source URLs of sandbox-captured tabs, keyed by tabId.
///
/// [sandboxCaptureMapProvider] is a drift stream over the whole capture-tab
/// table, so it re-emits a fresh map (and a fresh `CaptureTabData` per row) on
/// any write to that table — including ones that touch columns nothing on
/// screen renders. Consumers that build a tab-keyed list and only need the
/// source URL watch this instead, so an unrelated write can't rebuild the
/// always-visible quick tab switcher or the tab-preview list.
SandboxSourceUrisProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sandboxSourceUrisProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sandboxSourceUrisHash();
@$internal
@override
$ProviderElement<EquatableValue<Map<String, Uri>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<Map<String, Uri>> create(Ref ref) {
return sandboxSourceUris(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<Map<String, Uri>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<Map<String, Uri>>>(
value,
),
);
}
}
String _$sandboxSourceUrisHash() => r'2d04e6e0e79c5b79e640386fe29e101fdd64688d';
/// The canonical source URL of a sandbox-captured tab, or `null` when the /// The canonical source URL of a sandbox-captured tab, or `null` when the
/// tab is not a sandbox capture (the regular `tabState.url` should be used in /// tab is not a sandbox capture (the regular `tabState.url` should be used in
/// that case). /// that case).
@@ -3,30 +3,106 @@ import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/domain/entities/equatable_image.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/features/geckoview/utils/image_helper.dart';
void main() { void main() {
testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', ( testWidgets('tryDecodeImage rasterizes svg bytes at favicon size', (
tester, tester,
) async { ) async {
clearImageCache(); // Must run outside the fake-async zone: flutter_svg's loader parses the
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon)); // document via `compute()`, i.e. a real isolate, and that future never
// completes while `testWidgets` controls the clock — the test would hang
// forever rather than fail.
await tester.runAsync(() async {
clearImageCache();
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
final image = await tryDecodeImage(svgBytes); final image = await tryDecodeImage(svgBytes);
expect(image, isNotNull); expect(image, isNotNull);
expect(image!.value, isNotNull); expect(image!.value, isNotNull);
expect(image.value!.width, 32); expect(image.value!.width, 32);
expect(image.value!.height, 32); expect(image.value!.height, 32);
final byteData = await image.value!.toByteData( final byteData = await image.value!.toByteData(
format: ImageByteFormat.rawRgba, format: ImageByteFormat.rawRgba,
);
expect(byteData, isNotNull);
// A pixel in the right half should be painted once the SVG is scaled
// to the requested raster size instead of being left in the top-left.
expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]);
});
});
testWidgets('tryDecodeImage does not serve a cached decode across sizes', (
tester,
) async {
await tester.runAsync(() async {
clearImageCache();
final svgBytes = Uint8List.fromList(utf8.encode(_svgIcon));
// Same bytes, different decode options. The cache is keyed on the digest
// *and* the options, so the second call must not be answered with the
// first call's differently-sized image.
final sized = await tryDecodeImage(svgBytes, targetWidth: 64);
expect(sized?.value?.width, 64);
expect(sized?.value?.height, 64);
final unsized = await tryDecodeImage(svgBytes);
expect(unsized?.value?.width, 32);
expect(unsized?.value?.height, 32);
// Reversed order, to catch a cache that only poisons one direction.
clearImageCache();
final unsizedFirst = await tryDecodeImage(svgBytes);
expect(unsizedFirst?.value?.width, 32);
final sizedSecond = await tryDecodeImage(svgBytes, targetWidth: 64);
expect(sizedSecond?.value?.width, 64);
// Two decodes that differ only in size must not compare equal, or the
// `state[id] == next` guards in the tab-state notifiers would drop a
// genuine change.
expect(sizedSecond, isNot(equals(unsizedFirst)));
// ...while a repeat of the *same* request is still deduped.
final unsizedAgain = await tryDecodeImage(svgBytes);
expect(unsizedAgain, equals(unsizedFirst));
expect(identical(unsizedAgain, unsizedFirst), isTrue);
});
});
test('ImageIdentity compares structurally, not by a folded hash', () {
const a = (
digest: 0x0123456789ABCDEF,
targetWidth: 720,
targetHeight: null,
allowUpscaling: false,
);
// Differs only in the low bits of the 64-bit content digest. Folding the
// identity into a single hash code would truncate to 30 bits and could
// collapse these two onto the same cache entry; structural equality can't.
const b = (
digest: 0x0123456789ABCDEE,
targetWidth: 720,
targetHeight: null,
allowUpscaling: false,
); );
expect(byteData, isNotNull);
// A pixel in the right half should be painted once the SVG is scaled expect(a, isNot(equals(b)));
// to the requested raster size instead of being left in the top-left. expect(<ImageIdentity, String>{a: 'a', b: 'b'}.length, 2);
expect(_rgbaAt(byteData!, width: 32, x: 24, y: 16), [47, 128, 237, 255]);
// Options are part of the key too.
const sameDigestOtherWidth = (
digest: 0x0123456789ABCDEF,
targetWidth: 32,
targetHeight: null,
allowUpscaling: false,
);
expect(a, isNot(equals(sameDigestOtherWidth)));
}); });
} }