Skip to content

Commit 9339c42

Browse files
Find Method Picker (CodeEditApp#322)
### Description Added find method picker that changes the method by which text is found. Options include: - Contains - Match Word - Starts With - Ends With - Regular Expression ### Related Issues <!--- REQUIRED: Tag all related issues (e.g. * CodeEditApp#123) --> <!--- If this PR resolves the issue please specify (e.g. * closes CodeEditApp#123) --> <!--- If this PR addresses multiple issues, these issues must be related to one other --> * CodeEditApp/CodeEditTextView#1 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/8d68602a-13ec-4422-8c5c-518a5b0b9b67 <img width="782" alt="image" src="https://github.com/user-attachments/assets/ae1984f1-d5f0-4ec2-a706-5bb553b17063" /> <img width="782" alt="image" src="https://github.com/user-attachments/assets/81bc1e77-7adf-4d9c-976c-d695d53a0f0e" /> <img width="493" alt="image" src="https://github.com/user-attachments/assets/01bea694-6ba8-43fb-9e73-1445f031548b" /> <img width="493" alt="image" src="https://github.com/user-attachments/assets/5877859c-817f-4c0c-9412-bd8f2f27ce34" /> --------- Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com>
1 parent deaca0e commit 9339c42

File tree

7 files changed

+535
-48
lines changed

7 files changed

+535
-48
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// FindMethod.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Austin Condiff on 5/2/25.
6+
//
7+
8+
enum FindMethod: CaseIterable {
9+
case contains
10+
case matchesWord
11+
case startsWith
12+
case endsWith
13+
case regularExpression
14+
15+
var displayName: String {
16+
switch self {
17+
case .contains:
18+
return "Contains"
19+
case .matchesWord:
20+
return "Matches Word"
21+
case .startsWith:
22+
return "Starts With"
23+
case .endsWith:
24+
return "Ends With"
25+
case .regularExpression:
26+
return "Regular Expression"
27+
}
28+
}
29+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//
2+
// FindMethodPicker.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Austin Condiff on 5/2/25.
6+
//
7+
8+
import SwiftUI
9+
10+
/// A SwiftUI view that provides a method picker for the find panel.
11+
///
12+
/// The `FindMethodPicker` view is responsible for:
13+
/// - Displaying a dropdown menu to switch between different find methods
14+
/// - Managing the selected find method
15+
/// - Providing a visual indicator for the current method
16+
/// - Adapting its appearance based on the control's active state
17+
/// - Handling method selection
18+
struct FindMethodPicker: NSViewRepresentable {
19+
@Binding var method: FindMethod
20+
@Environment(\.controlActiveState) var activeState
21+
var condensed: Bool = false
22+
23+
private func createPopupButton(context: Context) -> NSPopUpButton {
24+
let popup = NSPopUpButton(frame: .zero, pullsDown: false)
25+
popup.bezelStyle = .regularSquare
26+
popup.isBordered = false
27+
popup.controlSize = .small
28+
popup.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small))
29+
popup.autoenablesItems = false
30+
popup.setContentHuggingPriority(.defaultHigh, for: .horizontal)
31+
popup.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
32+
popup.title = method.displayName
33+
if condensed {
34+
popup.isTransparent = true
35+
popup.alphaValue = 0
36+
}
37+
return popup
38+
}
39+
40+
private func createIconLabel() -> NSImageView {
41+
let imageView = NSImageView()
42+
let symbolName = method == .contains
43+
? "line.horizontal.3.decrease.circle"
44+
: "line.horizontal.3.decrease.circle.fill"
45+
imageView.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
46+
.withSymbolConfiguration(.init(pointSize: 14, weight: .regular))
47+
imageView.contentTintColor = method == .contains
48+
? (activeState == .inactive ? .tertiaryLabelColor : .labelColor)
49+
: (activeState == .inactive ? .tertiaryLabelColor : .controlAccentColor)
50+
return imageView
51+
}
52+
53+
private func createChevronLabel() -> NSImageView {
54+
let imageView = NSImageView()
55+
imageView.image = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil)?
56+
.withSymbolConfiguration(.init(pointSize: 8, weight: .black))
57+
imageView.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor
58+
return imageView
59+
}
60+
61+
private func createMenu(context: Context) -> NSMenu {
62+
let menu = NSMenu()
63+
64+
// Add method items
65+
FindMethod.allCases.forEach { method in
66+
let item = NSMenuItem(
67+
title: method.displayName,
68+
action: #selector(Coordinator.methodSelected(_:)),
69+
keyEquivalent: ""
70+
)
71+
item.target = context.coordinator
72+
item.tag = FindMethod.allCases.firstIndex(of: method) ?? 0
73+
item.state = method == self.method ? .on : .off
74+
menu.addItem(item)
75+
}
76+
77+
// Add separator before regular expression
78+
menu.insertItem(.separator(), at: 4)
79+
80+
return menu
81+
}
82+
83+
private func setupConstraints(
84+
container: NSView,
85+
popup: NSPopUpButton,
86+
iconLabel: NSImageView? = nil,
87+
chevronLabel: NSImageView? = nil
88+
) {
89+
popup.translatesAutoresizingMaskIntoConstraints = false
90+
iconLabel?.translatesAutoresizingMaskIntoConstraints = false
91+
chevronLabel?.translatesAutoresizingMaskIntoConstraints = false
92+
93+
var constraints: [NSLayoutConstraint] = []
94+
95+
if condensed {
96+
constraints += [
97+
popup.leadingAnchor.constraint(equalTo: container.leadingAnchor),
98+
popup.trailingAnchor.constraint(equalTo: container.trailingAnchor),
99+
popup.topAnchor.constraint(equalTo: container.topAnchor),
100+
popup.bottomAnchor.constraint(equalTo: container.bottomAnchor),
101+
popup.widthAnchor.constraint(equalToConstant: 36),
102+
popup.heightAnchor.constraint(equalToConstant: 20)
103+
]
104+
} else {
105+
constraints += [
106+
popup.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
107+
popup.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
108+
popup.topAnchor.constraint(equalTo: container.topAnchor),
109+
popup.bottomAnchor.constraint(equalTo: container.bottomAnchor)
110+
]
111+
}
112+
113+
if let iconLabel = iconLabel {
114+
constraints += [
115+
iconLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8),
116+
iconLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
117+
iconLabel.widthAnchor.constraint(equalToConstant: 14),
118+
iconLabel.heightAnchor.constraint(equalToConstant: 14)
119+
]
120+
}
121+
122+
if let chevronLabel = chevronLabel {
123+
constraints += [
124+
chevronLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -6),
125+
chevronLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
126+
chevronLabel.widthAnchor.constraint(equalToConstant: 8),
127+
chevronLabel.heightAnchor.constraint(equalToConstant: 8)
128+
]
129+
}
130+
131+
NSLayoutConstraint.activate(constraints)
132+
}
133+
134+
func makeNSView(context: Context) -> NSView {
135+
let container = NSView()
136+
container.wantsLayer = true
137+
138+
let popup = createPopupButton(context: context)
139+
popup.menu = createMenu(context: context)
140+
popup.selectItem(at: FindMethod.allCases.firstIndex(of: method) ?? 0)
141+
142+
container.addSubview(popup)
143+
144+
if condensed {
145+
let iconLabel = createIconLabel()
146+
let chevronLabel = createChevronLabel()
147+
container.addSubview(iconLabel)
148+
container.addSubview(chevronLabel)
149+
setupConstraints(container: container, popup: popup, iconLabel: iconLabel, chevronLabel: chevronLabel)
150+
} else {
151+
setupConstraints(container: container, popup: popup)
152+
}
153+
154+
return container
155+
}
156+
157+
func updateNSView(_ container: NSView, context: Context) {
158+
guard let popup = container.subviews.first as? NSPopUpButton else { return }
159+
160+
// Update selection, title, and color
161+
popup.selectItem(at: FindMethod.allCases.firstIndex(of: method) ?? 0)
162+
popup.title = method.displayName
163+
popup.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .labelColor
164+
if condensed {
165+
popup.isTransparent = true
166+
popup.alphaValue = 0
167+
} else {
168+
popup.isTransparent = false
169+
popup.alphaValue = 1
170+
}
171+
172+
// Update menu items state
173+
popup.menu?.items.forEach { item in
174+
let index = item.tag
175+
if index < FindMethod.allCases.count {
176+
item.state = FindMethod.allCases[index] == method ? .on : .off
177+
}
178+
}
179+
180+
// Update icon and chevron colors
181+
if condensed {
182+
if let iconLabel = container.subviews[1] as? NSImageView {
183+
let symbolName = method == .contains
184+
? "line.horizontal.3.decrease.circle"
185+
: "line.horizontal.3.decrease.circle.fill"
186+
iconLabel.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
187+
.withSymbolConfiguration(.init(pointSize: 14, weight: .regular))
188+
iconLabel.contentTintColor = method == .contains
189+
? (activeState == .inactive ? .tertiaryLabelColor : .labelColor)
190+
: (activeState == .inactive ? .tertiaryLabelColor : .controlAccentColor)
191+
}
192+
if let chevronLabel = container.subviews[2] as? NSImageView {
193+
chevronLabel.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor
194+
}
195+
}
196+
}
197+
198+
func makeCoordinator() -> Coordinator {
199+
Coordinator(method: $method)
200+
}
201+
202+
var body: some View {
203+
self.fixedSize()
204+
}
205+
206+
class Coordinator: NSObject {
207+
@Binding var method: FindMethod
208+
209+
init(method: Binding<FindMethod>) {
210+
self._method = method
211+
}
212+
213+
@objc func methodSelected(_ sender: NSMenuItem) {
214+
method = FindMethod.allCases[sender.tag]
215+
}
216+
}
217+
}
218+
219+
#Preview("Find Method Picker") {
220+
FindMethodPicker(method: .constant(.contains))
221+
.padding()
222+
}

Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ struct FindSearchField: View {
9494
.frame(width: 30, height: 20)
9595
})
9696
.toggleStyle(.icon)
97+
Divider()
98+
FindMethodPicker(method: $viewModel.findMethod, condensed: condensed)
9799
},
98100
helperText: helperText,
99101
clearable: true

Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,52 @@ extension FindPanelViewModel {
2020
}
2121

