From 4711767e0ccf60ed57e592cdb111c7aedb299673 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 25 Aug 2025 05:41:25 +0800 Subject: [PATCH] Add Search shortcut (CTRL + F) (#554)(#507)(#110) --- lib/ui/component/json/json_text.dart | 12 +--- lib/ui/component/search/highlight_text.dart | 56 ++++++++++--------- .../component/search/search_controller.dart | 8 ++- lib/ui/content/body.dart | 47 +++++++++++++--- lib/ui/content/panel.dart | 16 +++--- 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/lib/ui/component/json/json_text.dart b/lib/ui/component/json/json_text.dart index a0a61e6..df5c3d7 100644 --- a/lib/ui/component/json/json_text.dart +++ b/lib/ui/component/json/json_text.dart @@ -48,7 +48,7 @@ class _JsonTextState extends State { SearchTextController? searchController; @override - initState() { + void initState() { super.initState(); searchController = widget.searchController; } @@ -57,7 +57,6 @@ class _JsonTextState extends State { void dispose() { trackingScrollController?.dispose(); trackingScrollController = null; - logger.d('JsonText dispose'); super.dispose(); } @@ -74,13 +73,6 @@ class _JsonTextState extends State { ); } - double getAvailableHeight(BuildContext context) { - // 获取当前组件可用高度(屏幕高度减去系统padding和AppBar高度等) - final mediaQuery = MediaQuery.of(context); - final appBar = Scaffold.of(context).appBarMaxHeight ?? 0; - return mediaQuery.size.height - mediaQuery.padding.top - appBar; - } - Widget jsonTextWidget(BuildContext context) { var jsonParser = JsonParser(widget.json, widget.colorTheme, widget.indent, searchController); var textList = jsonParser.getJsonTree(); @@ -109,8 +101,6 @@ class _JsonTextState extends State { final key = jsonParser.matchKeys[currentIndex]; final context = key.currentContext; if (context != null) { - logger.d('scrollToMatch: currentIndex=$currentIndex, key=$key'); - Scrollable.ensureVisible( context, duration: const Duration(milliseconds: 300), diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index d0436cd..532fd5f 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -30,7 +30,7 @@ class HighlightTextWidget extends StatelessWidget { ); } - List _highlightMatches(BuildContext context) { + List _highlightMatches(BuildContext context) { if (!searchController.shouldSearch()) { return [TextSpan(text: text)]; } @@ -43,24 +43,27 @@ class HighlightTextWidget extends StatelessWidget { caseSensitive: searchController.value.isCaseSensitive, ); - final spans = []; + final spans = []; int start = 0; var allMatches = regex.allMatches(text).toList(); final currentIndex = searchController.currentMatchIndex.value; ColorScheme colorScheme = ColorScheme.of(context); - List matchOffsets = []; + List matchKeys = []; for (int i = 0; i < allMatches.length; i++) { final match = allMatches[i]; if (match.start > start) { spans.add(TextSpan(text: text.substring(start, match.start))); } - matchOffsets.add(match.start); - spans.add(TextSpan( - text: text.substring(match.start, match.end), - style: TextStyle( - backgroundColor: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, - ), - )); + + // 为每个高亮项分配一个 GlobalKey + final key = GlobalKey(); + matchKeys.add(key); + spans.add(WidgetSpan( + child: Container( + key: key, + color: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, + child: Text(text.substring(match.start, match.end)), + ))); start = match.end; } if (start < text.length) { @@ -69,25 +72,28 @@ class HighlightTextWidget extends StatelessWidget { WidgetsBinding.instance.addPostFrameCallback((_) { searchController.updateMatchCount(allMatches.length); - if (scrollController != null && allMatches.isNotEmpty && currentIndex < matchOffsets.length) { - _scrollToMatch(context, matchOffsets[currentIndex]); - } + _scrollToMatch(context, matchKeys); + matchKeys.clear(); }); return spans; } - void _scrollToMatch(BuildContext context, int charOffset) { - if (scrollController == null) return; - final textStyle = DefaultTextStyle.of(context).style; - final span = TextSpan(text: text.substring(0, charOffset), style: textStyle); - final tp = TextPainter( - text: span, - textDirection: TextDirection.ltr, - maxLines: null, - ); - tp.layout(maxWidth: scrollController!.position.viewportDimension); - final offset = tp.height; - scrollController!.animateTo(offset, duration: const Duration(milliseconds: 300), curve: Curves.ease); + void _scrollToMatch(BuildContext context, List matchKeys) { + if (matchKeys.isNotEmpty) { + final currentIndex = searchController.currentMatchIndex.value; + if (currentIndex >= 0 && currentIndex < matchKeys.length) { + final key = matchKeys[currentIndex]; + final context = key.currentContext; + if (context != null) { + + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + alignment: 0.5, // 高亮项在视图中的位置 + ); + } + } + } } } diff --git a/lib/ui/component/search/search_controller.dart b/lib/ui/component/search/search_controller.dart index 659283c..def9db7 100644 --- a/lib/ui/component/search/search_controller.dart +++ b/lib/ui/component/search/search_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get_rx/src/rx_types/rx_types.dart'; +import 'package:proxypin/network/util/logger.dart'; import 'package:proxypin/ui/component/search/search_field.dart'; class SearchTextController extends ValueNotifier { @@ -76,10 +77,11 @@ class SearchTextController extends ValueNotifier { @override void dispose() { + logger.d('Disposing SearchTextController'); + removeSearchOverlay(); patternController.dispose(); totalMatchCount.close(); currentMatchIndex.close(); - removeSearchOverlay(); super.dispose(); } @@ -100,8 +102,8 @@ class SearchTextController extends ValueNotifier { } OverlayEntry _buildSearchOverlay(BuildContext context, {double? top, double? right}) { - overlayTop ??= top; - overlayRight ??= right; + overlayTop = top ?? overlayTop; + overlayRight = right ?? overlayRight; return OverlayEntry( builder: (context) { return Positioned( diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index 9151f87..a39e95c 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -70,6 +70,7 @@ class HttpBodyWidget extends StatefulWidget { class HttpBodyState extends State { var bodyKey = GlobalKey<_BodyState>(); int tabIndex = 0; + final searchIconKey = GlobalKey(); final SearchTextController searchController = SearchTextController(); AppLocalizations get localizations => AppLocalizations.of(context)!; @@ -142,12 +143,39 @@ class HttpBodyState extends State { searchController: searchController)) //body ]; - var tabController = DefaultTabController( - initialIndex: tabIndex, - length: tabs.list.length, - child: widget.inNewWindow - ? ListView(children: list) - : Column(crossAxisAlignment: CrossAxisAlignment.start, children: list)); + var tabController = FocusableActionDetector( + shortcuts: { + LogicalKeySet( + Platform.isMacOS ? LogicalKeyboardKey.meta : LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): + ActivateIntent(), + LogicalKeySet(LogicalKeyboardKey.escape): DismissIntent(), + }, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (intent) { + if (searchController.isSearchOverlayVisible) { + hideSearchOverlay(); + } else { + RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; + Offset position = renderBox.localToGlobal(Offset.zero); // 获取搜索图标的位置 + searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); + } + return null; + }, + ), + DismissIntent: CallbackAction( + onInvoke: (intent) { + hideSearchOverlay(); + return null; + }, + ), + }, + child: DefaultTabController( + initialIndex: tabIndex, + length: tabs.list.length, + child: widget.inNewWindow + ? ListView(children: list) + : Column(crossAxisAlignment: CrossAxisAlignment.start, children: list))); //在新窗口打开 if (widget.inNewWindow) { @@ -180,13 +208,16 @@ class HttpBodyState extends State { Text('$type Body', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), const SizedBox(width: 18), InkWell( + key: searchIconKey, child: Icon(Icons.search, size: 20), // tooltip: localizations.search, - onTapDown: (TapDownDetails details) { + onTap: () { if (searchController.isSearchOverlayVisible) { searchController.removeSearchOverlay(); } else { - searchController.showSearchOverlay(context, top: details.globalPosition.dy + 50, right: 10); + RenderBox renderBox = searchIconKey.currentContext?.findRenderObject() as RenderBox; + Offset position = renderBox.localToGlobal(Offset.zero); // 获取搜索图标的位置 + searchController.showSearchOverlay(context, top: position.dy + renderBox.size.height + 50, right: 10); } }, ), diff --git a/lib/ui/content/panel.dart b/lib/ui/content/panel.dart index 59e07e5..452a2d1 100644 --- a/lib/ui/content/panel.dart +++ b/lib/ui/content/panel.dart @@ -232,10 +232,10 @@ class NetworkTabState extends State with SingleTickerProvi path = Uri.decodeFull(path); } catch (_) {} - return ListView( + return SingleChildScrollView( controller: scrollController, - physics: const AlwaysScrollableScrollPhysics(), - children: [RowWidget("Path", path), ...message(widget.request.get(), "Request", scrollController)]); + child: + Column(children: [RowWidget("Path", path), ...message(widget.request.get(), "Request", scrollController)])); } Widget response() { @@ -244,10 +244,12 @@ class NetworkTabState extends State with SingleTickerProvi } var scrollController = ScrollController(); - return ListView(controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), children: [ - RowWidget("StatusCode", widget.response.get()?.status.toString()), - ...message(widget.response.get(), "Response", scrollController) - ]); + return SingleChildScrollView( + controller: scrollController, + child: Column(children: [ + RowWidget("StatusCode", widget.response.get()?.status.toString()), + ...message(widget.response.get(), "Response", scrollController) + ])); } List message(HttpMessage? message, String type, ScrollController scrollController) {