reverse search bangs

This commit is contained in:
Fabian Freund
2026-05-27 10:06:24 +02:00
parent 7275ac9400
commit 5a7b0aa6cb
6 changed files with 800 additions and 1 deletions
@@ -29,6 +29,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.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/search.dart';
import 'package:weblibre/features/bangs/domain/services/reverse_match.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/providers/selected_tab.dart';
@@ -188,6 +189,12 @@ class SearchScreen extends HookConsumerWidget {
final isUrlInput = useState(false);
final isEditingAfterSearch = useState(false);
// Holds the original URL when reverse bang-matching has swapped the
// address-bar text for an extracted query. First tap of the clear button
// restores this URL; a subsequent tap clears the field normally.
final revertUrl = useState<String?>(null);
final reverseMatchedQuery = useState<String?>(null);
useOnListenableChangeSelector(
searchTextController,
() => searchTextController.text,
@@ -257,6 +264,49 @@ class SearchScreen extends HookConsumerWidget {
return null;
}, []);
// Try to recognise the current URL as a bang search. If a bang matches,
// swap the URL for its extracted query and pre-select the bang so the
// user can refine the search instead of editing the raw URL.
useEffect(() {
if (!startedWithUrl || initialSearchText == null) return null;
final uri = Uri.tryParse(initialSearchText!);
if (uri == null) return null;
unawaited(() async {
final match = await ref
.read(reverseBangMatcherProvider.notifier)
.match(uri);
if (!context.mounted) return;
if (match == null) return;
// Bail out if the user has already started editing while we matched.
if (searchTextController.text != initialSearchText) return;
revertUrl.value = initialSearchText;
reverseMatchedQuery.value = match.query;
searchTextController.value = TextEditingValue(
text: match.query,
selection: TextSelection(
baseOffset: 0,
extentOffset: match.query.length,
),
);
// Mutual exclusion: clear any site-scoped selection so the global
// auto-match isn't hidden behind a stale site bang (mirrors the
// SmartBangSelector selection logic).
final tabHost = existingTabState?.url.host;
if (tabHost != null && tabHost.isNotEmpty) {
ref
.read(selectedBangTriggerProvider(domain: tabHost).notifier)
.clearTrigger();
}
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(match.bang.toKey());
}());
return null;
}, []);
//Request initial focus in a way our useOnListenableChangeSelector is triggered
useEffect(() {
if (ref.read(searchAutofocusSuppressionProvider)) {
@@ -671,6 +721,32 @@ class SearchScreen extends HookConsumerWidget {
maxLines: isEditMode ? 3 : 1,
label: const Text('Search or enter URL'),
unfocusOnTapOutside: false,
onClearPressed: () {
final url = revertUrl.value;
if (url != null &&
searchTextController.text ==
reverseMatchedQuery.value) {
// First press after a reverse-match swap: restore the
// original URL and drop the auto-selected bang. The
// user can press again to actually clear.
searchTextController.value = TextEditingValue(
text: url,
selection: TextSelection(
baseOffset: 0,
extentOffset: url.length,
),
);
revertUrl.value = null;
reverseMatchedQuery.value = null;
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
revertUrl.value = null;
reverseMatchedQuery.value = null;
searchTextController.clear();
}
},
onSubmitted: (value) async {
if (value.isEmpty) return;
@@ -49,6 +49,11 @@ class SearchField extends HookConsumerWidget {
final BangData? activeBang;
final bool showBangIcon;
/// Overrides the clear (`x`) button behaviour. When null, the button just
/// clears the text. When provided, the callback decides what to do (e.g.
/// restore a previous value first, then clear on the next press).
final VoidCallback? onClearPressed;
const SearchField({
super.key,
required this.textEditingController,
@@ -64,6 +69,7 @@ class SearchField extends HookConsumerWidget {
this.autofocus = false,
this.textFieldKey,
this.hint,
this.onClearPressed,
});
@override
@@ -160,7 +166,11 @@ class SearchField extends HookConsumerWidget {
padding: const EdgeInsetsDirectional.only(end: 8.0),
child: IconButton(
onPressed: () {
textEditingController.clear();
if (onClearPressed != null) {
onClearPressed!();
} else {
textEditingController.clear();
}
},
icon: const Icon(Icons.clear),
),