2222
// Set case sensitivity based on matchCase property
23-
let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive]
24-
let escapedQuery = NSRegularExpression.escapedPattern(for: findText)
23+
var findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive]
2524

26-
guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else {
25+
// Add multiline options for regular expressions
26+
if findMethod == .regularExpression {
27+
findOptions.insert(.dotMatchesLineSeparators)
28+
findOptions.insert(.anchorsMatchLines)
29+
}
30+
31+
let pattern: String
32+
33+
switch findMethod {
34+
case .contains:
35+
// Simple substring match, escape special characters
36+
pattern = NSRegularExpression.escapedPattern(for: findText)
37+
38+
case .matchesWord:
39+
// Match whole words only using word boundaries
40+
pattern = "\\b" + NSRegularExpression.escapedPattern(for: findText) + "\\b"
41+
42+
case .startsWith:
43+
// Match at the start of a line or after a word boundary
44+
pattern = "(?:^|\\b)" + NSRegularExpression.escapedPattern(for: findText)
45+
46+
case .endsWith:
47+
// Match at the end of a line or before a word boundary
48+
pattern = NSRegularExpression.escapedPattern(for: findText) + "(?:$|\\b)"
49+
50+
case .regularExpression:
51+
// Use the pattern directly without additional escaping
52+
pattern = findText
53+
}
54+
55+
guard let regex = try? NSRegularExpression(pattern: pattern, options: findOptions) else {
2756
self.findMatches = []
28-
self.currentFindMatchIndex = 0
57+
self.currentFindMatchIndex = nil
2958
return
3059
}
3160

3261
let text = target.textView.string
33-
let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count))
62+
let range = target.textView.documentRange
63+
let matches = regex.matches(in: text, range: range).filter { !$0.range.isEmpty }
3464

3565
self.findMatches = matches.map(\.range)
3666

3767
// Find the nearest match to the current cursor position
38-
currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0
68+
currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches)
3969

4070
// Only add emphasis layers if the find panel is focused
4171
if isFocused {

Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ class FindPanelViewModel: ObservableObject {
2424
self.target?.findPanelModeDidChange(to: mode)
2525
}
2626
}
27+
@Published var findMethod: FindMethod = .contains {
28+
didSet {
29+
if !findText.isEmpty {
30+
find()
31+
}
32+
}
33+
}
2734

2835
@Published var isFocused: Bool = false
2936

0 commit comments

Comments
 (0)