diff --git a/lib/main.dart b/lib/main.dart index 0735c28..d61fb71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -76,11 +76,11 @@ void main(List args) async { await WindowManipulator.initialize(); // 调整关闭按钮的位置 WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.closeButton, offset: Offset(10, 16)); + buttonType: NSWindowButtonType.closeButton, offset: Offset(10, 13)); WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.miniaturizeButton, offset: const Offset(29, 16)); + buttonType: NSWindowButtonType.miniaturizeButton, offset: const Offset(29, 13)); WindowManipulator.overrideStandardWindowButtonPosition( - buttonType: NSWindowButtonType.zoomButton, offset: const Offset(48, 16)); + buttonType: NSWindowButtonType.zoomButton, offset: const Offset(48, 13)); } await windowManager.waitUntilReadyToShow(windowOptions, () async { diff --git a/lib/ui/component/json/json_text.dart b/lib/ui/component/json/json_text.dart index df5c3d7..34935cb 100644 --- a/lib/ui/component/json/json_text.dart +++ b/lib/ui/component/json/json_text.dart @@ -288,13 +288,17 @@ class JsonParser { // 为每个高亮项分配一个 GlobalKey final key = GlobalKey(); matchKeys.add(key); + spans.add(WidgetSpan( - child: Container( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.ideographic, + child: Text( + text.substring(match.start, match.end), key: key, - color: searchMatchTotal == currentIndex ? colorTheme.searchMatchCurrentColor : colorTheme.searchMatchColor, - child: Text( - text.substring(match.start, match.end), - style: TextStyle(color: textColor), + style: TextStyle( + color: textColor, + backgroundColor: + searchMatchTotal == currentIndex ? colorTheme.searchMatchCurrentColor : colorTheme.searchMatchColor, ), ), )); diff --git a/lib/ui/component/json/json_viewer.dart b/lib/ui/component/json/json_viewer.dart index 8ff1788..1004f30 100644 --- a/lib/ui/component/json/json_viewer.dart +++ b/lib/ui/component/json/json_viewer.dart @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -library flutter_json_widget; import 'dart:convert'; @@ -22,40 +21,94 @@ import 'package:flutter/services.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/ui/component/json/theme.dart'; import 'package:proxypin/ui/component/json/toast.dart'; +import 'package:proxypin/utils/lang.dart'; +import 'package:proxypin/utils/platform.dart'; + +import '../search/search_controller.dart'; class JsonViewer extends StatelessWidget { final dynamic jsonObj; final ColorTheme colorTheme; + final SearchTextController? searchController; - const JsonViewer(this.jsonObj, {super.key, required this.colorTheme}); + const JsonViewer(this.jsonObj, {super.key, required this.colorTheme, this.searchController}); @override Widget build(BuildContext context) { - return DefaultTextStyle.merge( - style: const TextStyle( - fontWeight: FontWeight.w600, - ), - child: getContentWidget(jsonObj)); + final matchKeys = []; + if (searchController == null) { + return DefaultTextStyle.merge( + style: const TextStyle(fontWeight: FontWeight.w600), + child: getContentWidget(jsonObj, matchTotalCount: ValueWrap.of(0), matchKeys: matchKeys)); + } + return AnimatedBuilder( + animation: searchController ?? ValueNotifier(0), + builder: (context, child) { + final matchTotalCount = ValueWrap.of(0); + matchKeys.clear(); + final contentWidget = DefaultTextStyle.merge( + style: const TextStyle(fontWeight: FontWeight.w600), + child: getContentWidget(jsonObj, matchTotalCount: matchTotalCount, matchKeys: matchKeys)); + WidgetsBinding.instance.addPostFrameCallback((_) { + searchController?.updateMatchCount(matchTotalCount.get()!); + scrollToMatch(matchKeys); + }); + return contentWidget; + }); } - Widget getContentWidget(dynamic content) { + Widget getContentWidget(dynamic content, + {required ValueWrap matchTotalCount, required List matchKeys}) { if (content is List) { - return JsonArrayViewer(content, notRoot: false, colorTheme: colorTheme); + return JsonArrayViewer(content, + colorTheme: colorTheme, + searchController: searchController, + matchTotalCount: matchTotalCount, + matchKeys: matchKeys); } else if (content is Map) { - return JsonObjectViewer(content, notRoot: false, colorTheme: colorTheme); + return JsonObjectViewer(content, + colorTheme: colorTheme, + searchController: searchController, + matchTotalCount: matchTotalCount, + matchKeys: matchKeys); } else { - return Text(content?.toString() ?? ''); + return SelectableText(showCursor: true, content?.toString() ?? ''); + } + } + + void scrollToMatch(List matchKeys) { + if (searchController != null && 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, // 高亮项在视图中的位置 + ); + } + } } } } class JsonObjectViewer extends StatefulWidget { final ColorTheme colorTheme; - final Map jsonObj; final bool notRoot; + final SearchTextController? searchController; + final ValueWrap matchTotalCount; + final List matchKeys; - const JsonObjectViewer(this.jsonObj, {super.key, this.notRoot = false, required this.colorTheme}); + const JsonObjectViewer(this.jsonObj, + {super.key, + this.notRoot = false, + required this.colorTheme, + this.searchController, + required this.matchTotalCount, + required this.matchKeys}); @override JsonObjectViewerState createState() => JsonObjectViewerState(); @@ -81,7 +134,7 @@ class JsonObjectViewerState extends State { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _getList()); } - _getList() { + List _getList() { List list = []; for (MapEntry entry in widget.jsonObj.entries) { if (openFlag[entry.key] == null) { @@ -93,13 +146,22 @@ class JsonObjectViewerState extends State { getKeyWidget(entry), Text(':', style: TextStyle(color: widget.colorTheme.colon)), const SizedBox(width: 3), - _copyValue(context, _getValueWidget(entry.value, widget.colorTheme), entry.value), + _copyValue( + context, + _getValueWidget(entry.value, widget.colorTheme, + searchController: widget.searchController, + matchTotalCount: widget.matchTotalCount, + matchKeys: widget.matchKeys), + entry.value), ], )); list.add(const SizedBox(height: 4)); if ((openFlag[entry.key] ?? false) && entry.value != null) { - list.add(getContentWidget(entry.value, widget.colorTheme)); + list.add(getContentWidget(entry.value, widget.colorTheme, + searchController: widget.searchController, + matchTotalCount: widget.matchTotalCount, + matchKeys: widget.matchKeys)); } } return list; @@ -107,6 +169,16 @@ class JsonObjectViewerState extends State { // key Widget getKeyWidget(MapEntry entry) { + final keyText = entry.key; + + final keyWidget = SelectableText.rich( + showCursor: true, + TextSpan( + children: _highlightText(keyText, TextStyle(color: widget.colorTheme.propertyKey), + searchController: widget.searchController, + colorTheme: widget.colorTheme, + matchTotalCount: widget.matchTotalCount, + matchKeys: widget.matchKeys))); //是否有子层级 if (_isExtensible(entry.value)) { return InkWell( @@ -121,22 +193,35 @@ class JsonObjectViewerState extends State { (openFlag[entry.key] ?? false) ? const Icon(Icons.keyboard_arrow_down, size: 18) : const Icon(Icons.keyboard_arrow_right, size: 18), - Text(entry.key, style: TextStyle(color: widget.colorTheme.propertyKey)), + keyWidget, ], )); } return Row(children: [ const Icon(Icons.keyboard_arrow_right, color: Color.fromARGB(0, 0, 0, 0), size: 18), - Text(entry.key, style: TextStyle(color: widget.colorTheme.propertyKey)), + keyWidget, ]); } - static getContentWidget(dynamic content, ColorTheme colorTheme) { + static Widget getContentWidget(dynamic content, ColorTheme colorTheme, + {SearchTextController? searchController, + required ValueWrap matchTotalCount, + required List matchKeys}) { if (content is List) { - return JsonArrayViewer(content, notRoot: true, colorTheme: colorTheme); + return JsonArrayViewer(content, + notRoot: true, + colorTheme: colorTheme, + searchController: searchController, + matchTotalCount: matchTotalCount, + matchKeys: matchKeys); } else { - return JsonObjectViewer(content, notRoot: true, colorTheme: colorTheme); + return JsonObjectViewer(content, + notRoot: true, + colorTheme: colorTheme, + searchController: searchController, + matchTotalCount: matchTotalCount, + matchKeys: matchKeys); } } } @@ -145,8 +230,17 @@ class JsonArrayViewer extends StatefulWidget { final ColorTheme colorTheme; final List jsonArray; final bool notRoot; + final SearchTextController? searchController; + final ValueWrap matchTotalCount; + final List matchKeys; - const JsonArrayViewer(this.jsonArray, {super.key, this.notRoot = false, required this.colorTheme}); + const JsonArrayViewer(this.jsonArray, + {super.key, + this.notRoot = false, + required this.colorTheme, + this.searchController, + required this.matchTotalCount, + required this.matchKeys}); @override State createState() => _JsonArrayViewerState(); @@ -171,7 +265,7 @@ class _JsonArrayViewerState extends State { openFlag = List.filled(widget.jsonArray.length, false); } - _getList() { + List _getList() { List list = []; int i = 0; for (dynamic content in widget.jsonArray) { @@ -181,12 +275,21 @@ class _JsonArrayViewerState extends State { getKeyWidget(content, i), Text(':', style: TextStyle(color: widget.colorTheme.colon)), const SizedBox(width: 3), - _copyValue(context, _getValueWidget(content, widget.colorTheme), content) + _copyValue( + context, + _getValueWidget(content, widget.colorTheme, + searchController: widget.searchController, + matchTotalCount: widget.matchTotalCount, + matchKeys: widget.matchKeys), + content) ], )); list.add(const SizedBox(height: 4)); if (openFlag[i]) { - list.add(JsonObjectViewerState.getContentWidget(content, widget.colorTheme)); + list.add(JsonObjectViewerState.getContentWidget(content, widget.colorTheme, + searchController: widget.searchController, + matchTotalCount: widget.matchTotalCount, + matchKeys: widget.matchKeys)); } i++; } @@ -222,23 +325,99 @@ class _JsonArrayViewerState extends State { } } -Widget _getValueWidget(dynamic value, ColorTheme colorTheme) { +Widget _getValueWidget(dynamic value, ColorTheme colorTheme, + {SearchTextController? searchController, + required ValueWrap matchTotalCount, + required List matchKeys}) { + String valueStr; + TextStyle style; if (value == null) { - return Text('null', style: TextStyle(color: colorTheme.keyword)); + valueStr = 'null'; + style = TextStyle(color: colorTheme.keyword); } else if (value is num) { - return Text(value.toString(), style: TextStyle(color: colorTheme.keyword)); + valueStr = value.toString(); + style = TextStyle(color: colorTheme.keyword); } else if (value is String) { - return Text('"$value"', style: TextStyle(color: colorTheme.string)); + valueStr = '"$value"'; + style = TextStyle(color: colorTheme.string); } else if (value is bool) { - return Text(value.toString(), style: TextStyle(color: colorTheme.keyword)); + valueStr = value.toString(); + style = TextStyle(color: colorTheme.keyword); } else if (value is List) { if (value.isEmpty) { - return const Text('Array[0]'); + valueStr = 'Array[0]'; + style = const TextStyle(); } else { - return Text('Array<${_getTypeName(value[0])}>[${value.length}]'); + valueStr = 'Array<${_getTypeName(value[0])}>[${value.length}]'; + style = const TextStyle(); } + } else { + valueStr = 'Object'; + style = const TextStyle(fontSize: 13); } - return const Text('Object', style: TextStyle(fontSize: 13)); + + if (searchController?.shouldSearch() == true) { + return SelectableText.rich( + showCursor: true, + TextSpan( + children: _highlightText(valueStr, style, + searchController: searchController, + colorTheme: colorTheme, + matchTotalCount: matchTotalCount, + matchKeys: matchKeys)), + ); + } + + return SelectableText(showCursor: true, valueStr, style: style); +} + +List _highlightText(String text, TextStyle textStyle, + {SearchTextController? searchController, + required ColorTheme colorTheme, + required ValueWrap matchTotalCount, + required List matchKeys}) { + if (searchController == null || searchController.shouldSearch() == false) { + return [TextSpan(text: text, style: textStyle)]; + } + + final pattern = searchController.value.pattern; + final regex = searchController.value.isRegExp + ? RegExp(pattern, caseSensitive: searchController.value.isCaseSensitive) + : RegExp(RegExp.escape(pattern), caseSensitive: searchController.value.isCaseSensitive); + + final spans = []; + int start = 0; + var allMatches = regex.allMatches(text).toList(); + final currentIndex = searchController.currentMatchIndex.value; + 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), style: textStyle)); + } + // 为每个高亮项分配一个 GlobalKey + final key = GlobalKey(); + matchKeys.add(key); + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.ideographic, + child: Text( + key: key, + text.substring(match.start, match.end), + style: textStyle.copyWith( + backgroundColor: matchTotalCount.get() == currentIndex + ? colorTheme.searchMatchCurrentColor + : colorTheme.searchMatchColor, + )), + )); + start = match.end; + + matchTotalCount.set(matchTotalCount.get()! + 1); + } + + if (start < text.length) { + spans.add(TextSpan(text: text.substring(start), style: textStyle)); + } + return spans; } ///获取值的类型 @@ -259,35 +438,33 @@ String _getTypeName(dynamic content) { /// 复制值 Widget _copyValue(BuildContext context, Widget child, Object? value) { + return Flexible( + child: GestureDetector( + onSecondaryTapDown: (details) => showJsonCopyMenu(context, details.globalPosition, value), + onTapDown: + Platforms.isDesktop() ? null : (details) => showJsonCopyMenu(context, details.globalPosition, value), + child: child)); +} + +void showJsonCopyMenu(BuildContext context, Offset position, Object? value) { AppLocalizations localizations = AppLocalizations.of(context)!; - return Flexible( - child: InkWell( - child: child, - onSecondaryTapDown: (details) { - //显示复制菜单 - showMenu( - context: context, - position: RelativeRect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - details.globalPosition.dx, - details.globalPosition.dy, - ), - items: [ - PopupMenuItem( - height: 30, - child: Text(localizations.copy), - onTap: () { - Clipboard.setData(ClipboardData(text: value is String ? value : jsonEncode(value))) - .then((value) => Toast.show(localizations.copied, context)); - }) - ]); - }, - onTap: () { - Clipboard.setData(ClipboardData(text: value is String ? value : jsonEncode(value))) - .then((value) => Toast.show(localizations.copied, context)); - })); + //显示复制菜单 + showMenu( + context: context, + position: RelativeRect.fromLTRB(position.dx, position.dy, position.dx, position.dy), + items: [ + PopupMenuItem( + height: 30, + child: Text(localizations.copy), + onTap: () { + if (value == null) { + return; + } + Clipboard.setData(ClipboardData(text: value is String ? value : jsonEncode(value))) + .then((value) => Toast.show(localizations.copied, context)); + }) + ]); } /// 是否可展开 diff --git a/lib/ui/component/search/highlight_text.dart b/lib/ui/component/search/highlight_text.dart index 532fd5f..a745dad 100644 --- a/lib/ui/component/search/highlight_text.dart +++ b/lib/ui/component/search/highlight_text.dart @@ -5,15 +5,8 @@ class HighlightTextWidget extends StatelessWidget { final String text; final EditableTextContextMenuBuilder? contextMenuBuilder; final SearchTextController searchController; - final ScrollController? scrollController; - const HighlightTextWidget({ - super.key, - required this.text, - this.contextMenuBuilder, - required this.searchController, - this.scrollController, - }); + const HighlightTextWidget({super.key, required this.text, this.contextMenuBuilder, required this.searchController}); @override Widget build(BuildContext context) { @@ -59,6 +52,8 @@ class HighlightTextWidget extends StatelessWidget { final key = GlobalKey(); matchKeys.add(key); spans.add(WidgetSpan( + alignment: PlaceholderAlignment.middle, + baseline: TextBaseline.ideographic, child: Container( key: key, color: i == currentIndex ? colorScheme.primary : colorScheme.inversePrimary, diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index a39e95c..1ed4f3d 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -15,6 +15,7 @@ */ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:file_picker/file_picker.dart'; @@ -158,7 +159,9 @@ class HttpBodyState extends State { } 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); + + searchController.showSearchOverlay(context, + top: max(position.dy + renderBox.size.height + 50, 100), right: 10); } return null; }, @@ -481,7 +484,6 @@ class _BodyState extends State<_Body> { return HighlightTextWidget( text: message!.body!.map(intToHex).join(" "), searchController: widget.searchController, - scrollController: widget.scrollController, contextMenuBuilder: contextMenu); } @@ -489,7 +491,6 @@ class _BodyState extends State<_Body> { return HighlightTextWidget( text: Uri.decodeFull(message!.getBodyString()), searchController: widget.searchController, - scrollController: widget.scrollController, contextMenuBuilder: contextMenu); } @@ -506,23 +507,18 @@ class _BodyState extends State<_Body> { } if (type == ViewType.json) { - return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(context)); + return JsonViewer(json.decode(body), + colorTheme: ColorTheme.of(context), searchController: widget.searchController); } return HighlightTextWidget( - text: body, - searchController: widget.searchController, - scrollController: widget.scrollController, - contextMenuBuilder: contextMenu); + text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu); } catch (e) { logger.e(e, stackTrace: StackTrace.current); } return HighlightTextWidget( - text: body, - searchController: widget.searchController, - scrollController: widget.scrollController, - contextMenuBuilder: contextMenu); + text: body, searchController: widget.searchController, contextMenuBuilder: contextMenu); }); } }