diff --git a/Demo/iOS/Pulse_Demo_iOSApp.swift b/Demo/iOS/Pulse_Demo_iOSApp.swift index ffabc9db..b8b9085f 100644 --- a/Demo/iOS/Pulse_Demo_iOSApp.swift +++ b/Demo/iOS/Pulse_Demo_iOSApp.swift @@ -6,13 +6,15 @@ import SwiftUI import Pulse import PulseUI +#warning("TODO: update") @main struct Pulse_Demo_iOSApp: App { var body: some Scene { // let _ = testProxy() WindowGroup { NavigationView { - ConsoleView(store: .demo) + _SearchView() +// ConsoleView(store: .demo) } } } diff --git a/Sources/PulseUI/Features/Search/ConsoleSearchView.swift b/Sources/PulseUI/Features/Search/ConsoleSearchView.swift index 7d623806..cdd9ea2a 100644 --- a/Sources/PulseUI/Features/Search/ConsoleSearchView.swift +++ b/Sources/PulseUI/Features/Search/ConsoleSearchView.swift @@ -7,6 +7,19 @@ import Pulse import CoreData import Combine +#warning("TODO: remove public") + +public struct _SearchView: View { + public init() {} + + public var body: some View { + if #available(iOS 15, *) { + ConsoleSearchView(viewModel: .init(entities: try! LoggerStore.mock.allMessages())) + } + } +} + +// TODO: use custom search bar? @available(iOS 15, tvOS 15, *) struct ConsoleSearchView: View { @ObservedObject var viewModel: ConsoleSearchViewModel @@ -20,7 +33,7 @@ struct ConsoleSearchView: View { // TODO: when open body, start with a search term immediatelly let occurences = Array(result.occurences.enumerated()) ForEach(occurences.prefix(3), id: \.offset) { item in - NavigationLink(destination: makeDestination(for: item.element.kind, entity: result.entity)) { + NavigationLink(destination: makeDestination(for: item.element, entity: result.entity)) { makeCell(for: item.element) } } @@ -38,23 +51,22 @@ struct ConsoleSearchView: View { // TODO: add occurence IDs instead of indices func makeCell(for occurence: ConsoleSearchOccurence) -> some View { - NavigationLink(destination: Text("Placeholder")) { - // TODO: handle errors - let attr = try! AttributedString(occurence.occurrence, including: \.uiKit) - VStack(alignment: .leading, spacing: 4) { - Text(occurence.kind.title + " (Line: \(occurence.line):\(occurence.range.lowerBound))") - .font(ConsoleConstants.fontTitle) - .foregroundColor(.secondary) - Text(attr) - .lineLimit(3) - } + // TODO: handle errors + let attr = try! AttributedString(occurence.occurrence, including: \.uiKit) + return VStack(alignment: .leading, spacing: 4) { + Text(occurence.kind.title + " (Line: \(occurence.line):\(occurence.range.lowerBound))") + .font(ConsoleConstants.fontTitle) + .foregroundColor(.secondary) + Text(attr) + .lineLimit(3) } } - func makeDestination(for kind: ConsoleSearchOccurence.Kind, entity: NSManagedObject) -> some View { - switch kind { + func makeDestination(for occurence: ConsoleSearchOccurence, entity: NSManagedObject) -> some View { + switch occurence.kind { case .responseBody: return NetworkInspectorResponseBodyView(viewModel: .init(task: entity as! NetworkTaskEntity)) + .environment(\.textViewSearchContext, occurence.searchContext) } } } @@ -80,6 +92,11 @@ final class ConsoleSearchViewModel: ObservableObject { $searchText.dropFirst().sink { [weak self] in self?.search($0) }.store(in: &cancellables) + + #warning("TEPM") + DispatchQueue.main.async { + self.searchText = "Nuke" + } } // TODO: perform in background @@ -110,7 +127,7 @@ final class ConsoleSearchViewModel: ObservableObject { var occurences: [ConsoleSearchOccurence] = [] if let responseBody = task.responseBody?.data, let string = String(data: responseBody, encoding: .utf8) { let matches = regex.matches(in: string) - for match in matches { + for (index, match) in matches.enumerated() { let range = match.fullMatch.startIndex.. let occurrence: NSAttributedString + let searchContext: RichTextViewModel.SearchContext } struct ConsoleSearchResultViewModel: Identifiable { diff --git a/Sources/PulseUI/Views/RichTextView/RichTextView.swift b/Sources/PulseUI/Views/RichTextView/RichTextView.swift index bee2dd84..5c6c8f09 100644 --- a/Sources/PulseUI/Views/RichTextView/RichTextView.swift +++ b/Sources/PulseUI/Views/RichTextView/RichTextView.swift @@ -14,6 +14,7 @@ struct RichTextView: View { let viewModel: RichTextViewModel private var isTextViewBarItemsHidden = false + @Environment(\.textViewSearchContext) private var searchContext init(viewModel: RichTextViewModel) { self.viewModel = viewModel @@ -26,6 +27,12 @@ struct RichTextView: View { } var body: some View { + contents + .onAppear { viewModel.prepare(searchContext) } + } + + @ViewBuilder + private var contents: some View { if #available(iOS 15, *) { _RichTextView(viewModel: viewModel, isTextViewBarItemsHidden: isTextViewBarItemsHidden) } else { @@ -72,7 +79,7 @@ struct _RichTextView: View { WrappedTextView(viewModel: viewModel) .edgesIgnoringSafeArea([.bottom]) .overlay { - if isSearching { + if isSearching || !viewModel.matches.isEmpty { VStack { Spacer() HStack { @@ -306,3 +313,14 @@ struct RichTextView_Previews: PreviewProvider { } } #endif + +private struct TextViewSearchContextKey: EnvironmentKey { + static var defaultValue: RichTextViewModel.SearchContext? +} + +extension EnvironmentValues { + var textViewSearchContext: RichTextViewModel.SearchContext? { + get { self[TextViewSearchContextKey.self] } + set { self[TextViewSearchContextKey.self] = newValue } + } +} diff --git a/Sources/PulseUI/Views/RichTextView/RichTextViewModel.swift b/Sources/PulseUI/Views/RichTextView/RichTextViewModel.swift index 013d5f5b..2d984f43 100644 --- a/Sources/PulseUI/Views/RichTextView/RichTextViewModel.swift +++ b/Sources/PulseUI/Views/RichTextView/RichTextViewModel.swift @@ -56,6 +56,22 @@ final class RichTextViewModel: ObservableObject { }.store(in: &bag) } + func prepare(_ context: SearchContext?) { + guard let context = context else { return } + + // Not updated self.searchTerm because searchable doesn't like that + let matches = search(searchTerm: context.searchTerm, in: textStorage.string as NSString, options: context.options) + self.didUpdateMatches(matches) + if context.matchIndex < matches.count { + DispatchQueue.main.async { + self.textView?.layoutManager.allowsNonContiguousLayout = false // Remove this workaround + UIView.performWithoutAnimation { + self.updateMatchIndex(context.matchIndex) + } + } + } + } + func display(_ text: NSAttributedString) { self.text = text self.matches.removeAll() @@ -127,7 +143,7 @@ final class RichTextViewModel: ObservableObject { updateMatchIndex(selectedMatchIndex - 1 < 0 ? matches.count - 1 : selectedMatchIndex - 1) } - private func updateMatchIndex(_ newIndex: Int) { + func updateMatchIndex(_ newIndex: Int) { let previousIndex = selectedMatchIndex selectedMatchIndex = newIndex didUpdateCurrentSelectedMatch(previousMatch: previousIndex) @@ -137,9 +153,20 @@ final class RichTextViewModel: ObservableObject { guard !matches.isEmpty else { return } // Scroll to visible range + // Make sure it's somewhere in the middle (find newlines) var range = matches[selectedMatchIndex] - if range.length + 50 < textStorage.length { - range.length += 50 + var index = range.upperBound + var newlines = 0 + let string = textStorage.string as NSString + while index < textStorage.length { + if let character = UnicodeScalar(string.character(at: index)).map(Character.init), character.isNewline { + newlines += 1 + range.length += index - range.upperBound + if newlines == 8 { + break + } + } + index += 1 } if let textView = textView { textView.scrollRangeToVisible(range) @@ -167,6 +194,12 @@ final class RichTextViewModel: ObservableObject { .foregroundColor: UXColor.white ], range: range) } + + struct SearchContext { + let searchTerm: String + let options: StringSearchOptions + let matchIndex: Int + } } private func search(searchTerm: String, in string: NSString, options: StringSearchOptions) -> [NSRange] {