diff --git a/Sources/CodeEditSourceEditor/Find/FindMethod.swift b/Sources/CodeEditSourceEditor/Find/FindMethod.swift new file mode 100644 index 000000000..1abe7e14e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindMethod.swift @@ -0,0 +1,29 @@ +// +// FindMethod.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 5/2/25. +// + +enum FindMethod: CaseIterable { + case contains + case matchesWord + case startsWith + case endsWith + case regularExpression + + var displayName: String { + switch self { + case .contains: + return "Contains" + case .matchesWord: + return "Matches Word" + case .startsWith: + return "Starts With" + case .endsWith: + return "Ends With" + case .regularExpression: + return "Regular Expression" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindMethodPicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindMethodPicker.swift new file mode 100644 index 000000000..191d94ddc --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindMethodPicker.swift @@ -0,0 +1,222 @@ +// +// FindMethodPicker.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 5/2/25. +// + +import SwiftUI + +/// A SwiftUI view that provides a method picker for the find panel. +/// +/// The `FindMethodPicker` view is responsible for: +/// - Displaying a dropdown menu to switch between different find methods +/// - Managing the selected find method +/// - Providing a visual indicator for the current method +/// - Adapting its appearance based on the control's active state +/// - Handling method selection +struct FindMethodPicker: NSViewRepresentable { + @Binding var method: FindMethod + @Environment(\.controlActiveState) var activeState + var condensed: Bool = false + + private func createPopupButton(context: Context) -> NSPopUpButton { + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.bezelStyle = .regularSquare + popup.isBordered = false + popup.controlSize = .small + popup.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) + popup.autoenablesItems = false + popup.setContentHuggingPriority(.defaultHigh, for: .horizontal) + popup.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + popup.title = method.displayName + if condensed { + popup.isTransparent = true + popup.alphaValue = 0 + } + return popup + } + + private func createIconLabel() -> NSImageView { + let imageView = NSImageView() + let symbolName = method == .contains + ? "line.horizontal.3.decrease.circle" + : "line.horizontal.3.decrease.circle.fill" + imageView.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 14, weight: .regular)) + imageView.contentTintColor = method == .contains + ? (activeState == .inactive ? .tertiaryLabelColor : .labelColor) + : (activeState == .inactive ? .tertiaryLabelColor : .controlAccentColor) + return imageView + } + + private func createChevronLabel() -> NSImageView { + let imageView = NSImageView() + imageView.image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 8, weight: .black)) + imageView.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor + return imageView + } + + private func createMenu(context: Context) -> NSMenu { + let menu = NSMenu() + + // Add method items + FindMethod.allCases.forEach { method in + let item = NSMenuItem( + title: method.displayName, + action: #selector(Coordinator.methodSelected(_:)), + keyEquivalent: "" + ) + item.target = context.coordinator + item.tag = FindMethod.allCases.firstIndex(of: method) ?? 0 + item.state = method == self.method ? .on : .off + menu.addItem(item) + } + + // Add separator before regular expression + menu.insertItem(.separator(), at: 4) + + return menu + } + + private func setupConstraints( + container: NSView, + popup: NSPopUpButton, + iconLabel: NSImageView? = nil, + chevronLabel: NSImageView? = nil + ) { + popup.translatesAutoresizingMaskIntoConstraints = false + iconLabel?.translatesAutoresizingMaskIntoConstraints = false + chevronLabel?.translatesAutoresizingMaskIntoConstraints = false + + var constraints: [NSLayoutConstraint] = [] + + if condensed { + constraints += [ + popup.leadingAnchor.constraint(equalTo: container.leadingAnchor), + popup.trailingAnchor.constraint(equalTo: container.trailingAnchor), + popup.topAnchor.constraint(equalTo: container.topAnchor), + popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), + popup.widthAnchor.constraint(equalToConstant: 36), + popup.heightAnchor.constraint(equalToConstant: 20) + ] + } else { + constraints += [ + popup.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + popup.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), + popup.topAnchor.constraint(equalTo: container.topAnchor), + popup.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ] + } + + if let iconLabel = iconLabel { + constraints += [ + iconLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), + iconLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + iconLabel.widthAnchor.constraint(equalToConstant: 14), + iconLabel.heightAnchor.constraint(equalToConstant: 14) + ] + } + + if let chevronLabel = chevronLabel { + constraints += [ + chevronLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -6), + chevronLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor), + chevronLabel.widthAnchor.constraint(equalToConstant: 8), + chevronLabel.heightAnchor.constraint(equalToConstant: 8) + ] + } + + NSLayoutConstraint.activate(constraints) + } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + let popup = createPopupButton(context: context) + popup.menu = createMenu(context: context) + popup.selectItem(at: FindMethod.allCases.firstIndex(of: method) ?? 0) + + container.addSubview(popup) + + if condensed { + let iconLabel = createIconLabel() + let chevronLabel = createChevronLabel() + container.addSubview(iconLabel) + container.addSubview(chevronLabel) + setupConstraints(container: container, popup: popup, iconLabel: iconLabel, chevronLabel: chevronLabel) + } else { + setupConstraints(container: container, popup: popup) + } + + return container + } + + func updateNSView(_ container: NSView, context: Context) { + guard let popup = container.subviews.first as? NSPopUpButton else { return } + + // Update selection, title, and color + popup.selectItem(at: FindMethod.allCases.firstIndex(of: method) ?? 0) + popup.title = method.displayName + popup.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .labelColor + if condensed { + popup.isTransparent = true + popup.alphaValue = 0 + } else { + popup.isTransparent = false + popup.alphaValue = 1 + } + + // Update menu items state + popup.menu?.items.forEach { item in + let index = item.tag + if index < FindMethod.allCases.count { + item.state = FindMethod.allCases[index] == method ? .on : .off + } + } + + // Update icon and chevron colors + if condensed { + if let iconLabel = container.subviews[1] as? NSImageView { + let symbolName = method == .contains + ? "line.horizontal.3.decrease.circle" + : "line.horizontal.3.decrease.circle.fill" + iconLabel.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 14, weight: .regular)) + iconLabel.contentTintColor = method == .contains + ? (activeState == .inactive ? .tertiaryLabelColor : .labelColor) + : (activeState == .inactive ? .tertiaryLabelColor : .controlAccentColor) + } + if let chevronLabel = container.subviews[2] as? NSImageView { + chevronLabel.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(method: $method) + } + + var body: some View { + self.fixedSize() + } + + class Coordinator: NSObject { + @Binding var method: FindMethod + + init(method: Binding) { + self._method = method + } + + @objc func methodSelected(_ sender: NSMenuItem) { + method = FindMethod.allCases[sender.tag] + } + } +} + +#Preview("Find Method Picker") { + FindMethodPicker(method: .constant(.contains)) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index 0d81ad81f..12d19fcdf 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -94,6 +94,8 @@ struct FindSearchField: View { .frame(width: 30, height: 20) }) .toggleStyle(.icon) + Divider() + FindMethodPicker(method: $viewModel.findMethod, condensed: condensed) }, helperText: helperText, clearable: true diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index ea75fff2c..ded2f09fa 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -20,22 +20,52 @@ extension FindPanelViewModel { } // Set case sensitivity based on matchCase property - let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: findText) + var findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { + // Add multiline options for regular expressions + if findMethod == .regularExpression { + findOptions.insert(.dotMatchesLineSeparators) + findOptions.insert(.anchorsMatchLines) + } + + let pattern: String + + switch findMethod { + case .contains: + // Simple substring match, escape special characters + pattern = NSRegularExpression.escapedPattern(for: findText) + + case .matchesWord: + // Match whole words only using word boundaries + pattern = "\\b" + NSRegularExpression.escapedPattern(for: findText) + "\\b" + + case .startsWith: + // Match at the start of a line or after a word boundary + pattern = "(?:^|\\b)" + NSRegularExpression.escapedPattern(for: findText) + + case .endsWith: + // Match at the end of a line or before a word boundary + pattern = NSRegularExpression.escapedPattern(for: findText) + "(?:$|\\b)" + + case .regularExpression: + // Use the pattern directly without additional escaping + pattern = findText + } + + guard let regex = try? NSRegularExpression(pattern: pattern, options: findOptions) else { self.findMatches = [] - self.currentFindMatchIndex = 0 + self.currentFindMatchIndex = nil return } let text = target.textView.string - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + let range = target.textView.documentRange + let matches = regex.matches(in: text, range: range).filter { !$0.range.isEmpty } self.findMatches = matches.map(\.range) // Find the nearest match to the current cursor position - currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) // Only add emphasis layers if the find panel is focused if isFocused { diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index d6975d112..6bbb02816 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -24,6 +24,13 @@ class FindPanelViewModel: ObservableObject { self.target?.findPanelModeDidChange(to: mode) } } + @Published var findMethod: FindMethod = .contains { + didSet { + if !findText.isEmpty { + find() + } + } + } @Published var isFocused: Bool = false diff --git a/Tests/CodeEditSourceEditorTests/FindPanelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelTests.swift new file mode 100644 index 000000000..4ddacbc4c --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/FindPanelTests.swift @@ -0,0 +1,239 @@ +import Testing +import AppKit +import CodeEditTextView +@testable import CodeEditSourceEditor + +@MainActor +struct FindPanelTests { + class MockPanelTarget: FindPanelTarget { + var emphasisManager: EmphasisManager? + var findPanelTargetView: NSView + var cursorPositions: [CursorPosition] = [] + var textView: TextView! + var findPanelWillShowCalled = false + var findPanelWillHideCalled = false + var findPanelModeDidChangeCalled = false + var lastMode: FindPanelMode? + + @MainActor init(text: String = "") { + findPanelTargetView = NSView() + textView = TextView(string: text) + } + + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) { } + func updateCursorPosition() { } + func findPanelWillShow(panelHeight: CGFloat) { + findPanelWillShowCalled = true + } + func findPanelWillHide(panelHeight: CGFloat) { + findPanelWillHideCalled = true + } + func findPanelModeDidChange(to mode: FindPanelMode) { + findPanelModeDidChangeCalled = true + lastMode = mode + } + } + + let target = MockPanelTarget() + let viewModel: FindPanelViewModel + let viewController: FindViewController + + init() { + viewController = FindViewController(target: target, childView: NSView()) + viewModel = viewController.viewModel + viewController.loadView() + } + + @Test func viewModelHeightUpdates() async throws { + let model = FindPanelViewModel(target: MockPanelTarget()) + model.mode = .find + #expect(model.panelHeight == 28) + + model.mode = .replace + #expect(model.panelHeight == 54) + } + + @Test func findPanelShowsOnCommandF() async throws { + // Show find panel + viewController.showFindPanel() + + // Verify panel is shown + #expect(viewModel.isShowingFindPanel == true) + #expect(target.findPanelWillShowCalled == true) + + // Hide find panel + viewController.hideFindPanel() + + // Verify panel is hidden + #expect(viewModel.isShowingFindPanel == false) + #expect(target.findPanelWillHideCalled == true) + } + + @Test func replaceFieldShowsWhenReplaceModeSelected() async throws { + // Switch to replace mode + viewModel.mode = .replace + + // Verify mode change + #expect(viewModel.mode == .replace) + #expect(target.findPanelModeDidChangeCalled == true) + #expect(target.lastMode == .replace) + #expect(viewModel.panelHeight == 54) // Height should be larger in replace mode + + // Switch back to find mode + viewModel.mode = .find + + // Verify mode change + #expect(viewModel.mode == .find) + #expect(viewModel.panelHeight == 28) // Height should be smaller in find mode + } + + @Test func wrapAroundEnabled() async throws { + target.textView.string = "test1\ntest2\ntest3" + viewModel.findText = "test" + viewModel.wrapAround = true + + // Perform initial find + viewModel.find() + #expect(viewModel.findMatches.count == 3) + + // Move to last match + viewModel.currentFindMatchIndex = 2 + + // Move to next (should wrap to first) + viewModel.moveToNextMatch() + #expect(viewModel.currentFindMatchIndex == 0) + + // Move to previous (should wrap to last) + viewModel.moveToPreviousMatch() + #expect(viewModel.currentFindMatchIndex == 2) + } + + @Test func wrapAroundDisabled() async throws { + target.textView.string = "test1\ntest2\ntest3" + viewModel.findText = "test" + viewModel.wrapAround = false + + // Perform initial find + viewModel.find() + #expect(viewModel.findMatches.count == 3) + + // Move to last match + viewModel.currentFindMatchIndex = 2 + + // Move to next (should stay at last) + viewModel.moveToNextMatch() + #expect(viewModel.currentFindMatchIndex == 2) + + // Move to first match + viewModel.currentFindMatchIndex = 0 + + // Move to previous (should stay at first) + viewModel.moveToPreviousMatch() + #expect(viewModel.currentFindMatchIndex == 0) + } + + @Test func findMatches() async throws { + target.textView.string = "test1\ntest2\ntest3" + viewModel.findText = "test" + + viewModel.find() + + #expect(viewModel.findMatches.count == 3) + #expect(viewModel.findMatches[0].location == 0) + #expect(viewModel.findMatches[1].location == 6) + #expect(viewModel.findMatches[2].location == 12) + } + + @Test func noMatchesFound() async throws { + target.textView.string = "test1\ntest2\ntest3" + viewModel.findText = "nonexistent" + + viewModel.find() + + #expect(viewModel.findMatches.isEmpty) + #expect(viewModel.currentFindMatchIndex == nil) + } + + @Test func matchCaseToggle() async throws { + target.textView.string = "Test1\ntest2\nTEST3" + + // Test case-sensitive + viewModel.matchCase = true + viewModel.findText = "Test" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test case-insensitive + viewModel.matchCase = false + viewModel.find() + #expect(viewModel.findMatches.count == 3) + } + + @Test func findMethodPickerOptions() async throws { + target.textView.string = "test1 test2 test3" + + // Test contains + viewModel.findMethod = .contains + viewModel.findText = "test" + viewModel.find() + #expect(viewModel.findMatches.count == 3) + + // Test matchesWord + viewModel.findMethod = .matchesWord + viewModel.findText = "test1" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test startsWith + viewModel.findMethod = .startsWith + viewModel.findText = "test" + viewModel.find() + #expect(viewModel.findMatches.count == 3) + + // Test endsWith + viewModel.findMethod = .endsWith + viewModel.findText = "3" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test regularExpression + viewModel.findMethod = .regularExpression + viewModel.findText = "test\\d" + viewModel.find() + #expect(viewModel.findMatches.count == 3) + } + + @Test func findMethodPickerOptionsWithComplexText() async throws { + target.textView.string = "test1 test2 test3\nprefix_test test_suffix\nword_test_word" + + // Test contains with partial matches + viewModel.findMethod = .contains + viewModel.findText = "test" + viewModel.find() + #expect(viewModel.findMatches.count == 6) + + // Test matchesWord with word boundaries + viewModel.findMethod = .matchesWord + viewModel.findText = "test1" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test startsWith with prefixes + viewModel.findMethod = .startsWith + viewModel.findText = "prefix" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test endsWith with suffixes + viewModel.findMethod = .endsWith + viewModel.findText = "suffix" + viewModel.find() + #expect(viewModel.findMatches.count == 1) + + // Test regularExpression with complex pattern + viewModel.findMethod = .regularExpression + viewModel.findText = "test\\d" + viewModel.find() + #expect(viewModel.findMatches.count == 3) + } +} diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift deleted file mode 100644 index ba2eb1530..000000000 --- a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// FindPanelViewModelTests.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/25/25. -// - -import Testing -import AppKit -import CodeEditTextView -@testable import CodeEditSourceEditor - -@MainActor -struct FindPanelViewModelTests { - class MockPanelTarget: FindPanelTarget { - var emphasisManager: EmphasisManager? - var text: String = "" - var findPanelTargetView: NSView - var cursorPositions: [CursorPosition] = [] - var textView: TextView! - - @MainActor init() { - findPanelTargetView = NSView() - textView = TextView(string: text) - } - - func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) { } - func updateCursorPosition() { } - func findPanelWillShow(panelHeight: CGFloat) { } - func findPanelWillHide(panelHeight: CGFloat) { } - func findPanelModeDidChange(to mode: FindPanelMode) { } - } - - @Test func viewModelHeightUpdates() async throws { - let model = FindPanelViewModel(target: MockPanelTarget()) - model.mode = .find - #expect(model.panelHeight == 28) - - model.mode = .replace - #expect(model.panelHeight == 54) - } -}