From 8d9dbd1e920d316ff125b0558cd15afbbe8176f5 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 10 Apr 2025 18:20:07 -0500 Subject: [PATCH 01/37] Added replace and match case toggle UI. --- .../xcshareddata/swiftpm/Package.resolved | 13 +- Package.swift | 8 +- .../Find/PanelView/FindModePicker.swift | 141 ++++++++++++ .../Find/PanelView/FindPanel.swift | 8 +- .../Find/PanelView/FindPanelView.swift | 209 +++++++++++++----- .../Find/PanelView/FindPanelViewModel.swift | 24 ++ 6 files changed, 347 insertions(+), 56 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 70b1e3e5a..e5bbcee35 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,13 +9,22 @@ "version" : "0.1.20" } }, + { + "identity" : "codeeditsymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", + "state" : { + "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", + "version" : "0.2.3" + } + }, { "identity" : "codeedittextview", "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "02202a8d925dc902f18626e953b3447e320253d1", - "version" : "0.8.1" + "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", + "version" : "0.8.2" } }, { diff --git a/Package.swift b/Package.swift index c0d9431e2..2ecee3bc3 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,11 @@ let package = Package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.20" ), + // CodeEditSymbols + .package( + url: "https://github.com/CodeEditApp/CodeEditSymbols.git", + exact: "0.2.3" + ), // SwiftLint .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", @@ -42,7 +47,8 @@ let package = Package( dependencies: [ "CodeEditTextView", "CodeEditLanguages", - "TextFormation" + "TextFormation", + "CodeEditSymbols" ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift new file mode 100644 index 000000000..e191e34ff --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -0,0 +1,141 @@ +// +// FindModePicker.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/10/25. +// + +import SwiftUI + +struct FindModePicker: NSViewRepresentable { + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool + @Environment(\.controlActiveState) var activeState + let onToggleWrapAround: () -> Void + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + // Create the magnifying glass button + let button = NSButton(frame: .zero) + button.bezelStyle = .regularSquare + button.isBordered = false + button.controlSize = .small + button.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) + button.imagePosition = .imageOnly + button.target = context.coordinator + button.action = #selector(Coordinator.openMenu(_:)) + + // Create the popup button + 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 + + // Calculate the required width + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + // Create menu + let menu = NSMenu() + + // Add mode items + FindPanelMode.allCases.forEach { mode in + let item = NSMenuItem(title: mode.displayName, action: #selector(Coordinator.modeSelected(_:)), keyEquivalent: "") + item.target = context.coordinator + item.tag = mode == .find ? 0 : 1 + menu.addItem(item) + } + + // Add separator + menu.addItem(.separator()) + + // Add wrap around item + let wrapItem = NSMenuItem(title: "Wrap Around", action: #selector(Coordinator.toggleWrapAround(_:)), keyEquivalent: "") + wrapItem.target = context.coordinator + wrapItem.state = wrapAround ? .on : .off + menu.addItem(wrapItem) + + popup.menu = menu + popup.selectItem(at: mode == .find ? 0 : 1) + + // Add subviews + container.addSubview(button) + container.addSubview(popup) + + // Set up constraints + button.translatesAutoresizingMaskIntoConstraints = false + popup.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + button.centerYAnchor.constraint(equalTo: container.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 16), + button.heightAnchor.constraint(equalToConstant: 20), + + popup.leadingAnchor.constraint(equalTo: button.trailingAnchor), + popup.trailingAnchor.constraint(equalTo: container.trailingAnchor), + popup.topAnchor.constraint(equalTo: container.topAnchor), + popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), + popup.widthAnchor.constraint(equalToConstant: totalWidth) + ]) + + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let popup = nsView.subviews.last as? NSPopUpButton { + popup.selectItem(at: mode == .find ? 0 : 1) + if let wrapItem = popup.menu?.items.last { + wrapItem.state = wrapAround ? .on : .off + } + } + + if let button = nsView.subviews.first as? NSButton { + button.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + var body: some View { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + return self.frame(width: totalWidth) + } + + class Coordinator: NSObject { + let parent: FindModePicker + + init(_ parent: FindModePicker) { + self.parent = parent + } + + @objc func openMenu(_ sender: NSButton) { + if let popup = sender.superview?.subviews.last as? NSPopUpButton { + popup.performClick(nil) + } + } + + @objc func modeSelected(_ sender: NSMenuItem) { + parent.mode = sender.tag == 0 ? .find : .replace + } + + @objc func toggleWrapAround(_ sender: NSMenuItem) { + parent.onToggleWrapAround() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index 86506018e..fbcd6603a 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -11,7 +11,13 @@ import Combine // NSView wrapper for using SwiftUI view in AppKit final class FindPanel: NSView { - static let height: CGFloat = 28 + /// The height of the find panel. + static var height: CGFloat { + if let findPanel = NSApp.windows.first(where: { $0.contentView is FindPanel })?.contentView as? FindPanel { + return findPanel.viewModel.mode == .replace ? 56 : 28 + } + return 28 + } weak var findDelegate: FindPanelDelegate? private var hostingView: NSHostingView! diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d18b33cc5..315ef2696 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -7,6 +7,7 @@ import SwiftUI import AppKit +import CodeEditSymbols struct FindPanelView: View { @Environment(\.controlActiveState) var activeState @@ -14,66 +15,170 @@ struct FindPanelView: View { @FocusState private var isFocused: Bool var body: some View { - HStack(spacing: 5) { - PanelTextField( - "Search...", - text: $viewModel.findText, - leadingAccessories: { - Image(systemName: "magnifyingglass") - .padding(.leading, 8) - .foregroundStyle(activeState == .inactive ? .tertiary : .secondary) - .font(.system(size: 12)) - .frame(width: 16, height: 20) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true - ) - .focused($isFocused) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() + VStack(spacing: 5) { + HStack(spacing: 5) { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround, + onToggleWrapAround: viewModel.toggleWrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + viewModel.findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + viewModel.findModePickerWidth = newWidth + } + }) + Divider() + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color(nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .focused($isFocused) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.prevButtonClicked) { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + } + .onChange(of: isFocused) { newValue in + viewModel.setFocus(newValue) + } + .onSubmit { + viewModel.onSubmit() + } + HStack(spacing: 4) { + ControlGroup { + Button(action: viewModel.prevButtonClicked) { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button(action: viewModel.nextButtonClicked) { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button(action: viewModel.onDismiss) { + Text("Done") .padding(.horizontal, 5) } - .disabled(viewModel.matchCount == 0) + .buttonStyle(PanelButtonStyle()) } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button(action: viewModel.onDismiss) { - Text("Done") - .padding(.horizontal, 5) + .background(GeometryReader { geometry in + Color.clear.onAppear { + viewModel.findControlsWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + viewModel.findControlsWidth = newWidth + } + }) + } + .padding(.horizontal, 5) + if viewModel.mode == .replace { + HStack(spacing: 5) { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + HStack(spacing: 0) { + Image(systemName: "pencil") + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: viewModel.findModePickerWidth, alignment: .leading) + Divider() + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) + } + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() + } + } + .onChange(of: isFocused) { newValue in + viewModel.setFocus(newValue) + } + .onSubmit { + viewModel.onSubmit() + } + HStack(spacing: 4) { + ControlGroup { + Button(action: viewModel.prevButtonClicked) { + Text("Replace") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button(action: viewModel.nextButtonClicked) { + Text("All") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + } } - .buttonStyle(PanelButtonStyle()) + .padding(.horizontal, 5) } } - .padding(.horizontal, 5) - .frame(height: FindPanel.height) + .frame(height: viewModel.mode == .replace ? FindPanel.height * 2 : FindPanel.height) .background(.bar) } } + +private struct FindModePickerWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index e8435f7a8..964925e6e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -8,10 +8,30 @@ import SwiftUI import Combine +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} + class FindPanelViewModel: ObservableObject { @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find + @Published var wrapAround: Bool = false @Published var matchCount: Int = 0 @Published var isFocused: Bool = false + @Published var findModePickerWidth: CGFloat = 0 + @Published var findControlsWidth: CGFloat = 0 + @Published var matchCase: Bool = false private weak var delegate: FindPanelDelegate? @@ -60,4 +80,8 @@ class FindPanelViewModel: ObservableObject { func nextButtonClicked() { delegate?.findPanelNextButtonClicked() } + + func toggleWrapAround() { + wrapAround.toggle() + } } From 34f606d112900ee003bb8e0a1f917cc3a109e500 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 11 Apr 2025 11:33:35 -0500 Subject: [PATCH 02/37] Insets are updating on mode change, still a WIP. --- .../TextViewController+FindPanelTarget.swift | 5 ++ .../TextViewController+StyleViews.swift | 4 +- .../Find/FindPanelDelegate.swift | 4 ++ .../Find/FindPanelTarget.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 27 ++++++++ .../Find/FindViewController+Toggle.swift | 14 ++-- .../Find/FindViewController.swift | 10 +++ .../Find/PanelView/FindPanel.swift | 12 ++-- .../Find/PanelView/FindPanelView.swift | 69 ++++++++++--------- .../Find/PanelView/FindPanelViewModel.swift | 23 ++++++- 10 files changed, 121 insertions(+), 48 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 36e4b45f7..d6ae4ee21 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -19,6 +19,11 @@ extension TextViewController: FindPanelTarget { gutterView.frame.origin.y = -scrollView.contentInsets.top } + func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { + scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + var emphasisManager: EmphasisManager? { textView?.emphasisManager } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 7fa819bbf..adccc1996 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -78,6 +78,8 @@ extension TextViewController { scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 - scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) + ? findViewController?.panelHeight ?? 0 + : 0 } } diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index 2fb440929..bfc3c1c1f 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -11,6 +11,10 @@ protocol FindPanelDelegate: AnyObject { func findPanelOnSubmit() func findPanelOnDismiss() func findPanelDidUpdate(_ searchText: String) + func findPanelDidUpdateMode(_ mode: FindPanelMode) + func findPanelDidUpdateWrapAround(_ wrapAround: Bool) + func findPanelDidUpdateMatchCase(_ matchCase: Bool) + func findPanelDidUpdateReplaceText(_ text: String) func findPanelPrevButtonClicked() func findPanelNextButtonClicked() func findPanelUpdateMatchCount(_ count: Int) diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index af0facadd..f1857ecb0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -18,4 +18,5 @@ protocol FindPanelTarget: AnyObject { func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 7b0ded2a2..de5ce1adb 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -9,6 +9,11 @@ import AppKit import CodeEditTextView extension FindViewController: FindPanelDelegate { + var findPanelMode: FindPanelMode { mode } + var findPanelWrapAround: Bool { wrapAround } + var findPanelMatchCase: Bool { matchCase } + var findPanelReplaceText: String { replaceText } + func findPanelOnSubmit() { findPanelNextButtonClicked() } @@ -61,6 +66,28 @@ extension FindViewController: FindPanelDelegate { find(text: text) } + func findPanelDidUpdateMode(_ mode: FindPanelMode) { + self.mode = mode + if isShowingFindPanel { + target?.findPanelModeDidChange(to: mode, panelHeight: panelHeight) + } + } + + func findPanelDidUpdateWrapAround(_ wrapAround: Bool) { + self.wrapAround = wrapAround + } + + func findPanelDidUpdateMatchCase(_ matchCase: Bool) { + self.matchCase = matchCase + if !findText.isEmpty { + performFind() + } + } + + func findPanelDidUpdateReplaceText(_ text: String) { + self.replaceText = text + } + func findPanelPrevButtonClicked() { guard let textViewController = target as? TextViewController, let emphasisManager = target?.emphasisManager else { return } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 99645ce08..2886bfc2e 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -22,18 +22,24 @@ extension FindViewController { return } + if mode == .replace { + mode = .find + findPanel.updateMode(mode) + } + isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - FindPanel.height + findPanelVerticalConstraint.constant = resolvedTopPadding - panelHeight + view.layoutSubtreeIfNeeded() // Perform the animation conditionalAnimated(animated) { // SwiftUI breaks things here, and refuses to return the correct `findPanel.fittingSize` so we // are forced to use a constant number. - target?.findPanelWillShow(panelHeight: FindPanel.height) + target?.findPanelWillShow(panelHeight: panelHeight) setFindPanelConstraintShow() } onComplete: { } @@ -54,7 +60,7 @@ extension FindViewController { findPanel?.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: FindPanel.height) + target?.findPanelWillHide(panelHeight: panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true @@ -113,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (FindPanel.height * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 4d9172c92..1e2d2f05d 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -26,8 +26,13 @@ final class FindViewController: NSViewController { var findPanel: FindPanel! var findMatches: [NSRange] = [] + // TODO: we might make this nil if no current match so we can disable the match button in the find panel var currentFindMatchIndex: Int = 0 var findText: String = "" + var replaceText: String = "" + var matchCase: Bool = false + var wrapAround: Bool = true + var mode: FindPanelMode = .find var findPanelVerticalConstraint: NSLayoutConstraint! var isShowingFindPanel: Bool = false @@ -38,6 +43,11 @@ final class FindViewController: NSViewController { (topPadding ?? view.safeAreaInsets.top) } + /// The height of the find panel. + var panelHeight: CGFloat { + return self.mode == .replace ? 56 : 28 + } + init(target: FindPanelTarget, childView: NSView) { self.target = target self.childView = childView diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index fbcd6603a..1d9dd8b78 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -11,14 +11,6 @@ import Combine // NSView wrapper for using SwiftUI view in AppKit final class FindPanel: NSView { - /// The height of the find panel. - static var height: CGFloat { - if let findPanel = NSApp.windows.first(where: { $0.contentView is FindPanel })?.contentView as? FindPanel { - return findPanel.viewModel.mode == .replace ? 56 : 28 - } - return 28 - } - weak var findDelegate: FindPanelDelegate? private var hostingView: NSHostingView! private var viewModel: FindPanelViewModel! @@ -118,6 +110,10 @@ final class FindPanel: NSView { viewModel.updateMatchCount(count) } + func updateMode(_ mode: FindPanelMode) { + viewModel.mode = mode + } + // MARK: - Search Text Management func updateSearchText(_ text: String) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 315ef2696..a187d79f5 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -61,15 +61,6 @@ struct FindPanelView: View { ) .controlSize(.small) .focused($isFocused) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } .onChange(of: isFocused) { newValue in viewModel.setFocus(newValue) } @@ -126,43 +117,31 @@ struct FindPanelView: View { .frame(width: viewModel.findModePickerWidth, alignment: .leading) Divider() }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", clearable: true ) .controlSize(.small) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() - } + // TODO: Handle replace text field focus and submit HStack(spacing: 4) { ControlGroup { - Button(action: viewModel.prevButtonClicked) { + Button(action: { + // TODO: Replace action + }) { Text("Replace") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } - .disabled(viewModel.matchCount == 0) + // TODO: disable if there is not an active match + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { + Button(action: { + // TODO: Replace all action + }) { Text("All") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } - .disabled(viewModel.matchCount == 0) + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) .fixedSize() @@ -171,8 +150,30 @@ struct FindPanelView: View { .padding(.horizontal, 5) } } - .frame(height: viewModel.mode == .replace ? FindPanel.height * 2 : FindPanel.height) + .frame(height: viewModel.panelHeight) .background(.bar) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) + } + .onChange(of: viewModel.replaceText) { newValue in + viewModel.onReplaceTextChange(newValue) + } + .onChange(of: viewModel.mode) { newMode in + viewModel.onModeChange(newMode) + } + .onChange(of: viewModel.wrapAround) { newValue in + viewModel.onWrapAroundChange(newValue) + } + .onChange(of: viewModel.matchCase) { newValue in + viewModel.onMatchCaseChange(newValue) + } + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() + } + } + } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index 964925e6e..759e1af9b 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -26,13 +26,17 @@ class FindPanelViewModel: ObservableObject { @Published var findText: String = "" @Published var replaceText: String = "" @Published var mode: FindPanelMode = .find - @Published var wrapAround: Bool = false + @Published var wrapAround: Bool = true @Published var matchCount: Int = 0 @Published var isFocused: Bool = false @Published var findModePickerWidth: CGFloat = 0 @Published var findControlsWidth: CGFloat = 0 @Published var matchCase: Bool = false + var panelHeight: CGFloat { + return mode == .replace ? 56 : 28 + } + private weak var delegate: FindPanelDelegate? init(delegate: FindPanelDelegate?) { @@ -49,6 +53,22 @@ class FindPanelViewModel: ObservableObject { delegate?.findPanelDidUpdate(text) } + func onReplaceTextChange(_ text: String) { + delegate?.findPanelDidUpdateReplaceText(text) + } + + func onModeChange(_ mode: FindPanelMode) { + delegate?.findPanelDidUpdateMode(mode) + } + + func onWrapAroundChange(_ wrapAround: Bool) { + delegate?.findPanelDidUpdateWrapAround(wrapAround) + } + + func onMatchCaseChange(_ matchCase: Bool) { + delegate?.findPanelDidUpdateMatchCase(matchCase) + } + func onSubmit() { delegate?.findPanelOnSubmit() } @@ -83,5 +103,6 @@ class FindPanelViewModel: ObservableObject { func toggleWrapAround() { wrapAround.toggle() + delegate?.findPanelDidUpdateWrapAround(wrapAround) } } From 397428b33e75a2a41a82c52c2e3b062c73012e02 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 09:59:58 -0500 Subject: [PATCH 03/37] Implemented wrap around setting logic. Now when disabled, we do not loop find matches. --- ...FindViewController+FindPanelDelegate.swift | 117 ++++++++---------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index de5ce1adb..4a6bd4eb0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -88,54 +88,69 @@ extension FindViewController: FindPanelDelegate { self.replaceText = text } + private func flashCurrentMatch(emphasisManager: EmphasisManager, textViewController: TextViewController) { + let newActiveRange = findMatches[currentFindMatchIndex] + emphasisManager.removeEmphases(for: EmphasisGroup.find) + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + selectInDocument: true + ), + for: EmphasisGroup.find + ) + } + func findPanelPrevButtonClicked() { guard let textViewController = target as? TextViewController, let emphasisManager = target?.emphasisManager else { return } // Check if there are any matches if findMatches.isEmpty { + NSSound.beep() + BezelNotification.show( + symbolName: "arrow.up.to.line", + over: textViewController.textView + ) return } - // Update to previous match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // Show bezel notification if we cycled from first to last match - if oldIndex == 0 && currentFindMatchIndex == findMatches.count - 1 { + // Check if we're at the first match and wrapAround is false + if !wrapAround && currentFindMatchIndex == 0 { + NSSound.beep() BezelNotification.show( - symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", + symbolName: "arrow.up.to.line", over: textViewController.textView ) + if textViewController.textView.window?.firstResponder === textViewController.textView { + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) + return + } + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + return } + // Update to previous match∂ + currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count + // If the text view has focus, show a flash animation for the current match if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) return } - // Create updated emphases with new active state + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + } + + private func updateEmphasesForCurrentMatch(emphasisManager: EmphasisManager, flash: Bool = false) { + // Create updated emphases with current match emphasized let updatedEmphases = findMatches.enumerated().map { index, range in Emphasis( range: range, style: .standard, - flash: false, + flash: flash, inactive: index != currentFindMatchIndex, selectInDocument: index == currentFindMatchIndex ) @@ -151,7 +166,6 @@ extension FindViewController: FindPanelDelegate { // Check if there are any matches if findMatches.isEmpty { - // Show "no matches" bezel notification and play beep NSSound.beep() BezelNotification.show( symbolName: "arrow.down.to.line", @@ -160,52 +174,31 @@ extension FindViewController: FindPanelDelegate { return } - // Update to next match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // Show bezel notification if we cycled from last to first match - if oldIndex == findMatches.count - 1 && currentFindMatchIndex == 0 { + // Check if we're at the last match and wrapAround is false + if !wrapAround && currentFindMatchIndex == findMatches.count - 1 { + NSSound.beep() BezelNotification.show( - symbolName: "arrow.triangle.capsulepath", + symbolName: "arrow.down.to.line", over: textViewController.textView ) + if textViewController.textView.window?.firstResponder === textViewController.textView { + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) + return + } + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + return } + // Update to next match + currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count + // If the text view has focus, show a flash animation for the current match if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) return } - // Create updated emphases with new active state - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) } func findPanelUpdateMatchCount(_ count: Int) { From 4a852a5b63cc19d11b8f32371aa0e2e704a1f8af Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 10:13:07 -0500 Subject: [PATCH 04/37] Fixed SwiftLint issues --- .../Find/PanelView/FindModePicker.swift | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index e191e34ff..b7f068345 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -13,11 +13,7 @@ struct FindModePicker: NSViewRepresentable { @Environment(\.controlActiveState) var activeState let onToggleWrapAround: () -> Void - func makeNSView(context: Context) -> NSView { - let container = NSView() - container.wantsLayer = true - - // Create the magnifying glass button + private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) button.bezelStyle = .regularSquare button.isBordered = false @@ -26,29 +22,33 @@ struct FindModePicker: NSViewRepresentable { .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) button.imagePosition = .imageOnly button.target = context.coordinator + button.action = nil + button.sendAction(on: .leftMouseDown) + button.target = context.coordinator button.action = #selector(Coordinator.openMenu(_:)) + return button + } - // Create the popup button + 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 + return popup + } - // Calculate the required width - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) - let maxWidth = FindPanelMode.allCases.map { mode in - mode.displayName.size(withAttributes: [.font: font]).width - }.max() ?? 0 - let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing - - // Create menu + private func createMenu(context: Context) -> NSMenu { let menu = NSMenu() // Add mode items FindPanelMode.allCases.forEach { mode in - let item = NSMenuItem(title: mode.displayName, action: #selector(Coordinator.modeSelected(_:)), keyEquivalent: "") + let item = NSMenuItem( + title: mode.displayName, + action: #selector(Coordinator.modeSelected(_:)), + keyEquivalent: "" + ) item.target = context.coordinator item.tag = mode == .find ? 0 : 1 menu.addItem(item) @@ -58,19 +58,19 @@ struct FindModePicker: NSViewRepresentable { menu.addItem(.separator()) // Add wrap around item - let wrapItem = NSMenuItem(title: "Wrap Around", action: #selector(Coordinator.toggleWrapAround(_:)), keyEquivalent: "") + let wrapItem = NSMenuItem( + title: "Wrap Around", + action: #selector(Coordinator.toggleWrapAround(_:)), + keyEquivalent: "" + ) wrapItem.target = context.coordinator wrapItem.state = wrapAround ? .on : .off menu.addItem(wrapItem) - popup.menu = menu - popup.selectItem(at: mode == .find ? 0 : 1) - - // Add subviews - container.addSubview(button) - container.addSubview(popup) + return menu + } - // Set up constraints + private func setupConstraints(container: NSView, button: NSButton, popup: NSPopUpButton, totalWidth: CGFloat) { button.translatesAutoresizingMaskIntoConstraints = false popup.translatesAutoresizingMaskIntoConstraints = false @@ -86,6 +86,30 @@ struct FindModePicker: NSViewRepresentable { popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), popup.widthAnchor.constraint(equalToConstant: totalWidth) ]) + } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + let button = createSymbolButton(context: context) + let popup = createPopupButton(context: context) + + // Calculate the required width + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + popup.menu = createMenu(context: context) + popup.selectItem(at: mode == .find ? 0 : 1) + + // Add subviews + container.addSubview(button) + container.addSubview(popup) + + setupConstraints(container: container, button: button, popup: popup, totalWidth: totalWidth) return container } From d15603beed5d716d107631ad72e330472a82b7d1 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 10:18:17 -0500 Subject: [PATCH 05/37] More SwiftLint fixes --- .../Find/PanelView/FindPanelView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index a187d79f5..d2f8d269f 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -125,22 +125,22 @@ struct FindPanelView: View { ControlGroup { Button(action: { // TODO: Replace action - }) { + }, label: { Text("Replace") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } + }) // TODO: disable if there is not an active match .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) Button(action: { // TODO: Replace all action - }) { + }, label: { Text("All") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } + }) .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) From 97b468defd9c0ea22ae546e7a8ad190a0afb353d Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 11:13:55 -0500 Subject: [PATCH 06/37] We are retaining our find match emphases when the replace text field is in focus. --- .../Find/PanelView/FindModePicker.swift | 2 ++ .../Find/PanelView/FindPanelView.swift | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index b7f068345..c0993d603 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -12,6 +12,7 @@ struct FindModePicker: NSViewRepresentable { @Binding var wrapAround: Bool @Environment(\.controlActiveState) var activeState let onToggleWrapAround: () -> Void + let onModeChange: () -> Void private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) @@ -156,6 +157,7 @@ struct FindModePicker: NSViewRepresentable { @objc func modeSelected(_ sender: NSMenuItem) { parent.mode = sender.tag == 0 ? .find : .replace + parent.onModeChange() } @objc func toggleWrapAround(_ sender: NSMenuItem) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d2f8d269f..515f19688 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -12,7 +12,8 @@ import CodeEditSymbols struct FindPanelView: View { @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFocused: Bool + @FocusState private var isFindFieldFocused: Bool + @FocusState private var isReplaceFieldFocused: Bool var body: some View { VStack(spacing: 5) { @@ -24,7 +25,13 @@ struct FindPanelView: View { FindModePicker( mode: $viewModel.mode, wrapAround: $viewModel.wrapAround, - onToggleWrapAround: viewModel.toggleWrapAround + onToggleWrapAround: viewModel.toggleWrapAround, + onModeChange: { + isFindFieldFocused = true + if let textField = NSApp.keyWindow?.firstResponder as? NSTextView { + textField.selectAll(nil) + } + } ) .background(GeometryReader { geometry in Color.clear.onAppear { @@ -60,9 +67,9 @@ struct FindPanelView: View { clearable: true ) .controlSize(.small) - .focused($isFocused) - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) + .focused($isFindFieldFocused) + .onChange(of: isFindFieldFocused) { newValue in + viewModel.setFocus(newValue || isReplaceFieldFocused) } .onSubmit { viewModel.onSubmit() @@ -120,7 +127,10 @@ struct FindPanelView: View { clearable: true ) .controlSize(.small) - // TODO: Handle replace text field focus and submit + .focused($isReplaceFieldFocused) + .onChange(of: isReplaceFieldFocused) { newValue in + viewModel.setFocus(newValue || isFindFieldFocused) + } HStack(spacing: 4) { ControlGroup { Button(action: { @@ -168,12 +178,11 @@ struct FindPanelView: View { viewModel.onMatchCaseChange(newValue) } .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue + isFindFieldFocused = newValue if !newValue { viewModel.removeEmphasis() } } - } } From 58d83910c69c4f4a6cce7af14f53b870d63e50ff Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 11:50:19 -0500 Subject: [PATCH 07/37] Enabled match case functionality so now the match case toggle in the find panel works. --- .../Find/FindViewController+FindPanelDelegate.swift | 3 ++- .../Find/FindViewController+Operations.swift | 11 +++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 4a6bd4eb0..b68a29878 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -81,6 +81,7 @@ extension FindViewController: FindPanelDelegate { self.matchCase = matchCase if !findText.isEmpty { performFind() + addEmphases() } } @@ -132,7 +133,7 @@ extension FindViewController: FindPanelDelegate { return } - // Update to previous match∂ + // Update to previous match currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count // If the text view has focus, show a flash animation for the current match diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index d67054f39..1b3906f2b 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -32,7 +32,8 @@ extension FindViewController { return } - let findOptions: NSRegularExpression.Options = smartCase(str: findText) ? [] : [.caseInsensitive] + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] let escapedQuery = NSRegularExpression.escapedPattern(for: findText) guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { @@ -52,7 +53,7 @@ extension FindViewController { currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 } - private func addEmphases() { + func addEmphases() { guard let target = target, let emphasisManager = target.emphasisManager else { return } @@ -116,10 +117,4 @@ extension FindViewController { // Only re-find the part of the file that changed upwards private func reFind() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the find text is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } } From 0b7a36c6f86b03138ddeed536652820efd86a1f8 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 12:55:44 -0500 Subject: [PATCH 08/37] Added replace current match functionality. --- .../Find/FindPanelDelegate.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 6 ++- .../Find/FindViewController+Operations.swift | 40 +++++++++++++++++++ .../Find/PanelView/FindPanelView.swift | 6 +-- .../Find/PanelView/FindPanelViewModel.swift | 4 ++ 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index bfc3c1c1f..65a46c454 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -17,6 +17,7 @@ protocol FindPanelDelegate: AnyObject { func findPanelDidUpdateReplaceText(_ text: String) func findPanelPrevButtonClicked() func findPanelNextButtonClicked() + func findPanelReplaceButtonClicked() func findPanelUpdateMatchCount(_ count: Int) func findPanelClearEmphasis() } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index b68a29878..74bf6427f 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -12,7 +12,6 @@ extension FindViewController: FindPanelDelegate { var findPanelMode: FindPanelMode { mode } var findPanelWrapAround: Bool { wrapAround } var findPanelMatchCase: Bool { matchCase } - var findPanelReplaceText: String { replaceText } func findPanelOnSubmit() { findPanelNextButtonClicked() @@ -202,6 +201,11 @@ extension FindViewController: FindPanelDelegate { updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) } + func findPanelReplaceButtonClicked() { + guard !findMatches.isEmpty else { return } + replaceCurrentMatch() + } + func findPanelUpdateMatchCount(_ count: Int) { findPanel.updateMatchCount(count) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 1b3906f2b..9f5104ad3 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -53,6 +53,46 @@ extension FindViewController { currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 } + func replaceCurrentMatch() { + guard let target = target, + !findMatches.isEmpty else { return } + + // Get the current match range + let currentMatchRange = findMatches[currentFindMatchIndex] + + // Set cursor positions to the match range + target.setCursorPositions([CursorPosition(range: currentMatchRange)]) + + // Replace the text using the cursor positions + if let textViewController = target as? TextViewController { + textViewController.textView.insertText(replaceText, replacementRange: currentMatchRange) + } + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - currentMatchRange.length + + // Update the current match index + if findMatches.isEmpty { + currentFindMatchIndex = 0 + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + } else { + // Update all match ranges after the current match + for index in (currentFindMatchIndex + 1).. Date: Wed, 16 Apr 2025 21:29:03 -0500 Subject: [PATCH 09/37] Added replace all logic. Adjusted panel text field coloring. --- .../CodeEditUI/PanelTextField.swift | 17 +++++-- .../Find/FindPanelDelegate.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 5 ++ .../Find/FindViewController+Operations.swift | 49 +++++++++++++++++-- .../Find/PanelView/FindPanelView.swift | 21 +++++--- .../Find/PanelView/FindPanelViewModel.swift | 4 ++ 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift index beefdd7d4..cda97dbca 100644 --- a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift @@ -66,16 +66,24 @@ struct PanelTextField: View Color(.textBackgroundColor) } else { if colorScheme == .light { - Color.black.opacity(0.06) + // TODO: if over sidebar 0.06 else 0.085 +// Color.black.opacity(0.06) + Color.black.opacity(0.085) } else { - Color.white.opacity(0.24) + // TODO: if over sidebar 0.24 else 0.06 +// Color.white.opacity(0.24) + Color.white.opacity(0.06) } } } else { if colorScheme == .light { - Color.clear + // TODO: if over sidebar 0.0 else 0.06 +// Color.clear + Color.black.opacity(0.06) } else { - Color.white.opacity(0.14) + // TODO: if over sidebar 0.14 else 0.045 +// Color.white.opacity(0.14) + Color.white.opacity(0.045) } } } @@ -98,6 +106,7 @@ struct PanelTextField: View Text(helperText) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } } if clearable == true { diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index 65a46c454..e7cdb8cd6 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -18,6 +18,7 @@ protocol FindPanelDelegate: AnyObject { func findPanelPrevButtonClicked() func findPanelNextButtonClicked() func findPanelReplaceButtonClicked() + func findPanelReplaceAllButtonClicked() func findPanelUpdateMatchCount(_ count: Int) func findPanelClearEmphasis() } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 74bf6427f..dd558f28b 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -206,6 +206,11 @@ extension FindViewController: FindPanelDelegate { replaceCurrentMatch() } + func findPanelReplaceAllButtonClicked() { + guard !findMatches.isEmpty else { return } + replaceAllMatches() + } + func findPanelUpdateMatchCount(_ count: Int) { findPanel.updateMatchCount(count) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 9f5104ad3..7d6eda560 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -81,9 +81,6 @@ extension FindViewController { findMatches[index].location += lengthDiff } - // Remove the current match from the array - findMatches.remove(at: currentFindMatchIndex) - // Keep the current index in bounds currentFindMatchIndex = min(currentFindMatchIndex, findMatches.count - 1) findPanel.findDelegate?.findPanelUpdateMatchCount(findMatches.count) @@ -93,6 +90,52 @@ extension FindViewController { addEmphases() } + func replaceAllMatches() { + guard let target = target, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { return } + + // Sort matches in reverse order to avoid range shifting issues + let sortedMatches = findMatches.sorted { $0.location > $1.location } + + // Begin undo grouping using CEUndoManager + if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { + ceUndoManager.beginUndoGrouping() + } + + // Replace each match + for matchRange in sortedMatches { + // Set cursor positions to the match range + target.setCursorPositions([CursorPosition(range: matchRange)]) + + // Replace the text using the cursor positions + textViewController.textView.insertText(replaceText, replacementRange: matchRange) + } + + // End undo grouping + if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { + ceUndoManager.endUndoGrouping() + } + + // Set cursor position to the end of the last replaced match + if let lastMatch = sortedMatches.first { + let endPosition = lastMatch.location + replaceText.utf16.count + let cursorRange = NSRange(location: endPosition, length: 0) + target.setCursorPositions([CursorPosition(range: cursorRange)]) + textViewController.textView.selectionManager.setSelectedRanges([cursorRange]) + textViewController.textView.scrollSelectionToVisible() + textViewController.textView.needsDisplay = true + } + + // Clear all matches since they've been replaced + findMatches = [] + currentFindMatchIndex = 0 + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + + // Update the emphases + addEmphases() + } + func addEmphases() { guard let target = target, let emphasisManager = target.emphasisManager else { return } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index b625d994d..edf115f9e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -16,7 +16,7 @@ struct FindPanelView: View { @FocusState private var isReplaceFieldFocused: Bool var body: some View { - VStack(spacing: 5) { + VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { PanelTextField( "Text", @@ -117,6 +117,7 @@ struct FindPanelView: View { leadingAccessories: { HStack(spacing: 0) { Image(systemName: "pencil") + .foregroundStyle(.secondary) .padding(.leading, 8) .padding(.trailing, 5) Text("With") @@ -135,20 +136,26 @@ struct FindPanelView: View { ControlGroup { Button(action: viewModel.replaceButtonClicked) { Text("Replace") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } // TODO: disable if there is not an active match - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: { - // TODO: Replace all action - }, label: { + Button(action: viewModel.replaceAllButtonClicked) { Text("All") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - }) + } .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index 602bc1577..fdc55b716 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -105,6 +105,10 @@ class FindPanelViewModel: ObservableObject { delegate?.findPanelReplaceButtonClicked() } + func replaceAllButtonClicked() { + delegate?.findPanelReplaceAllButtonClicked() + } + func toggleWrapAround() { wrapAround.toggle() delegate?.findPanelDidUpdateWrapAround(wrapAround) From a67b45170145db7c49570161ec2b6a47c1671df5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:22:01 -0500 Subject: [PATCH 10/37] Update TextViewController+StyleViews.swift --- .../Controller/TextViewController+StyleViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index bcf0a6869..a5aaa4acb 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -96,7 +96,7 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + let findInset = (findViewController?.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset From 3bd9c71788decd6a920cc7265a31d23dcf4a65d1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:01:33 -0500 Subject: [PATCH 11/37] Find and Replace Refactor (#311) ### Description @austincondiff has been building out the 'replace' functionality and realized the way we had structured the panel, controller, target, and model was extremely overcomplicating things. This PR is an attempt to fix that Changes: - Moves all 'business logic' into the `FindPanelViewModel` observable object. This includes clarified methods like `find`, `replace`, and `moveToNextMatch/moveToPreviousMatch`. All state has been moved to this object as well, out of a combination of both the SwiftUI view and the find view controller. - Removes the `FindPanelDelegate` type entirely. All that type was doing was passing method calls from the find panel to it's controller. Since all that logic is now in the shared view model, the controller & view can just call the necessary methods on the model. - Simplifies the `FindViewController` to only handle view/model setup and layout. - Changes the focus state variable to an enum instead of two `Bool`s. This fixes an issue where there was a moment of nothing being focused when switching between the find and replace text fields. - Removes the unnecessary `NSHostingView -> NSView -> SwiftUI View` structure, replacing it with an `NSHostingView` subclass `FindPanelHostingView` that hosts a `FindPanelView`. - Clarifies some view naming to reflect what each type does. - `FindPanel` -> `FindPanelHostingView` - `FindPanelView` search fields moved to: - `FindSearchField` - `ReplaceSearchField` ### Related Issues * #295 ### 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 --- .../TextViewController+Cursor.swift | 6 +- .../TextViewController+FindPanelTarget.swift | 11 +- .../TextViewController+LoadView.swift | 2 +- .../TextViewController+StyleViews.swift | 6 +- .../Find/FindPanelDelegate.swift | 24 -- .../Find/FindPanelMode.swift | 20 ++ .../Find/FindPanelTarget.swift | 7 +- ...FindViewController+FindPanelDelegate.swift | 221 -------------- .../Find/FindViewController+Operations.swift | 203 ------------- .../Find/FindViewController+Toggle.swift | 34 +-- .../Find/FindViewController.swift | 50 +--- .../Find/PanelView/FindModePicker.swift | 17 +- .../Find/PanelView/FindPanel.swift | 124 -------- .../Find/PanelView/FindPanelHostingView.swift | 60 ++++ .../Find/PanelView/FindPanelView.swift | 269 +++++++----------- .../Find/PanelView/FindPanelViewModel.swift | 116 -------- .../Find/PanelView/FindSearchField.swift | 64 +++++ .../Find/PanelView/ReplaceSearchField.swift | 35 +++ .../FindPanelViewModel+Emphasis.swift | 37 +++ .../ViewModel/FindPanelViewModel+Find.swift | 83 ++++++ .../ViewModel/FindPanelViewModel+Move.swift | 64 +++++ .../FindPanelViewModel+Replace.swift | 69 +++++ .../Find/ViewModel/FindPanelViewModel.swift | 99 +++++++ .../SupportingViews/PanelTextField.swift | 12 +- 24 files changed, 699 insertions(+), 934 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelMode.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index de2783f76..04af69ac7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -11,7 +11,7 @@ import AppKit extension TextViewController { /// Sets new cursor positions. /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. - public func setCursorPositions(_ positions: [CursorPosition]) { + public func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool = false) { if isPostingCursorNotification { return } var newSelectedRanges: [NSRange] = [] for position in positions { @@ -33,6 +33,10 @@ extension TextViewController { } } textView.selectionManager.setSelectedRanges(newSelectedRanges) + + if scrollToVisible { + textView.scrollSelectionToVisible() + } } /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 4e5e5782a..3401ea3cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -5,10 +5,14 @@ // Created by Khan Winter on 3/16/25. // -import Foundation +import AppKit import CodeEditTextView extension TextViewController: FindPanelTarget { + var findPanelTargetView: NSView { + textView + } + func findPanelWillShow(panelHeight: CGFloat) { updateContentInsets() } @@ -17,9 +21,8 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { - scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight - gutterView.frame.origin.y = -scrollView.contentInsets.top + func findPanelModeDidChange(to mode: FindPanelMode) { + updateContentInsets() } var emphasisManager: EmphasisManager? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 15c4f839a..806280d0f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -192,7 +192,7 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.findPanel.dismiss() + self.findViewController?.hideFindPanel() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index a5aaa4acb..e47a43315 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -96,7 +96,11 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 + let findInset: CGFloat = if findViewController?.viewModel.isShowingFindPanel ?? false { + findViewController?.viewModel.panelHeight ?? 0 + } else { + 0 + } scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift deleted file mode 100644 index e7cdb8cd6..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// FindPanelDelegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import Foundation - -protocol FindPanelDelegate: AnyObject { - func findPanelOnSubmit() - func findPanelOnDismiss() - func findPanelDidUpdate(_ searchText: String) - func findPanelDidUpdateMode(_ mode: FindPanelMode) - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) - func findPanelDidUpdateMatchCase(_ matchCase: Bool) - func findPanelDidUpdateReplaceText(_ text: String) - func findPanelPrevButtonClicked() - func findPanelNextButtonClicked() - func findPanelReplaceButtonClicked() - func findPanelReplaceAllButtonClicked() - func findPanelUpdateMatchCount(_ count: Int) - func findPanelClearEmphasis() -} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift new file mode 100644 index 000000000..f7bbf26bd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift @@ -0,0 +1,20 @@ +// +// FindPanelMode.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index f1857ecb0..90a286715 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -5,18 +5,19 @@ // Created by Khan Winter on 3/10/25. // -import Foundation +import AppKit import CodeEditTextView protocol FindPanelTarget: AnyObject { var emphasisManager: EmphasisManager? { get } var text: String { get } + var findPanelTargetView: NSView { get } var cursorPositions: [CursorPosition] { get } - func setCursorPositions(_ positions: [CursorPosition]) + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) func updateCursorPosition() func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift deleted file mode 100644 index dd558f28b..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// FindViewController+Delegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController: FindPanelDelegate { - var findPanelMode: FindPanelMode { mode } - var findPanelWrapAround: Bool { wrapAround } - var findPanelMatchCase: Bool { matchCase } - - func findPanelOnSubmit() { - findPanelNextButtonClicked() - } - - func findPanelOnDismiss() { - if isShowingFindPanel { - hideFindPanel() - // Ensure text view becomes first responder after hiding - if let textViewController = target as? TextViewController { - DispatchQueue.main.async { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) - } - } - } - } - - func findPanelDidUpdate(_ text: String) { - // Check if this update was triggered by a return key without shift - if let currentEvent = NSApp.currentEvent, - currentEvent.type == .keyDown, - currentEvent.keyCode == 36, // Return key - !currentEvent.modifierFlags.contains(.shift) { - return // Skip find for regular return key - } - - // Only perform find if we're focusing the text view - if let textViewController = target as? TextViewController, - textViewController.textView.window?.firstResponder === textViewController.textView { - // If the text view has focus, just clear visual emphases but keep matches in memory - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: EmphasisGroup.find), - let activeEmphasis = emphases.first(where: { !$0.inactive }) { - target?.emphasisManager?.addEmphasis( - Emphasis( - range: activeEmphasis.range, - style: .standard, - flash: false, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - return - } - - // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - find(text: text) - } - - func findPanelDidUpdateMode(_ mode: FindPanelMode) { - self.mode = mode - if isShowingFindPanel { - target?.findPanelModeDidChange(to: mode, panelHeight: panelHeight) - } - } - - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) { - self.wrapAround = wrapAround - } - - func findPanelDidUpdateMatchCase(_ matchCase: Bool) { - self.matchCase = matchCase - if !findText.isEmpty { - performFind() - addEmphases() - } - } - - func findPanelDidUpdateReplaceText(_ text: String) { - self.replaceText = text - } - - private func flashCurrentMatch(emphasisManager: EmphasisManager, textViewController: TextViewController) { - let newActiveRange = findMatches[currentFindMatchIndex] - emphasisManager.removeEmphases(for: EmphasisGroup.find) - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - - func findPanelPrevButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the first match and wrapAround is false - if !wrapAround && currentFindMatchIndex == 0 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to previous match - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - private func updateEmphasesForCurrentMatch(emphasisManager: EmphasisManager, flash: Bool = false) { - // Create updated emphases with current match emphasized - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: flash, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) - } - - func findPanelNextButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the last match and wrapAround is false - if !wrapAround && currentFindMatchIndex == findMatches.count - 1 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to next match - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - func findPanelReplaceButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceCurrentMatch() - } - - func findPanelReplaceAllButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceAllMatches() - } - - func findPanelUpdateMatchCount(_ count: Int) { - findPanel.updateMatchCount(count) - } - - func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift deleted file mode 100644 index 7d6eda560..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// FindViewController+Operations.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController { - func find(text: String) { - findText = text - performFind() - addEmphases() - } - - func performFind() { - // Don't find if target or emphasisManager isn't ready - guard let target = target else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Clear emphases and return if query is empty - if findText.isEmpty { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Set case sensitivity based on matchCase property - let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: findText) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - let text = target.text - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - - findMatches = matches.map { $0.range } - findPanel.findDelegate?.findPanelUpdateMatchCount(findMatches.count) - - // Find the nearest match to the current cursor position - currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 - } - - func replaceCurrentMatch() { - guard let target = target, - !findMatches.isEmpty else { return } - - // Get the current match range - let currentMatchRange = findMatches[currentFindMatchIndex] - - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: currentMatchRange)]) - - // Replace the text using the cursor positions - if let textViewController = target as? TextViewController { - textViewController.textView.insertText(replaceText, replacementRange: currentMatchRange) - } - - // Adjust the length of the replacement - let lengthDiff = replaceText.utf16.count - currentMatchRange.length - - // Update the current match index - if findMatches.isEmpty { - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - } else { - // Update all match ranges after the current match - for index in (currentFindMatchIndex + 1).. $1.location } - - // Begin undo grouping using CEUndoManager - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.beginUndoGrouping() - } - - // Replace each match - for matchRange in sortedMatches { - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: matchRange)]) - - // Replace the text using the cursor positions - textViewController.textView.insertText(replaceText, replacementRange: matchRange) - } - - // End undo grouping - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.endUndoGrouping() - } - - // Set cursor position to the end of the last replaced match - if let lastMatch = sortedMatches.first { - let endPosition = lastMatch.location + replaceText.utf16.count - let cursorRange = NSRange(location: endPosition, length: 0) - target.setCursorPositions([CursorPosition(range: cursorRange)]) - textViewController.textView.selectionManager.setSelectedRanges([cursorRange]) - textViewController.textView.scrollSelectionToVisible() - textViewController.textView.needsDisplay = true - } - - // Clear all matches since they've been replaced - findMatches = [] - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - - // Update the emphases - addEmphases() - } - - func addEmphases() { - guard let target = target, - let emphasisManager = target.emphasisManager else { return } - - // Clear existing emphases - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - // Create emphasis with the nearest match as active - let emphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Add all emphases - emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) - } - - private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = target?.cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = matchRanges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = matchRanges[mid].location - let diff = abs(midStart - start) - - // If it's an exact match, return immediately - if diff == 0 { - return mid - } - - // If this is the closest so far, update the best index - if diff < bestDiff { - bestDiff = diff - bestIndex = mid - } - - // Move left or right based on the cursor position - if midStart < start { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return bestIndex >= 0 ? bestIndex : nil - } - - // Only re-find the part of the file that changed upwards - private func reFind() { } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 2886bfc2e..bfea53c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -16,22 +16,21 @@ extension FindViewController { /// - Animates the find panel into position (resolvedTopPadding). /// - Makes the find panel the first responder. func showFindPanel(animated: Bool = true) { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { // If panel is already showing, just focus the text field - _ = findPanel?.becomeFirstResponder() + viewModel.isFocused = true return } - if mode == .replace { - mode = .find - findPanel.updateMode(mode) + if viewModel.mode == .replace { + viewModel.mode = .find } - isShowingFindPanel = true + viewModel.isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - panelHeight + findPanelVerticalConstraint.constant = resolvedTopPadding - viewModel.panelHeight view.layoutSubtreeIfNeeded() @@ -39,12 +38,12 @@ extension FindViewController { conditionalAnimated(animated) { // SwiftUI breaks things here, and refuses to return the correct `findPanel.fittingSize` so we // are forced to use a constant number. - target?.findPanelWillShow(panelHeight: panelHeight) + viewModel.target?.findPanelWillShow(panelHeight: viewModel.panelHeight) setFindPanelConstraintShow() } onComplete: { } - _ = findPanel?.becomeFirstResponder() - findPanel?.addEventMonitor() + viewModel.isFocused = true + findPanel.addEventMonitor() } /// Hide the find panel @@ -55,20 +54,21 @@ extension FindViewController { /// - Hides the find panel. /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { - isShowingFindPanel = false - _ = findPanel?.resignFirstResponder() - findPanel?.removeEventMonitor() + viewModel.isShowingFindPanel = false + _ = findPanel.resignFirstResponder() + findPanel.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: panelHeight) + viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true + self?.viewModel.isFocused = false } // Set first responder back to text view - if let textViewController = target as? TextViewController { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + if let target = viewModel.target { + _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } } @@ -119,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (panelHeight * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (viewModel.panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 1e2d2f05d..a9e2dd3b0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -10,69 +10,35 @@ import CodeEditTextView /// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { - weak var target: FindPanelTarget? + var viewModel: FindPanelViewModel /// The amount of padding from the top of the view to inset the find panel by. /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. var topPadding: CGFloat? { didSet { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { setFindPanelConstraintShow() } } } var childView: NSView - var findPanel: FindPanel! - var findMatches: [NSRange] = [] - - // TODO: we might make this nil if no current match so we can disable the match button in the find panel - var currentFindMatchIndex: Int = 0 - var findText: String = "" - var replaceText: String = "" - var matchCase: Bool = false - var wrapAround: Bool = true - var mode: FindPanelMode = .find + var findPanel: FindPanelHostingView var findPanelVerticalConstraint: NSLayoutConstraint! - var isShowingFindPanel: Bool = false - /// The 'real' top padding amount. /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. var resolvedTopPadding: CGFloat { (topPadding ?? view.safeAreaInsets.top) } - /// The height of the find panel. - var panelHeight: CGFloat { - return self.mode == .replace ? 56 : 28 - } - init(target: FindPanelTarget, childView: NSView) { - self.target = target + viewModel = FindPanelViewModel(target: target) self.childView = childView + findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) - self.findPanel = FindPanel(delegate: self, textView: target as? NSView) - - // Add notification observer for text changes - if let textViewController = target as? TextViewController { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: TextView.textDidChangeNotification, - object: textViewController.textView - ) - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func textDidChange() { - // Only update if we have find text - if !findText.isEmpty { - performFind() + viewModel.dismiss = { [weak self] in + self?.hideFindPanel() } } @@ -115,7 +81,7 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - if isShowingFindPanel { // Update constraints for initial state + if viewModel.isShowingFindPanel { // Update constraints for initial state findPanel.isHidden = false setFindPanelConstraintShow() } else { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index c0993d603..8245681aa 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -11,8 +11,6 @@ struct FindModePicker: NSViewRepresentable { @Binding var mode: FindPanelMode @Binding var wrapAround: Bool @Environment(\.controlActiveState) var activeState - let onToggleWrapAround: () -> Void - let onModeChange: () -> Void private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) @@ -129,7 +127,7 @@ struct FindModePicker: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(self) + Coordinator(mode: $mode, wrapAround: $wrapAround) } var body: some View { @@ -143,10 +141,12 @@ struct FindModePicker: NSViewRepresentable { } class Coordinator: NSObject { - let parent: FindModePicker + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool - init(_ parent: FindModePicker) { - self.parent = parent + init(mode: Binding, wrapAround: Binding) { + self._mode = mode + self._wrapAround = wrapAround } @objc func openMenu(_ sender: NSButton) { @@ -156,12 +156,11 @@ struct FindModePicker: NSViewRepresentable { } @objc func modeSelected(_ sender: NSMenuItem) { - parent.mode = sender.tag == 0 ? .find : .replace - parent.onModeChange() + mode = sender.tag == 0 ? .find : .replace } @objc func toggleWrapAround(_ sender: NSMenuItem) { - parent.onToggleWrapAround() + wrapAround.toggle() } } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift deleted file mode 100644 index 1d9dd8b78..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// FindPanel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import SwiftUI -import AppKit -import Combine - -// NSView wrapper for using SwiftUI view in AppKit -final class FindPanel: NSView { - weak var findDelegate: FindPanelDelegate? - private var hostingView: NSHostingView! - private var viewModel: FindPanelViewModel! - private weak var textView: NSView? - private var isViewReady = false - private var findQueryText: String = "" // Store search text at panel level - private var eventMonitor: Any? - - init(delegate: FindPanelDelegate?, textView: NSView?) { - self.findDelegate = delegate - self.textView = textView - super.init(frame: .zero) - - viewModel = FindPanelViewModel(delegate: findDelegate) - viewModel.findText = findQueryText // Initialize with stored value - hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) - hostingView.translatesAutoresizingMaskIntoConstraints = false - - // Make the NSHostingView transparent - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear - - // Make the FindPanel itself transparent - self.wantsLayer = true - self.layer?.backgroundColor = .clear - - addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - if !isViewReady && superview != nil { - isViewReady = true - viewModel.startObservingFindText() - } - } - - deinit { - removeEventMonitor() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var fittingSize: NSSize { - hostingView.fittingSize - } - - // MARK: - First Responder Management - - override func becomeFirstResponder() -> Bool { - viewModel.setFocus(true) - return true - } - - override func resignFirstResponder() -> Bool { - viewModel.setFocus(false) - return true - } - - // MARK: - Event Monitor Management - - func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in - if event.keyCode == 53 { // if esc pressed - self.dismiss() - return nil // do not play "beep" sound - } - return event - } - } - - func removeEventMonitor() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - // MARK: - Public Methods - - func dismiss() { - viewModel.onDismiss() - } - - func updateMatchCount(_ count: Int) { - viewModel.updateMatchCount(count) - } - - func updateMode(_ mode: FindPanelMode) { - viewModel.mode = mode - } - - // MARK: - Search Text Management - - func updateSearchText(_ text: String) { - findQueryText = text - viewModel.findText = text - findDelegate?.findPanelDidUpdate(text) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift new file mode 100644 index 000000000..a02e4f7c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -0,0 +1,60 @@ +// +// FindPanelHostingView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import SwiftUI +import AppKit +import Combine + +// NSView wrapper for using SwiftUI view in AppKit +final class FindPanelHostingView: NSHostingView { + private weak var viewModel: FindPanelViewModel? + + private var eventMonitor: Any? + + init(viewModel: FindPanelViewModel) { + self.viewModel = viewModel + super.init(rootView: FindPanelView(viewModel: viewModel)) + + self.translatesAutoresizingMaskIntoConstraints = false + + self.wantsLayer = true + self.layer?.backgroundColor = .clear + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @MainActor @preconcurrency required init(rootView: FindPanelView) { + super.init(rootView: rootView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + removeEventMonitor() + } + + // MARK: - Event Monitor Management + + func addEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + self.viewModel?.dismiss?() + return nil // do not play "beep" sound + } + return event + } + } + + func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index edf115f9e..7b8fb551e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -10,184 +10,133 @@ import AppKit import CodeEditSymbols struct FindPanelView: View { + enum FindPanelFocus: Equatable { + case find + case replace + } + @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFindFieldFocused: Bool - @FocusState private var isReplaceFieldFocused: Bool + @State private var findModePickerWidth: CGFloat = 1.0 + + @FocusState private var focus: FindPanelFocus? var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.findText, - leadingAccessories: { - FindModePicker( - mode: $viewModel.mode, - wrapAround: $viewModel.wrapAround, - onToggleWrapAround: viewModel.toggleWrapAround, - onModeChange: { - isFindFieldFocused = true - if let textField = NSApp.keyWindow?.firstResponder as? NSTextView { - textField.selectAll(nil) - } - } - ) - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findModePickerWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findModePickerWidth = newWidth - } - }) - Divider() - }, - trailingAccessories: { - Divider() - Toggle(isOn: $viewModel.matchCase, label: { - Image(systemName: "textformat") - .font(.system( - size: 11, - weight: viewModel.matchCase ? .bold : .medium - )) - .foregroundStyle( - Color(nsColor: viewModel.matchCase - ? .controlAccentColor - : .labelColor - ) - ) - .frame(width: 30, height: 20) - }) - .toggleStyle(.icon) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true - ) - .controlSize(.small) - .focused($isFindFieldFocused) - .onChange(of: isFindFieldFocused) { newValue in - viewModel.setFocus(newValue || isReplaceFieldFocused) - } - .onSubmit { - viewModel.onSubmit() - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.prevButtonClicked) { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button(action: viewModel.onDismiss) { - Text("Done") - .padding(.horizontal, 5) - } - .buttonStyle(PanelButtonStyle()) + HStack(spacing: 5) { + VStack(alignment: .leading, spacing: 4) { + FindSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) + if viewModel.mode == .replace { + ReplaceSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) } - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findControlsWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findControlsWidth = newWidth - } - }) } - .padding(.horizontal, 5) - if viewModel.mode == .replace { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.replaceText, - leadingAccessories: { - HStack(spacing: 0) { - Image(systemName: "pencil") - .foregroundStyle(.secondary) - .padding(.leading, 8) - .padding(.trailing, 5) - Text("With") - } - .frame(width: viewModel.findModePickerWidth, alignment: .leading) - Divider() - }, - clearable: true - ) - .controlSize(.small) - .focused($isReplaceFieldFocused) - .onChange(of: isReplaceFieldFocused) { newValue in - viewModel.setFocus(newValue || isFindFieldFocused) - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.replaceButtonClicked) { - Text("Replace") - .opacity( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 ? 0.33 : 1 - ) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - // TODO: disable if there is not an active match - .disabled( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 - ) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.replaceAllButtonClicked) { - Text("All") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - } + VStack(alignment: .leading, spacing: 4) { + doneNextControls + if viewModel.mode == .replace { + Spacer(minLength: 0) + replaceControls } - .padding(.horizontal, 5) } + .fixedSize() } + .padding(.horizontal, 5) .frame(height: viewModel.panelHeight) .background(.bar) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) + .onChange(of: focus) { newValue in + viewModel.isFocused = newValue != nil } - .onChange(of: viewModel.replaceText) { newValue in - viewModel.onReplaceTextChange(newValue) + .onChange(of: viewModel.findText) { _ in + viewModel.findTextDidChange() } - .onChange(of: viewModel.mode) { newMode in - viewModel.onModeChange(newMode) + .onChange(of: viewModel.wrapAround) { _ in + viewModel.find() } - .onChange(of: viewModel.wrapAround) { newValue in - viewModel.onWrapAroundChange(newValue) - } - .onChange(of: viewModel.matchCase) { newValue in - viewModel.onMatchCaseChange(newValue) + .onChange(of: viewModel.matchCase) { _ in + viewModel.find() } .onChange(of: viewModel.isFocused) { newValue in - isFindFieldFocused = newValue - if !newValue { - viewModel.removeEmphasis() + if newValue { + if focus == nil { + focus = .find + } + if !viewModel.findText.isEmpty { + // Restore emphases when focus is regained and we have search text + viewModel.addMatchEmphases(flashCurrent: false) + } + } else { + viewModel.clearMatchEmphases() + } + } + } + + @ViewBuilder private var doneNextControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.moveToPreviousMatch() + } label: { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button { + viewModel.moveToNextMatch() + } label: { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button { + viewModel.dismiss?() + } label: { + Text("Done") + .padding(.horizontal, 5) + } + .buttonStyle(PanelButtonStyle()) + } + } + + @ViewBuilder private var replaceControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace(all: false) + } label: { + Text("Replace") + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) + } + // TODO: disable if there is not an active match + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) + .frame(maxWidth: .infinity) + + Divider().overlay(Color(nsColor: .tertiaryLabelColor)) + + Button { + viewModel.replace(all: true) + } label: { + Text("All") + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + } + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .frame(maxWidth: .infinity) } + .controlGroupStyle(PanelControlGroupStyle()) } + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift deleted file mode 100644 index fdc55b716..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// FindPanelViewModel.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import SwiftUI -import Combine - -enum FindPanelMode: CaseIterable { - case find - case replace - - var displayName: String { - switch self { - case .find: - return "Find" - case .replace: - return "Replace" - } - } -} - -class FindPanelViewModel: ObservableObject { - @Published var findText: String = "" - @Published var replaceText: String = "" - @Published var mode: FindPanelMode = .find - @Published var wrapAround: Bool = true - @Published var matchCount: Int = 0 - @Published var isFocused: Bool = false - @Published var findModePickerWidth: CGFloat = 0 - @Published var findControlsWidth: CGFloat = 0 - @Published var matchCase: Bool = false - - var panelHeight: CGFloat { - return mode == .replace ? 56 : 28 - } - - private weak var delegate: FindPanelDelegate? - - init(delegate: FindPanelDelegate?) { - self.delegate = delegate - } - - func startObservingFindText() { - if !findText.isEmpty { - delegate?.findPanelDidUpdate(findText) - } - } - - func onFindTextChange(_ text: String) { - delegate?.findPanelDidUpdate(text) - } - - func onReplaceTextChange(_ text: String) { - delegate?.findPanelDidUpdateReplaceText(text) - } - - func onModeChange(_ mode: FindPanelMode) { - delegate?.findPanelDidUpdateMode(mode) - } - - func onWrapAroundChange(_ wrapAround: Bool) { - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } - - func onMatchCaseChange(_ matchCase: Bool) { - delegate?.findPanelDidUpdateMatchCase(matchCase) - } - - func onSubmit() { - delegate?.findPanelOnSubmit() - } - - func onDismiss() { - delegate?.findPanelOnDismiss() - } - - func setFocus(_ focused: Bool) { - isFocused = focused - if focused && !findText.isEmpty { - // Restore emphases when focus is regained and we have search text - delegate?.findPanelDidUpdate(findText) - } - } - - func updateMatchCount(_ count: Int) { - matchCount = count - } - - func removeEmphasis() { - delegate?.findPanelClearEmphasis() - } - - func prevButtonClicked() { - delegate?.findPanelPrevButtonClicked() - } - - func nextButtonClicked() { - delegate?.findPanelNextButtonClicked() - } - - func replaceButtonClicked() { - delegate?.findPanelReplaceButtonClicked() - } - - func replaceAllButtonClicked() { - delegate?.findPanelReplaceAllButtonClicked() - } - - func toggleWrapAround() { - wrapAround.toggle() - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift new file mode 100644 index 000000000..00a528ee2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -0,0 +1,64 @@ +// +// FindSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct FindSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + findModePickerWidth = newWidth + } + }) + .focusable(false) + Divider() + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color(nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .find) + .onSubmit { + viewModel.moveToNextMatch() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift new file mode 100644 index 000000000..9e40721c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -0,0 +1,35 @@ +// +// ReplaceSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct ReplaceSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + HStack(spacing: 0) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: findModePickerWidth, alignment: .leading) + Divider() + }, + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .replace) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift new file mode 100644 index 000000000..74d411e45 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -0,0 +1,37 @@ +// +// FindPanelViewModel+Emphasis.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import CodeEditTextView + +extension FindPanelViewModel { + func addMatchEmphases(flashCurrent: Bool) { + guard let target = target, let emphasisManager = target.emphasisManager else { + return + } + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphases = findMatches.enumerated().map { index, range in + Emphasis( + range: range, + style: .standard, + flash: flashCurrent && index == currentFindMatchIndex, + inactive: index != currentFindMatchIndex, + selectInDocument: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) + } + + func clearMatchEmphases() { + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift new file mode 100644 index 000000000..438df586a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -0,0 +1,83 @@ +// +// FindPanelViewModel+Find.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation + +extension FindPanelViewModel { + // MARK: - Find + + /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis + /// manager's emphases. + func find() { + // Don't find if target or emphasisManager isn't ready or the query is empty + guard let target = target, isFocused, !findText.isEmpty else { + updateMatches([]) + return + } + + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: findText) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { + updateMatches([]) + return + } + + let text = target.text + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + + updateMatches(matches.map(\.range)) + + // Find the nearest match to the current cursor position + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 + addMatchEmphases(flashCurrent: false) + } + + // MARK: - Get Nearest Emphasis Index + + private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = target?.cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = matchRanges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = matchRanges[mid].location + let diff = abs(midStart - start) + + // If it's an exact match, return immediately + if diff == 0 { + return mid + } + + // If this is the closest so far, update the best index + if diff < bestDiff { + bestDiff = diff + bestIndex = mid + } + + // Move left or right based on the cursor position + if midStart < start { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return bestIndex >= 0 ? bestIndex : nil + } + +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift new file mode 100644 index 000000000..66b0d6b2b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -0,0 +1,64 @@ +// +// FindPanelViewModel+Move.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import AppKit + +extension FindPanelViewModel { + func moveToNextMatch() { + moveMatch(forwards: true) + } + + func moveToPreviousMatch() { + moveMatch(forwards: false) + } + + private func moveMatch(forwards: Bool) { + guard let target = target else { return } + + guard !findMatches.isEmpty else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + // From here on out we want to emphasize the result no matter what + defer { addMatchEmphases(flashCurrent: isTargetFirstResponder) } + + guard let currentFindMatchIndex else { + self.currentFindMatchIndex = 0 + return + } + + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 + guard !isAtLimit || wrapAround else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + self.currentFindMatchIndex = if forwards { + (currentFindMatchIndex + 1) % findMatches.count + } else { + (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count + } + if isAtLimit { + showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + } + } + + private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { + if error { + NSSound.beep() + } + BezelNotification.show( + symbolName: error ? + forwards ? "arrow.up.to.line" : "arrow.down.to.line" + : forwards + ? "arrow.trianglehead.topright.capsulepath.clockwise" + : "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: targetView + ) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift new file mode 100644 index 000000000..278765b1f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -0,0 +1,69 @@ +// +// FindPanelViewModel+Replace.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation +import CodeEditTextView + +extension FindPanelViewModel { + /// Replace one or all ``findMatches`` with the contents of ``replaceText``. + /// - Parameter all: If true, replaces all matches instead of just the selected one. + func replace(all: Bool) { + guard let target = target, + let currentFindMatchIndex, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { + return + } + + if all { + textViewController.textView.undoManager?.beginUndoGrouping() + textViewController.textView.textStorage.beginEditing() + + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) + } + + textViewController.textView.textStorage.endEditing() + textViewController.textView.undoManager?.endUndoGrouping() + + if let lastMatch = sortedMatches.last { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) + } + + updateMatches([]) + } else { + replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) + updateMatches(findMatches) + } + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + /// Replace a single match in the text view, updating all other find matches with any length changes. + /// - Parameters: + /// - index: The index of the match to replace in the `matches` array. + /// - textView: The text view to replace characters in. + /// - matches: The array of matches to use and update. + private func replaceMatch(index: Int, textView: TextView, matches: inout [NSRange]) { + let range = matches[index] + // Set cursor positions to the match range + textView.replaceCharacters(in: range, with: replaceText) + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - range.length + + // Update all match ranges after the current match + for idx in matches.dropFirst(index + 1).indices { + matches[idx].location -= lengthDiff + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift new file mode 100644 index 000000000..19f62018b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -0,0 +1,99 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine +import CodeEditTextView + +class FindPanelViewModel: ObservableObject { + weak var target: FindPanelTarget? + var dismiss: (() -> Void)? + + @Published var findMatches: [NSRange] = [] + @Published var currentFindMatchIndex: Int? + @Published var isShowingFindPanel: Bool = false + + @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find { + didSet { + self.target?.findPanelModeDidChange(to: mode) + } + } + + @Published var isFocused: Bool = false + + @Published var matchCase: Bool = false + @Published var wrapAround: Bool = true + + /// The height of the find panel. + var panelHeight: CGFloat { + return mode == .replace ? 54 : 28 + } + + /// The number of current find matches. + var matchCount: Int { + findMatches.count + } + + var isTargetFirstResponder: Bool { + target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView + } + + init(target: FindPanelTarget) { + self.target = target + + // Add notification observer for text changes + if let textViewController = target as? TextViewController { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: TextView.textDidChangeNotification, + object: textViewController.textView + ) + } + } + + // MARK: - Update Matches + + func updateMatches(_ newMatches: [NSRange]) { + findMatches = newMatches + currentFindMatchIndex = newMatches.isEmpty ? nil : 0 + } + + // MARK: - Text Listeners + + /// Find target's text content changed, we need to re-search the contents and emphasize results. + @objc private func textDidChange() { + // Only update if we have find text + if !findText.isEmpty { + find() + } + } + + /// The contents of the find search field changed, trigger related events. + func findTextDidChange() { + // Check if this update was triggered by a return key without shift + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .keyDown, + currentEvent.keyCode == 36, // Return key + !currentEvent.modifierFlags.contains(.shift) { + return // Skip find for regular return key + } + + // If the textview is first responder, exit fast + if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { + // If the text view has focus, just clear visual emphases but keep our find matches + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + return + } + + // Clear existing emphases before performing new find + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + find() + } +} diff --git a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift index cda97dbca..9c66afc67 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift @@ -33,8 +33,6 @@ struct PanelTextField: View var onClear: (() -> Void) - var hasValue: Bool - init( _ label: String, text: Binding, @@ -43,8 +41,7 @@ struct PanelTextField: View @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, helperText: String? = nil, clearable: Bool? = false, - onClear: (() -> Void)? = {}, - hasValue: Bool? = false + onClear: (() -> Void)? = {} ) { self.label = label _text = text @@ -54,15 +51,14 @@ struct PanelTextField: View self.helperText = helperText ?? nil self.clearable = clearable ?? false self.onClear = onClear ?? {} - self.hasValue = hasValue ?? false } @ViewBuilder public func selectionBackground( _ isFocused: Bool = false ) -> some View { - if self.controlActive != .inactive || !text.isEmpty || hasValue { - if isFocused || !text.isEmpty || hasValue { + if self.controlActive != .inactive || !text.isEmpty { + if isFocused || !text.isEmpty { Color(.textBackgroundColor) } else { if colorScheme == .light { @@ -135,7 +131,7 @@ struct PanelTextField: View ) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .stroke(isFocused || !text.isEmpty ? .tertiary : .quaternary, lineWidth: 1.25) .clipShape(RoundedRectangle(cornerRadius: 6)) .disabled(true) .edgesIgnoringSafeArea(.all) From a9f134effb07fb8b7de5a4abddf1b41e91729400 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:16:02 -0500 Subject: [PATCH 12/37] Fix Fialing Test --- .../TextViewControllerTests.swift | 5 ++- .../FindPanelViewModelTests.swift | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) rename Tests/CodeEditSourceEditorTests/{ => Controller}/TextViewControllerTests.swift (99%) create mode 100644 Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift similarity index 99% rename from Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift rename to Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index 956a763d9..8088920ce 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -162,12 +162,13 @@ final class TextViewControllerTests: XCTestCase { controller.findViewController?.showFindPanel(animated: false) // Extra insets do not effect find panel's insets + let findModel = try XCTUnwrap(controller.findViewController) try assertInsetsEqual( scrollView.contentInsets, - NSEdgeInsets(top: 10 + FindPanel.height, left: 0, bottom: 10, right: 0) + NSEdgeInsets(top: 10 + findModel.viewModel.panelHeight, left: 0, bottom: 10, right: 0) ) XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) - XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - FindPanel.height) + XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - findModel.viewModel.panelHeight) } func test_editorOverScroll_ZeroCondition() throws { diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift new file mode 100644 index 000000000..8f98681bd --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift @@ -0,0 +1,40 @@ +// +// 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] = [] + + @MainActor init() { + findPanelTargetView = NSView() + } + + 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) + } +} From a55bd4b4130c53ca72806a79ffd9a38d42a4b53e Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 10 Apr 2025 18:20:07 -0500 Subject: [PATCH 13/37] Added replace and match case toggle UI. --- .../xcshareddata/swiftpm/Package.resolved | 9 + Package.swift | 8 +- .../Find/PanelView/FindModePicker.swift | 141 ++++++++++++ .../Find/PanelView/FindPanel.swift | 8 +- .../Find/PanelView/FindPanelView.swift | 209 +++++++++++++----- .../Find/PanelView/FindPanelViewModel.swift | 24 ++ 6 files changed, 345 insertions(+), 54 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a1eb3b548..3f475425b 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeeditsymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", + "state" : { + "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", + "version" : "0.2.3" + } + }, { "identity" : "codeedittextview", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a14085dcf..524d4b609 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,11 @@ let package = Package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.20" ), + // CodeEditSymbols + .package( + url: "https://github.com/CodeEditApp/CodeEditSymbols.git", + exact: "0.2.3" + ), // SwiftLint .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", @@ -43,7 +48,8 @@ let package = Package( dependencies: [ "CodeEditTextView", "CodeEditLanguages", - "TextFormation" + "TextFormation", + "CodeEditSymbols" ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift new file mode 100644 index 000000000..e191e34ff --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -0,0 +1,141 @@ +// +// FindModePicker.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/10/25. +// + +import SwiftUI + +struct FindModePicker: NSViewRepresentable { + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool + @Environment(\.controlActiveState) var activeState + let onToggleWrapAround: () -> Void + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + // Create the magnifying glass button + let button = NSButton(frame: .zero) + button.bezelStyle = .regularSquare + button.isBordered = false + button.controlSize = .small + button.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) + button.imagePosition = .imageOnly + button.target = context.coordinator + button.action = #selector(Coordinator.openMenu(_:)) + + // Create the popup button + 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 + + // Calculate the required width + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + // Create menu + let menu = NSMenu() + + // Add mode items + FindPanelMode.allCases.forEach { mode in + let item = NSMenuItem(title: mode.displayName, action: #selector(Coordinator.modeSelected(_:)), keyEquivalent: "") + item.target = context.coordinator + item.tag = mode == .find ? 0 : 1 + menu.addItem(item) + } + + // Add separator + menu.addItem(.separator()) + + // Add wrap around item + let wrapItem = NSMenuItem(title: "Wrap Around", action: #selector(Coordinator.toggleWrapAround(_:)), keyEquivalent: "") + wrapItem.target = context.coordinator + wrapItem.state = wrapAround ? .on : .off + menu.addItem(wrapItem) + + popup.menu = menu + popup.selectItem(at: mode == .find ? 0 : 1) + + // Add subviews + container.addSubview(button) + container.addSubview(popup) + + // Set up constraints + button.translatesAutoresizingMaskIntoConstraints = false + popup.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + button.centerYAnchor.constraint(equalTo: container.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 16), + button.heightAnchor.constraint(equalToConstant: 20), + + popup.leadingAnchor.constraint(equalTo: button.trailingAnchor), + popup.trailingAnchor.constraint(equalTo: container.trailingAnchor), + popup.topAnchor.constraint(equalTo: container.topAnchor), + popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), + popup.widthAnchor.constraint(equalToConstant: totalWidth) + ]) + + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let popup = nsView.subviews.last as? NSPopUpButton { + popup.selectItem(at: mode == .find ? 0 : 1) + if let wrapItem = popup.menu?.items.last { + wrapItem.state = wrapAround ? .on : .off + } + } + + if let button = nsView.subviews.first as? NSButton { + button.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + var body: some View { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + return self.frame(width: totalWidth) + } + + class Coordinator: NSObject { + let parent: FindModePicker + + init(_ parent: FindModePicker) { + self.parent = parent + } + + @objc func openMenu(_ sender: NSButton) { + if let popup = sender.superview?.subviews.last as? NSPopUpButton { + popup.performClick(nil) + } + } + + @objc func modeSelected(_ sender: NSMenuItem) { + parent.mode = sender.tag == 0 ? .find : .replace + } + + @objc func toggleWrapAround(_ sender: NSMenuItem) { + parent.onToggleWrapAround() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index 86506018e..fbcd6603a 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -11,7 +11,13 @@ import Combine // NSView wrapper for using SwiftUI view in AppKit final class FindPanel: NSView { - static let height: CGFloat = 28 + /// The height of the find panel. + static var height: CGFloat { + if let findPanel = NSApp.windows.first(where: { $0.contentView is FindPanel })?.contentView as? FindPanel { + return findPanel.viewModel.mode == .replace ? 56 : 28 + } + return 28 + } weak var findDelegate: FindPanelDelegate? private var hostingView: NSHostingView! diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d18b33cc5..315ef2696 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -7,6 +7,7 @@ import SwiftUI import AppKit +import CodeEditSymbols struct FindPanelView: View { @Environment(\.controlActiveState) var activeState @@ -14,66 +15,170 @@ struct FindPanelView: View { @FocusState private var isFocused: Bool var body: some View { - HStack(spacing: 5) { - PanelTextField( - "Search...", - text: $viewModel.findText, - leadingAccessories: { - Image(systemName: "magnifyingglass") - .padding(.leading, 8) - .foregroundStyle(activeState == .inactive ? .tertiary : .secondary) - .font(.system(size: 12)) - .frame(width: 16, height: 20) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true - ) - .focused($isFocused) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() + VStack(spacing: 5) { + HStack(spacing: 5) { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround, + onToggleWrapAround: viewModel.toggleWrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + viewModel.findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + viewModel.findModePickerWidth = newWidth + } + }) + Divider() + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color(nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .focused($isFocused) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.prevButtonClicked) { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + } + .onChange(of: isFocused) { newValue in + viewModel.setFocus(newValue) + } + .onSubmit { + viewModel.onSubmit() + } + HStack(spacing: 4) { + ControlGroup { + Button(action: viewModel.prevButtonClicked) { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button(action: viewModel.nextButtonClicked) { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button(action: viewModel.onDismiss) { + Text("Done") .padding(.horizontal, 5) } - .disabled(viewModel.matchCount == 0) + .buttonStyle(PanelButtonStyle()) } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button(action: viewModel.onDismiss) { - Text("Done") - .padding(.horizontal, 5) + .background(GeometryReader { geometry in + Color.clear.onAppear { + viewModel.findControlsWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + viewModel.findControlsWidth = newWidth + } + }) + } + .padding(.horizontal, 5) + if viewModel.mode == .replace { + HStack(spacing: 5) { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + HStack(spacing: 0) { + Image(systemName: "pencil") + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: viewModel.findModePickerWidth, alignment: .leading) + Divider() + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) + } + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() + } + } + .onChange(of: isFocused) { newValue in + viewModel.setFocus(newValue) + } + .onSubmit { + viewModel.onSubmit() + } + HStack(spacing: 4) { + ControlGroup { + Button(action: viewModel.prevButtonClicked) { + Text("Replace") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button(action: viewModel.nextButtonClicked) { + Text("All") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + } } - .buttonStyle(PanelButtonStyle()) + .padding(.horizontal, 5) } } - .padding(.horizontal, 5) - .frame(height: FindPanel.height) + .frame(height: viewModel.mode == .replace ? FindPanel.height * 2 : FindPanel.height) .background(.bar) } } + +private struct FindModePickerWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index e8435f7a8..964925e6e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -8,10 +8,30 @@ import SwiftUI import Combine +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} + class FindPanelViewModel: ObservableObject { @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find + @Published var wrapAround: Bool = false @Published var matchCount: Int = 0 @Published var isFocused: Bool = false + @Published var findModePickerWidth: CGFloat = 0 + @Published var findControlsWidth: CGFloat = 0 + @Published var matchCase: Bool = false private weak var delegate: FindPanelDelegate? @@ -60,4 +80,8 @@ class FindPanelViewModel: ObservableObject { func nextButtonClicked() { delegate?.findPanelNextButtonClicked() } + + func toggleWrapAround() { + wrapAround.toggle() + } } From b6d0a07cbd97d5d9501e364d9ea63a871e0a6354 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 11 Apr 2025 11:33:35 -0500 Subject: [PATCH 14/37] Insets are updating on mode change, still a WIP. --- .../TextViewController+FindPanelTarget.swift | 5 ++ .../Find/FindPanelDelegate.swift | 4 ++ .../Find/FindPanelTarget.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 27 ++++++++ .../Find/FindViewController+Toggle.swift | 14 ++-- .../Find/FindViewController.swift | 10 +++ .../Find/PanelView/FindPanel.swift | 12 ++-- .../Find/PanelView/FindPanelView.swift | 69 ++++++++++--------- .../Find/PanelView/FindPanelViewModel.swift | 23 ++++++- 9 files changed, 118 insertions(+), 47 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 697ccc54b..4e5e5782a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -17,6 +17,11 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } + func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { + scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + var emphasisManager: EmphasisManager? { textView?.emphasisManager } diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index 2fb440929..bfc3c1c1f 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -11,6 +11,10 @@ protocol FindPanelDelegate: AnyObject { func findPanelOnSubmit() func findPanelOnDismiss() func findPanelDidUpdate(_ searchText: String) + func findPanelDidUpdateMode(_ mode: FindPanelMode) + func findPanelDidUpdateWrapAround(_ wrapAround: Bool) + func findPanelDidUpdateMatchCase(_ matchCase: Bool) + func findPanelDidUpdateReplaceText(_ text: String) func findPanelPrevButtonClicked() func findPanelNextButtonClicked() func findPanelUpdateMatchCount(_ count: Int) diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index af0facadd..f1857ecb0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -18,4 +18,5 @@ protocol FindPanelTarget: AnyObject { func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 7b0ded2a2..de5ce1adb 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -9,6 +9,11 @@ import AppKit import CodeEditTextView extension FindViewController: FindPanelDelegate { + var findPanelMode: FindPanelMode { mode } + var findPanelWrapAround: Bool { wrapAround } + var findPanelMatchCase: Bool { matchCase } + var findPanelReplaceText: String { replaceText } + func findPanelOnSubmit() { findPanelNextButtonClicked() } @@ -61,6 +66,28 @@ extension FindViewController: FindPanelDelegate { find(text: text) } + func findPanelDidUpdateMode(_ mode: FindPanelMode) { + self.mode = mode + if isShowingFindPanel { + target?.findPanelModeDidChange(to: mode, panelHeight: panelHeight) + } + } + + func findPanelDidUpdateWrapAround(_ wrapAround: Bool) { + self.wrapAround = wrapAround + } + + func findPanelDidUpdateMatchCase(_ matchCase: Bool) { + self.matchCase = matchCase + if !findText.isEmpty { + performFind() + } + } + + func findPanelDidUpdateReplaceText(_ text: String) { + self.replaceText = text + } + func findPanelPrevButtonClicked() { guard let textViewController = target as? TextViewController, let emphasisManager = target?.emphasisManager else { return } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 99645ce08..2886bfc2e 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -22,18 +22,24 @@ extension FindViewController { return } + if mode == .replace { + mode = .find + findPanel.updateMode(mode) + } + isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - FindPanel.height + findPanelVerticalConstraint.constant = resolvedTopPadding - panelHeight + view.layoutSubtreeIfNeeded() // Perform the animation conditionalAnimated(animated) { // SwiftUI breaks things here, and refuses to return the correct `findPanel.fittingSize` so we // are forced to use a constant number. - target?.findPanelWillShow(panelHeight: FindPanel.height) + target?.findPanelWillShow(panelHeight: panelHeight) setFindPanelConstraintShow() } onComplete: { } @@ -54,7 +60,7 @@ extension FindViewController { findPanel?.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: FindPanel.height) + target?.findPanelWillHide(panelHeight: panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true @@ -113,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (FindPanel.height * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 4d9172c92..1e2d2f05d 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -26,8 +26,13 @@ final class FindViewController: NSViewController { var findPanel: FindPanel! var findMatches: [NSRange] = [] + // TODO: we might make this nil if no current match so we can disable the match button in the find panel var currentFindMatchIndex: Int = 0 var findText: String = "" + var replaceText: String = "" + var matchCase: Bool = false + var wrapAround: Bool = true + var mode: FindPanelMode = .find var findPanelVerticalConstraint: NSLayoutConstraint! var isShowingFindPanel: Bool = false @@ -38,6 +43,11 @@ final class FindViewController: NSViewController { (topPadding ?? view.safeAreaInsets.top) } + /// The height of the find panel. + var panelHeight: CGFloat { + return self.mode == .replace ? 56 : 28 + } + init(target: FindPanelTarget, childView: NSView) { self.target = target self.childView = childView diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index fbcd6603a..1d9dd8b78 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -11,14 +11,6 @@ import Combine // NSView wrapper for using SwiftUI view in AppKit final class FindPanel: NSView { - /// The height of the find panel. - static var height: CGFloat { - if let findPanel = NSApp.windows.first(where: { $0.contentView is FindPanel })?.contentView as? FindPanel { - return findPanel.viewModel.mode == .replace ? 56 : 28 - } - return 28 - } - weak var findDelegate: FindPanelDelegate? private var hostingView: NSHostingView! private var viewModel: FindPanelViewModel! @@ -118,6 +110,10 @@ final class FindPanel: NSView { viewModel.updateMatchCount(count) } + func updateMode(_ mode: FindPanelMode) { + viewModel.mode = mode + } + // MARK: - Search Text Management func updateSearchText(_ text: String) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 315ef2696..a187d79f5 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -61,15 +61,6 @@ struct FindPanelView: View { ) .controlSize(.small) .focused($isFocused) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } .onChange(of: isFocused) { newValue in viewModel.setFocus(newValue) } @@ -126,43 +117,31 @@ struct FindPanelView: View { .frame(width: viewModel.findModePickerWidth, alignment: .leading) Divider() }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", clearable: true ) .controlSize(.small) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() - } + // TODO: Handle replace text field focus and submit HStack(spacing: 4) { ControlGroup { - Button(action: viewModel.prevButtonClicked) { + Button(action: { + // TODO: Replace action + }) { Text("Replace") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } - .disabled(viewModel.matchCount == 0) + // TODO: disable if there is not an active match + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { + Button(action: { + // TODO: Replace all action + }) { Text("All") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } - .disabled(viewModel.matchCount == 0) + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) .fixedSize() @@ -171,8 +150,30 @@ struct FindPanelView: View { .padding(.horizontal, 5) } } - .frame(height: viewModel.mode == .replace ? FindPanel.height * 2 : FindPanel.height) + .frame(height: viewModel.panelHeight) .background(.bar) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) + } + .onChange(of: viewModel.replaceText) { newValue in + viewModel.onReplaceTextChange(newValue) + } + .onChange(of: viewModel.mode) { newMode in + viewModel.onModeChange(newMode) + } + .onChange(of: viewModel.wrapAround) { newValue in + viewModel.onWrapAroundChange(newValue) + } + .onChange(of: viewModel.matchCase) { newValue in + viewModel.onMatchCaseChange(newValue) + } + .onChange(of: viewModel.isFocused) { newValue in + isFocused = newValue + if !newValue { + viewModel.removeEmphasis() + } + } + } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index 964925e6e..759e1af9b 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -26,13 +26,17 @@ class FindPanelViewModel: ObservableObject { @Published var findText: String = "" @Published var replaceText: String = "" @Published var mode: FindPanelMode = .find - @Published var wrapAround: Bool = false + @Published var wrapAround: Bool = true @Published var matchCount: Int = 0 @Published var isFocused: Bool = false @Published var findModePickerWidth: CGFloat = 0 @Published var findControlsWidth: CGFloat = 0 @Published var matchCase: Bool = false + var panelHeight: CGFloat { + return mode == .replace ? 56 : 28 + } + private weak var delegate: FindPanelDelegate? init(delegate: FindPanelDelegate?) { @@ -49,6 +53,22 @@ class FindPanelViewModel: ObservableObject { delegate?.findPanelDidUpdate(text) } + func onReplaceTextChange(_ text: String) { + delegate?.findPanelDidUpdateReplaceText(text) + } + + func onModeChange(_ mode: FindPanelMode) { + delegate?.findPanelDidUpdateMode(mode) + } + + func onWrapAroundChange(_ wrapAround: Bool) { + delegate?.findPanelDidUpdateWrapAround(wrapAround) + } + + func onMatchCaseChange(_ matchCase: Bool) { + delegate?.findPanelDidUpdateMatchCase(matchCase) + } + func onSubmit() { delegate?.findPanelOnSubmit() } @@ -83,5 +103,6 @@ class FindPanelViewModel: ObservableObject { func toggleWrapAround() { wrapAround.toggle() + delegate?.findPanelDidUpdateWrapAround(wrapAround) } } From ab32c92903dd6b5fbccbfbb9443b9e1fed0ec37f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 09:59:58 -0500 Subject: [PATCH 15/37] Implemented wrap around setting logic. Now when disabled, we do not loop find matches. --- ...FindViewController+FindPanelDelegate.swift | 117 ++++++++---------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index de5ce1adb..4a6bd4eb0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -88,54 +88,69 @@ extension FindViewController: FindPanelDelegate { self.replaceText = text } + private func flashCurrentMatch(emphasisManager: EmphasisManager, textViewController: TextViewController) { + let newActiveRange = findMatches[currentFindMatchIndex] + emphasisManager.removeEmphases(for: EmphasisGroup.find) + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + selectInDocument: true + ), + for: EmphasisGroup.find + ) + } + func findPanelPrevButtonClicked() { guard let textViewController = target as? TextViewController, let emphasisManager = target?.emphasisManager else { return } // Check if there are any matches if findMatches.isEmpty { + NSSound.beep() + BezelNotification.show( + symbolName: "arrow.up.to.line", + over: textViewController.textView + ) return } - // Update to previous match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // Show bezel notification if we cycled from first to last match - if oldIndex == 0 && currentFindMatchIndex == findMatches.count - 1 { + // Check if we're at the first match and wrapAround is false + if !wrapAround && currentFindMatchIndex == 0 { + NSSound.beep() BezelNotification.show( - symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", + symbolName: "arrow.up.to.line", over: textViewController.textView ) + if textViewController.textView.window?.firstResponder === textViewController.textView { + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) + return + } + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + return } + // Update to previous match∂ + currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count + // If the text view has focus, show a flash animation for the current match if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) return } - // Create updated emphases with new active state + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + } + + private func updateEmphasesForCurrentMatch(emphasisManager: EmphasisManager, flash: Bool = false) { + // Create updated emphases with current match emphasized let updatedEmphases = findMatches.enumerated().map { index, range in Emphasis( range: range, style: .standard, - flash: false, + flash: flash, inactive: index != currentFindMatchIndex, selectInDocument: index == currentFindMatchIndex ) @@ -151,7 +166,6 @@ extension FindViewController: FindPanelDelegate { // Check if there are any matches if findMatches.isEmpty { - // Show "no matches" bezel notification and play beep NSSound.beep() BezelNotification.show( symbolName: "arrow.down.to.line", @@ -160,52 +174,31 @@ extension FindViewController: FindPanelDelegate { return } - // Update to next match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // Show bezel notification if we cycled from last to first match - if oldIndex == findMatches.count - 1 && currentFindMatchIndex == 0 { + // Check if we're at the last match and wrapAround is false + if !wrapAround && currentFindMatchIndex == findMatches.count - 1 { + NSSound.beep() BezelNotification.show( - symbolName: "arrow.triangle.capsulepath", + symbolName: "arrow.down.to.line", over: textViewController.textView ) + if textViewController.textView.window?.firstResponder === textViewController.textView { + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) + return + } + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) + return } + // Update to next match + currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count + // If the text view has focus, show a flash animation for the current match if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - + flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) return } - // Create updated emphases with new active state - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) + updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) } func findPanelUpdateMatchCount(_ count: Int) { From 717d3f9eaf5de2dbfb39a0163f742bb2907775c9 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 10:13:07 -0500 Subject: [PATCH 16/37] Fixed SwiftLint issues --- .../Find/PanelView/FindModePicker.swift | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index e191e34ff..b7f068345 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -13,11 +13,7 @@ struct FindModePicker: NSViewRepresentable { @Environment(\.controlActiveState) var activeState let onToggleWrapAround: () -> Void - func makeNSView(context: Context) -> NSView { - let container = NSView() - container.wantsLayer = true - - // Create the magnifying glass button + private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) button.bezelStyle = .regularSquare button.isBordered = false @@ -26,29 +22,33 @@ struct FindModePicker: NSViewRepresentable { .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) button.imagePosition = .imageOnly button.target = context.coordinator + button.action = nil + button.sendAction(on: .leftMouseDown) + button.target = context.coordinator button.action = #selector(Coordinator.openMenu(_:)) + return button + } - // Create the popup button + 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 + return popup + } - // Calculate the required width - let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) - let maxWidth = FindPanelMode.allCases.map { mode in - mode.displayName.size(withAttributes: [.font: font]).width - }.max() ?? 0 - let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing - - // Create menu + private func createMenu(context: Context) -> NSMenu { let menu = NSMenu() // Add mode items FindPanelMode.allCases.forEach { mode in - let item = NSMenuItem(title: mode.displayName, action: #selector(Coordinator.modeSelected(_:)), keyEquivalent: "") + let item = NSMenuItem( + title: mode.displayName, + action: #selector(Coordinator.modeSelected(_:)), + keyEquivalent: "" + ) item.target = context.coordinator item.tag = mode == .find ? 0 : 1 menu.addItem(item) @@ -58,19 +58,19 @@ struct FindModePicker: NSViewRepresentable { menu.addItem(.separator()) // Add wrap around item - let wrapItem = NSMenuItem(title: "Wrap Around", action: #selector(Coordinator.toggleWrapAround(_:)), keyEquivalent: "") + let wrapItem = NSMenuItem( + title: "Wrap Around", + action: #selector(Coordinator.toggleWrapAround(_:)), + keyEquivalent: "" + ) wrapItem.target = context.coordinator wrapItem.state = wrapAround ? .on : .off menu.addItem(wrapItem) - popup.menu = menu - popup.selectItem(at: mode == .find ? 0 : 1) - - // Add subviews - container.addSubview(button) - container.addSubview(popup) + return menu + } - // Set up constraints + private func setupConstraints(container: NSView, button: NSButton, popup: NSPopUpButton, totalWidth: CGFloat) { button.translatesAutoresizingMaskIntoConstraints = false popup.translatesAutoresizingMaskIntoConstraints = false @@ -86,6 +86,30 @@ struct FindModePicker: NSViewRepresentable { popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), popup.widthAnchor.constraint(equalToConstant: totalWidth) ]) + } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + let button = createSymbolButton(context: context) + let popup = createPopupButton(context: context) + + // Calculate the required width + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + popup.menu = createMenu(context: context) + popup.selectItem(at: mode == .find ? 0 : 1) + + // Add subviews + container.addSubview(button) + container.addSubview(popup) + + setupConstraints(container: container, button: button, popup: popup, totalWidth: totalWidth) return container } From 4f0ddd29b0c6d25c59c36586a130af5d1f3c1bf4 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 14 Apr 2025 10:18:17 -0500 Subject: [PATCH 17/37] More SwiftLint fixes --- .../Find/PanelView/FindPanelView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index a187d79f5..d2f8d269f 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -125,22 +125,22 @@ struct FindPanelView: View { ControlGroup { Button(action: { // TODO: Replace action - }) { + }, label: { Text("Replace") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } + }) // TODO: disable if there is not an active match .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) Button(action: { // TODO: Replace all action - }) { + }, label: { Text("All") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } + }) .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) From 09555f6fe31fbad3485e43ed27d0eadf3c081c46 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 11:13:55 -0500 Subject: [PATCH 18/37] We are retaining our find match emphases when the replace text field is in focus. --- .../Find/PanelView/FindModePicker.swift | 2 ++ .../Find/PanelView/FindPanelView.swift | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index b7f068345..c0993d603 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -12,6 +12,7 @@ struct FindModePicker: NSViewRepresentable { @Binding var wrapAround: Bool @Environment(\.controlActiveState) var activeState let onToggleWrapAround: () -> Void + let onModeChange: () -> Void private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) @@ -156,6 +157,7 @@ struct FindModePicker: NSViewRepresentable { @objc func modeSelected(_ sender: NSMenuItem) { parent.mode = sender.tag == 0 ? .find : .replace + parent.onModeChange() } @objc func toggleWrapAround(_ sender: NSMenuItem) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d2f8d269f..515f19688 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -12,7 +12,8 @@ import CodeEditSymbols struct FindPanelView: View { @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFocused: Bool + @FocusState private var isFindFieldFocused: Bool + @FocusState private var isReplaceFieldFocused: Bool var body: some View { VStack(spacing: 5) { @@ -24,7 +25,13 @@ struct FindPanelView: View { FindModePicker( mode: $viewModel.mode, wrapAround: $viewModel.wrapAround, - onToggleWrapAround: viewModel.toggleWrapAround + onToggleWrapAround: viewModel.toggleWrapAround, + onModeChange: { + isFindFieldFocused = true + if let textField = NSApp.keyWindow?.firstResponder as? NSTextView { + textField.selectAll(nil) + } + } ) .background(GeometryReader { geometry in Color.clear.onAppear { @@ -60,9 +67,9 @@ struct FindPanelView: View { clearable: true ) .controlSize(.small) - .focused($isFocused) - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) + .focused($isFindFieldFocused) + .onChange(of: isFindFieldFocused) { newValue in + viewModel.setFocus(newValue || isReplaceFieldFocused) } .onSubmit { viewModel.onSubmit() @@ -120,7 +127,10 @@ struct FindPanelView: View { clearable: true ) .controlSize(.small) - // TODO: Handle replace text field focus and submit + .focused($isReplaceFieldFocused) + .onChange(of: isReplaceFieldFocused) { newValue in + viewModel.setFocus(newValue || isFindFieldFocused) + } HStack(spacing: 4) { ControlGroup { Button(action: { @@ -168,12 +178,11 @@ struct FindPanelView: View { viewModel.onMatchCaseChange(newValue) } .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue + isFindFieldFocused = newValue if !newValue { viewModel.removeEmphasis() } } - } } From 88a364a09d17ef9b2b6b891cc9d2a7f7539edb1b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 11:50:19 -0500 Subject: [PATCH 19/37] Enabled match case functionality so now the match case toggle in the find panel works. --- .../Find/FindViewController+FindPanelDelegate.swift | 3 ++- .../Find/FindViewController+Operations.swift | 11 +++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 4a6bd4eb0..b68a29878 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -81,6 +81,7 @@ extension FindViewController: FindPanelDelegate { self.matchCase = matchCase if !findText.isEmpty { performFind() + addEmphases() } } @@ -132,7 +133,7 @@ extension FindViewController: FindPanelDelegate { return } - // Update to previous match∂ + // Update to previous match currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count // If the text view has focus, show a flash animation for the current match diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index d67054f39..1b3906f2b 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -32,7 +32,8 @@ extension FindViewController { return } - let findOptions: NSRegularExpression.Options = smartCase(str: findText) ? [] : [.caseInsensitive] + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] let escapedQuery = NSRegularExpression.escapedPattern(for: findText) guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { @@ -52,7 +53,7 @@ extension FindViewController { currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 } - private func addEmphases() { + func addEmphases() { guard let target = target, let emphasisManager = target.emphasisManager else { return } @@ -116,10 +117,4 @@ extension FindViewController { // Only re-find the part of the file that changed upwards private func reFind() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the find text is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } } From 8f8c81ab0c4f52433f3fc2c7a1a58b8de0749bed Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 16 Apr 2025 12:55:44 -0500 Subject: [PATCH 20/37] Added replace current match functionality. --- .../Find/FindPanelDelegate.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 6 ++- .../Find/FindViewController+Operations.swift | 40 +++++++++++++++++++ .../Find/PanelView/FindPanelView.swift | 6 +-- .../Find/PanelView/FindPanelViewModel.swift | 4 ++ 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index bfc3c1c1f..65a46c454 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -17,6 +17,7 @@ protocol FindPanelDelegate: AnyObject { func findPanelDidUpdateReplaceText(_ text: String) func findPanelPrevButtonClicked() func findPanelNextButtonClicked() + func findPanelReplaceButtonClicked() func findPanelUpdateMatchCount(_ count: Int) func findPanelClearEmphasis() } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index b68a29878..74bf6427f 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -12,7 +12,6 @@ extension FindViewController: FindPanelDelegate { var findPanelMode: FindPanelMode { mode } var findPanelWrapAround: Bool { wrapAround } var findPanelMatchCase: Bool { matchCase } - var findPanelReplaceText: String { replaceText } func findPanelOnSubmit() { findPanelNextButtonClicked() @@ -202,6 +201,11 @@ extension FindViewController: FindPanelDelegate { updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) } + func findPanelReplaceButtonClicked() { + guard !findMatches.isEmpty else { return } + replaceCurrentMatch() + } + func findPanelUpdateMatchCount(_ count: Int) { findPanel.updateMatchCount(count) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 1b3906f2b..9f5104ad3 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -53,6 +53,46 @@ extension FindViewController { currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 } + func replaceCurrentMatch() { + guard let target = target, + !findMatches.isEmpty else { return } + + // Get the current match range + let currentMatchRange = findMatches[currentFindMatchIndex] + + // Set cursor positions to the match range + target.setCursorPositions([CursorPosition(range: currentMatchRange)]) + + // Replace the text using the cursor positions + if let textViewController = target as? TextViewController { + textViewController.textView.insertText(replaceText, replacementRange: currentMatchRange) + } + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - currentMatchRange.length + + // Update the current match index + if findMatches.isEmpty { + currentFindMatchIndex = 0 + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + } else { + // Update all match ranges after the current match + for index in (currentFindMatchIndex + 1).. Date: Wed, 16 Apr 2025 21:29:03 -0500 Subject: [PATCH 21/37] Added replace all logic. Adjusted panel text field coloring. --- .../Find/FindPanelDelegate.swift | 1 + ...FindViewController+FindPanelDelegate.swift | 5 ++ .../Find/FindViewController+Operations.swift | 49 +++++++++++++++++-- .../Find/PanelView/FindPanelView.swift | 21 +++++--- .../Find/PanelView/FindPanelViewModel.swift | 4 ++ .../SupportingViews/PanelTextField.swift | 17 +++++-- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index 65a46c454..e7cdb8cd6 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -18,6 +18,7 @@ protocol FindPanelDelegate: AnyObject { func findPanelPrevButtonClicked() func findPanelNextButtonClicked() func findPanelReplaceButtonClicked() + func findPanelReplaceAllButtonClicked() func findPanelUpdateMatchCount(_ count: Int) func findPanelClearEmphasis() } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 74bf6427f..dd558f28b 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -206,6 +206,11 @@ extension FindViewController: FindPanelDelegate { replaceCurrentMatch() } + func findPanelReplaceAllButtonClicked() { + guard !findMatches.isEmpty else { return } + replaceAllMatches() + } + func findPanelUpdateMatchCount(_ count: Int) { findPanel.updateMatchCount(count) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 9f5104ad3..7d6eda560 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -81,9 +81,6 @@ extension FindViewController { findMatches[index].location += lengthDiff } - // Remove the current match from the array - findMatches.remove(at: currentFindMatchIndex) - // Keep the current index in bounds currentFindMatchIndex = min(currentFindMatchIndex, findMatches.count - 1) findPanel.findDelegate?.findPanelUpdateMatchCount(findMatches.count) @@ -93,6 +90,52 @@ extension FindViewController { addEmphases() } + func replaceAllMatches() { + guard let target = target, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { return } + + // Sort matches in reverse order to avoid range shifting issues + let sortedMatches = findMatches.sorted { $0.location > $1.location } + + // Begin undo grouping using CEUndoManager + if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { + ceUndoManager.beginUndoGrouping() + } + + // Replace each match + for matchRange in sortedMatches { + // Set cursor positions to the match range + target.setCursorPositions([CursorPosition(range: matchRange)]) + + // Replace the text using the cursor positions + textViewController.textView.insertText(replaceText, replacementRange: matchRange) + } + + // End undo grouping + if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { + ceUndoManager.endUndoGrouping() + } + + // Set cursor position to the end of the last replaced match + if let lastMatch = sortedMatches.first { + let endPosition = lastMatch.location + replaceText.utf16.count + let cursorRange = NSRange(location: endPosition, length: 0) + target.setCursorPositions([CursorPosition(range: cursorRange)]) + textViewController.textView.selectionManager.setSelectedRanges([cursorRange]) + textViewController.textView.scrollSelectionToVisible() + textViewController.textView.needsDisplay = true + } + + // Clear all matches since they've been replaced + findMatches = [] + currentFindMatchIndex = 0 + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + + // Update the emphases + addEmphases() + } + func addEmphases() { guard let target = target, let emphasisManager = target.emphasisManager else { return } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index b625d994d..edf115f9e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -16,7 +16,7 @@ struct FindPanelView: View { @FocusState private var isReplaceFieldFocused: Bool var body: some View { - VStack(spacing: 5) { + VStack(alignment: .leading, spacing: 5) { HStack(spacing: 5) { PanelTextField( "Text", @@ -117,6 +117,7 @@ struct FindPanelView: View { leadingAccessories: { HStack(spacing: 0) { Image(systemName: "pencil") + .foregroundStyle(.secondary) .padding(.leading, 8) .padding(.trailing, 5) Text("With") @@ -135,20 +136,26 @@ struct FindPanelView: View { ControlGroup { Button(action: viewModel.replaceButtonClicked) { Text("Replace") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) } // TODO: disable if there is not an active match - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: { - // TODO: Replace all action - }, label: { + Button(action: viewModel.replaceAllButtonClicked) { Text("All") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - }) + } .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) } .controlGroupStyle(PanelControlGroupStyle()) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index 602bc1577..fdc55b716 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -105,6 +105,10 @@ class FindPanelViewModel: ObservableObject { delegate?.findPanelReplaceButtonClicked() } + func replaceAllButtonClicked() { + delegate?.findPanelReplaceAllButtonClicked() + } + func toggleWrapAround() { wrapAround.toggle() delegate?.findPanelDidUpdateWrapAround(wrapAround) diff --git a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift index beefdd7d4..cda97dbca 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift @@ -66,16 +66,24 @@ struct PanelTextField: View Color(.textBackgroundColor) } else { if colorScheme == .light { - Color.black.opacity(0.06) + // TODO: if over sidebar 0.06 else 0.085 +// Color.black.opacity(0.06) + Color.black.opacity(0.085) } else { - Color.white.opacity(0.24) + // TODO: if over sidebar 0.24 else 0.06 +// Color.white.opacity(0.24) + Color.white.opacity(0.06) } } } else { if colorScheme == .light { - Color.clear + // TODO: if over sidebar 0.0 else 0.06 +// Color.clear + Color.black.opacity(0.06) } else { - Color.white.opacity(0.14) + // TODO: if over sidebar 0.14 else 0.045 +// Color.white.opacity(0.14) + Color.white.opacity(0.045) } } } @@ -98,6 +106,7 @@ struct PanelTextField: View Text(helperText) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } } if clearable == true { From 26edd07ccefd8859993bdf81a53dc63ac6d86e55 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:22:01 -0500 Subject: [PATCH 22/37] Update TextViewController+StyleViews.swift --- .../Controller/TextViewController+StyleViews.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 2cc2f13b5..691b780e0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -96,7 +96,7 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + let findInset = (findViewController?.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset From 53c6b80daef7d4e7e777aacc8687353598ab56d0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:01:33 -0500 Subject: [PATCH 23/37] Find and Replace Refactor (#311) ### Description @austincondiff has been building out the 'replace' functionality and realized the way we had structured the panel, controller, target, and model was extremely overcomplicating things. This PR is an attempt to fix that Changes: - Moves all 'business logic' into the `FindPanelViewModel` observable object. This includes clarified methods like `find`, `replace`, and `moveToNextMatch/moveToPreviousMatch`. All state has been moved to this object as well, out of a combination of both the SwiftUI view and the find view controller. - Removes the `FindPanelDelegate` type entirely. All that type was doing was passing method calls from the find panel to it's controller. Since all that logic is now in the shared view model, the controller & view can just call the necessary methods on the model. - Simplifies the `FindViewController` to only handle view/model setup and layout. - Changes the focus state variable to an enum instead of two `Bool`s. This fixes an issue where there was a moment of nothing being focused when switching between the find and replace text fields. - Removes the unnecessary `NSHostingView -> NSView -> SwiftUI View` structure, replacing it with an `NSHostingView` subclass `FindPanelHostingView` that hosts a `FindPanelView`. - Clarifies some view naming to reflect what each type does. - `FindPanel` -> `FindPanelHostingView` - `FindPanelView` search fields moved to: - `FindSearchField` - `ReplaceSearchField` ### Related Issues * #295 ### 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 --- .../TextViewController+Cursor.swift | 6 +- .../TextViewController+FindPanelTarget.swift | 11 +- .../TextViewController+LoadView.swift | 2 +- .../TextViewController+StyleViews.swift | 6 +- .../Find/FindPanelDelegate.swift | 24 -- .../Find/FindPanelMode.swift | 20 ++ .../Find/FindPanelTarget.swift | 7 +- ...FindViewController+FindPanelDelegate.swift | 221 -------------- .../Find/FindViewController+Operations.swift | 203 ------------- .../Find/FindViewController+Toggle.swift | 34 +-- .../Find/FindViewController.swift | 50 +--- .../Find/PanelView/FindModePicker.swift | 17 +- .../Find/PanelView/FindPanel.swift | 124 -------- .../Find/PanelView/FindPanelHostingView.swift | 60 ++++ .../Find/PanelView/FindPanelView.swift | 269 +++++++----------- .../Find/PanelView/FindPanelViewModel.swift | 116 -------- .../Find/PanelView/FindSearchField.swift | 64 +++++ .../Find/PanelView/ReplaceSearchField.swift | 35 +++ .../FindPanelViewModel+Emphasis.swift | 37 +++ .../ViewModel/FindPanelViewModel+Find.swift | 83 ++++++ .../ViewModel/FindPanelViewModel+Move.swift | 64 +++++ .../FindPanelViewModel+Replace.swift | 69 +++++ .../Find/ViewModel/FindPanelViewModel.swift | 99 +++++++ .../SupportingViews/PanelTextField.swift | 12 +- 24 files changed, 699 insertions(+), 934 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelMode.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index de2783f76..04af69ac7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -11,7 +11,7 @@ import AppKit extension TextViewController { /// Sets new cursor positions. /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. - public func setCursorPositions(_ positions: [CursorPosition]) { + public func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool = false) { if isPostingCursorNotification { return } var newSelectedRanges: [NSRange] = [] for position in positions { @@ -33,6 +33,10 @@ extension TextViewController { } } textView.selectionManager.setSelectedRanges(newSelectedRanges) + + if scrollToVisible { + textView.scrollSelectionToVisible() + } } /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 4e5e5782a..3401ea3cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -5,10 +5,14 @@ // Created by Khan Winter on 3/16/25. // -import Foundation +import AppKit import CodeEditTextView extension TextViewController: FindPanelTarget { + var findPanelTargetView: NSView { + textView + } + func findPanelWillShow(panelHeight: CGFloat) { updateContentInsets() } @@ -17,9 +21,8 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { - scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight - gutterView.frame.origin.y = -scrollView.contentInsets.top + func findPanelModeDidChange(to mode: FindPanelMode) { + updateContentInsets() } var emphasisManager: EmphasisManager? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 1b960ed48..be71427a7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -220,7 +220,7 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.findPanel.dismiss() + self.findViewController?.hideFindPanel() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 691b780e0..8fdc5f478 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -96,7 +96,11 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 + let findInset: CGFloat = if findViewController?.viewModel.isShowingFindPanel ?? false { + findViewController?.viewModel.panelHeight ?? 0 + } else { + 0 + } scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift deleted file mode 100644 index e7cdb8cd6..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// FindPanelDelegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import Foundation - -protocol FindPanelDelegate: AnyObject { - func findPanelOnSubmit() - func findPanelOnDismiss() - func findPanelDidUpdate(_ searchText: String) - func findPanelDidUpdateMode(_ mode: FindPanelMode) - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) - func findPanelDidUpdateMatchCase(_ matchCase: Bool) - func findPanelDidUpdateReplaceText(_ text: String) - func findPanelPrevButtonClicked() - func findPanelNextButtonClicked() - func findPanelReplaceButtonClicked() - func findPanelReplaceAllButtonClicked() - func findPanelUpdateMatchCount(_ count: Int) - func findPanelClearEmphasis() -} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift new file mode 100644 index 000000000..f7bbf26bd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift @@ -0,0 +1,20 @@ +// +// FindPanelMode.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index f1857ecb0..90a286715 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -5,18 +5,19 @@ // Created by Khan Winter on 3/10/25. // -import Foundation +import AppKit import CodeEditTextView protocol FindPanelTarget: AnyObject { var emphasisManager: EmphasisManager? { get } var text: String { get } + var findPanelTargetView: NSView { get } var cursorPositions: [CursorPosition] { get } - func setCursorPositions(_ positions: [CursorPosition]) + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) func updateCursorPosition() func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift deleted file mode 100644 index dd558f28b..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// FindViewController+Delegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController: FindPanelDelegate { - var findPanelMode: FindPanelMode { mode } - var findPanelWrapAround: Bool { wrapAround } - var findPanelMatchCase: Bool { matchCase } - - func findPanelOnSubmit() { - findPanelNextButtonClicked() - } - - func findPanelOnDismiss() { - if isShowingFindPanel { - hideFindPanel() - // Ensure text view becomes first responder after hiding - if let textViewController = target as? TextViewController { - DispatchQueue.main.async { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) - } - } - } - } - - func findPanelDidUpdate(_ text: String) { - // Check if this update was triggered by a return key without shift - if let currentEvent = NSApp.currentEvent, - currentEvent.type == .keyDown, - currentEvent.keyCode == 36, // Return key - !currentEvent.modifierFlags.contains(.shift) { - return // Skip find for regular return key - } - - // Only perform find if we're focusing the text view - if let textViewController = target as? TextViewController, - textViewController.textView.window?.firstResponder === textViewController.textView { - // If the text view has focus, just clear visual emphases but keep matches in memory - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: EmphasisGroup.find), - let activeEmphasis = emphases.first(where: { !$0.inactive }) { - target?.emphasisManager?.addEmphasis( - Emphasis( - range: activeEmphasis.range, - style: .standard, - flash: false, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - return - } - - // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - find(text: text) - } - - func findPanelDidUpdateMode(_ mode: FindPanelMode) { - self.mode = mode - if isShowingFindPanel { - target?.findPanelModeDidChange(to: mode, panelHeight: panelHeight) - } - } - - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) { - self.wrapAround = wrapAround - } - - func findPanelDidUpdateMatchCase(_ matchCase: Bool) { - self.matchCase = matchCase - if !findText.isEmpty { - performFind() - addEmphases() - } - } - - func findPanelDidUpdateReplaceText(_ text: String) { - self.replaceText = text - } - - private func flashCurrentMatch(emphasisManager: EmphasisManager, textViewController: TextViewController) { - let newActiveRange = findMatches[currentFindMatchIndex] - emphasisManager.removeEmphases(for: EmphasisGroup.find) - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - - func findPanelPrevButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the first match and wrapAround is false - if !wrapAround && currentFindMatchIndex == 0 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to previous match - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - private func updateEmphasesForCurrentMatch(emphasisManager: EmphasisManager, flash: Bool = false) { - // Create updated emphases with current match emphasized - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: flash, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) - } - - func findPanelNextButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the last match and wrapAround is false - if !wrapAround && currentFindMatchIndex == findMatches.count - 1 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to next match - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - func findPanelReplaceButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceCurrentMatch() - } - - func findPanelReplaceAllButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceAllMatches() - } - - func findPanelUpdateMatchCount(_ count: Int) { - findPanel.updateMatchCount(count) - } - - func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift deleted file mode 100644 index 7d6eda560..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// FindViewController+Operations.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController { - func find(text: String) { - findText = text - performFind() - addEmphases() - } - - func performFind() { - // Don't find if target or emphasisManager isn't ready - guard let target = target else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Clear emphases and return if query is empty - if findText.isEmpty { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Set case sensitivity based on matchCase property - let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: findText) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - let text = target.text - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - - findMatches = matches.map { $0.range } - findPanel.findDelegate?.findPanelUpdateMatchCount(findMatches.count) - - // Find the nearest match to the current cursor position - currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 - } - - func replaceCurrentMatch() { - guard let target = target, - !findMatches.isEmpty else { return } - - // Get the current match range - let currentMatchRange = findMatches[currentFindMatchIndex] - - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: currentMatchRange)]) - - // Replace the text using the cursor positions - if let textViewController = target as? TextViewController { - textViewController.textView.insertText(replaceText, replacementRange: currentMatchRange) - } - - // Adjust the length of the replacement - let lengthDiff = replaceText.utf16.count - currentMatchRange.length - - // Update the current match index - if findMatches.isEmpty { - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - } else { - // Update all match ranges after the current match - for index in (currentFindMatchIndex + 1).. $1.location } - - // Begin undo grouping using CEUndoManager - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.beginUndoGrouping() - } - - // Replace each match - for matchRange in sortedMatches { - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: matchRange)]) - - // Replace the text using the cursor positions - textViewController.textView.insertText(replaceText, replacementRange: matchRange) - } - - // End undo grouping - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.endUndoGrouping() - } - - // Set cursor position to the end of the last replaced match - if let lastMatch = sortedMatches.first { - let endPosition = lastMatch.location + replaceText.utf16.count - let cursorRange = NSRange(location: endPosition, length: 0) - target.setCursorPositions([CursorPosition(range: cursorRange)]) - textViewController.textView.selectionManager.setSelectedRanges([cursorRange]) - textViewController.textView.scrollSelectionToVisible() - textViewController.textView.needsDisplay = true - } - - // Clear all matches since they've been replaced - findMatches = [] - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - - // Update the emphases - addEmphases() - } - - func addEmphases() { - guard let target = target, - let emphasisManager = target.emphasisManager else { return } - - // Clear existing emphases - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - // Create emphasis with the nearest match as active - let emphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Add all emphases - emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) - } - - private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = target?.cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = matchRanges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = matchRanges[mid].location - let diff = abs(midStart - start) - - // If it's an exact match, return immediately - if diff == 0 { - return mid - } - - // If this is the closest so far, update the best index - if diff < bestDiff { - bestDiff = diff - bestIndex = mid - } - - // Move left or right based on the cursor position - if midStart < start { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return bestIndex >= 0 ? bestIndex : nil - } - - // Only re-find the part of the file that changed upwards - private func reFind() { } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 2886bfc2e..bfea53c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -16,22 +16,21 @@ extension FindViewController { /// - Animates the find panel into position (resolvedTopPadding). /// - Makes the find panel the first responder. func showFindPanel(animated: Bool = true) { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { // If panel is already showing, just focus the text field - _ = findPanel?.becomeFirstResponder() + viewModel.isFocused = true return } - if mode == .replace { - mode = .find - findPanel.updateMode(mode) + if viewModel.mode == .replace { + viewModel.mode = .find } - isShowingFindPanel = true + viewModel.isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - panelHeight + findPanelVerticalConstraint.constant = resolvedTopPadding - viewModel.panelHeight view.layoutSubtreeIfNeeded() @@ -39,12 +38,12 @@ extension FindViewController { conditionalAnimated(animated) { // SwiftUI breaks things here, and refuses to return the correct `findPanel.fittingSize` so we // are forced to use a constant number. - target?.findPanelWillShow(panelHeight: panelHeight) + viewModel.target?.findPanelWillShow(panelHeight: viewModel.panelHeight) setFindPanelConstraintShow() } onComplete: { } - _ = findPanel?.becomeFirstResponder() - findPanel?.addEventMonitor() + viewModel.isFocused = true + findPanel.addEventMonitor() } /// Hide the find panel @@ -55,20 +54,21 @@ extension FindViewController { /// - Hides the find panel. /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { - isShowingFindPanel = false - _ = findPanel?.resignFirstResponder() - findPanel?.removeEventMonitor() + viewModel.isShowingFindPanel = false + _ = findPanel.resignFirstResponder() + findPanel.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: panelHeight) + viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true + self?.viewModel.isFocused = false } // Set first responder back to text view - if let textViewController = target as? TextViewController { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + if let target = viewModel.target { + _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } } @@ -119,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (panelHeight * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (viewModel.panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 1e2d2f05d..a9e2dd3b0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -10,69 +10,35 @@ import CodeEditTextView /// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { - weak var target: FindPanelTarget? + var viewModel: FindPanelViewModel /// The amount of padding from the top of the view to inset the find panel by. /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. var topPadding: CGFloat? { didSet { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { setFindPanelConstraintShow() } } } var childView: NSView - var findPanel: FindPanel! - var findMatches: [NSRange] = [] - - // TODO: we might make this nil if no current match so we can disable the match button in the find panel - var currentFindMatchIndex: Int = 0 - var findText: String = "" - var replaceText: String = "" - var matchCase: Bool = false - var wrapAround: Bool = true - var mode: FindPanelMode = .find + var findPanel: FindPanelHostingView var findPanelVerticalConstraint: NSLayoutConstraint! - var isShowingFindPanel: Bool = false - /// The 'real' top padding amount. /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. var resolvedTopPadding: CGFloat { (topPadding ?? view.safeAreaInsets.top) } - /// The height of the find panel. - var panelHeight: CGFloat { - return self.mode == .replace ? 56 : 28 - } - init(target: FindPanelTarget, childView: NSView) { - self.target = target + viewModel = FindPanelViewModel(target: target) self.childView = childView + findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) - self.findPanel = FindPanel(delegate: self, textView: target as? NSView) - - // Add notification observer for text changes - if let textViewController = target as? TextViewController { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: TextView.textDidChangeNotification, - object: textViewController.textView - ) - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func textDidChange() { - // Only update if we have find text - if !findText.isEmpty { - performFind() + viewModel.dismiss = { [weak self] in + self?.hideFindPanel() } } @@ -115,7 +81,7 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - if isShowingFindPanel { // Update constraints for initial state + if viewModel.isShowingFindPanel { // Update constraints for initial state findPanel.isHidden = false setFindPanelConstraintShow() } else { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index c0993d603..8245681aa 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -11,8 +11,6 @@ struct FindModePicker: NSViewRepresentable { @Binding var mode: FindPanelMode @Binding var wrapAround: Bool @Environment(\.controlActiveState) var activeState - let onToggleWrapAround: () -> Void - let onModeChange: () -> Void private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) @@ -129,7 +127,7 @@ struct FindModePicker: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(self) + Coordinator(mode: $mode, wrapAround: $wrapAround) } var body: some View { @@ -143,10 +141,12 @@ struct FindModePicker: NSViewRepresentable { } class Coordinator: NSObject { - let parent: FindModePicker + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool - init(_ parent: FindModePicker) { - self.parent = parent + init(mode: Binding, wrapAround: Binding) { + self._mode = mode + self._wrapAround = wrapAround } @objc func openMenu(_ sender: NSButton) { @@ -156,12 +156,11 @@ struct FindModePicker: NSViewRepresentable { } @objc func modeSelected(_ sender: NSMenuItem) { - parent.mode = sender.tag == 0 ? .find : .replace - parent.onModeChange() + mode = sender.tag == 0 ? .find : .replace } @objc func toggleWrapAround(_ sender: NSMenuItem) { - parent.onToggleWrapAround() + wrapAround.toggle() } } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift deleted file mode 100644 index 1d9dd8b78..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// FindPanel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import SwiftUI -import AppKit -import Combine - -// NSView wrapper for using SwiftUI view in AppKit -final class FindPanel: NSView { - weak var findDelegate: FindPanelDelegate? - private var hostingView: NSHostingView! - private var viewModel: FindPanelViewModel! - private weak var textView: NSView? - private var isViewReady = false - private var findQueryText: String = "" // Store search text at panel level - private var eventMonitor: Any? - - init(delegate: FindPanelDelegate?, textView: NSView?) { - self.findDelegate = delegate - self.textView = textView - super.init(frame: .zero) - - viewModel = FindPanelViewModel(delegate: findDelegate) - viewModel.findText = findQueryText // Initialize with stored value - hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) - hostingView.translatesAutoresizingMaskIntoConstraints = false - - // Make the NSHostingView transparent - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear - - // Make the FindPanel itself transparent - self.wantsLayer = true - self.layer?.backgroundColor = .clear - - addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - if !isViewReady && superview != nil { - isViewReady = true - viewModel.startObservingFindText() - } - } - - deinit { - removeEventMonitor() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var fittingSize: NSSize { - hostingView.fittingSize - } - - // MARK: - First Responder Management - - override func becomeFirstResponder() -> Bool { - viewModel.setFocus(true) - return true - } - - override func resignFirstResponder() -> Bool { - viewModel.setFocus(false) - return true - } - - // MARK: - Event Monitor Management - - func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in - if event.keyCode == 53 { // if esc pressed - self.dismiss() - return nil // do not play "beep" sound - } - return event - } - } - - func removeEventMonitor() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - // MARK: - Public Methods - - func dismiss() { - viewModel.onDismiss() - } - - func updateMatchCount(_ count: Int) { - viewModel.updateMatchCount(count) - } - - func updateMode(_ mode: FindPanelMode) { - viewModel.mode = mode - } - - // MARK: - Search Text Management - - func updateSearchText(_ text: String) { - findQueryText = text - viewModel.findText = text - findDelegate?.findPanelDidUpdate(text) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift new file mode 100644 index 000000000..a02e4f7c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -0,0 +1,60 @@ +// +// FindPanelHostingView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import SwiftUI +import AppKit +import Combine + +// NSView wrapper for using SwiftUI view in AppKit +final class FindPanelHostingView: NSHostingView { + private weak var viewModel: FindPanelViewModel? + + private var eventMonitor: Any? + + init(viewModel: FindPanelViewModel) { + self.viewModel = viewModel + super.init(rootView: FindPanelView(viewModel: viewModel)) + + self.translatesAutoresizingMaskIntoConstraints = false + + self.wantsLayer = true + self.layer?.backgroundColor = .clear + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @MainActor @preconcurrency required init(rootView: FindPanelView) { + super.init(rootView: rootView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + removeEventMonitor() + } + + // MARK: - Event Monitor Management + + func addEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + self.viewModel?.dismiss?() + return nil // do not play "beep" sound + } + return event + } + } + + func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index edf115f9e..7b8fb551e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -10,184 +10,133 @@ import AppKit import CodeEditSymbols struct FindPanelView: View { + enum FindPanelFocus: Equatable { + case find + case replace + } + @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFindFieldFocused: Bool - @FocusState private var isReplaceFieldFocused: Bool + @State private var findModePickerWidth: CGFloat = 1.0 + + @FocusState private var focus: FindPanelFocus? var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.findText, - leadingAccessories: { - FindModePicker( - mode: $viewModel.mode, - wrapAround: $viewModel.wrapAround, - onToggleWrapAround: viewModel.toggleWrapAround, - onModeChange: { - isFindFieldFocused = true - if let textField = NSApp.keyWindow?.firstResponder as? NSTextView { - textField.selectAll(nil) - } - } - ) - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findModePickerWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findModePickerWidth = newWidth - } - }) - Divider() - }, - trailingAccessories: { - Divider() - Toggle(isOn: $viewModel.matchCase, label: { - Image(systemName: "textformat") - .font(.system( - size: 11, - weight: viewModel.matchCase ? .bold : .medium - )) - .foregroundStyle( - Color(nsColor: viewModel.matchCase - ? .controlAccentColor - : .labelColor - ) - ) - .frame(width: 30, height: 20) - }) - .toggleStyle(.icon) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true - ) - .controlSize(.small) - .focused($isFindFieldFocused) - .onChange(of: isFindFieldFocused) { newValue in - viewModel.setFocus(newValue || isReplaceFieldFocused) - } - .onSubmit { - viewModel.onSubmit() - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.prevButtonClicked) { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button(action: viewModel.onDismiss) { - Text("Done") - .padding(.horizontal, 5) - } - .buttonStyle(PanelButtonStyle()) + HStack(spacing: 5) { + VStack(alignment: .leading, spacing: 4) { + FindSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) + if viewModel.mode == .replace { + ReplaceSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) } - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findControlsWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findControlsWidth = newWidth - } - }) } - .padding(.horizontal, 5) - if viewModel.mode == .replace { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.replaceText, - leadingAccessories: { - HStack(spacing: 0) { - Image(systemName: "pencil") - .foregroundStyle(.secondary) - .padding(.leading, 8) - .padding(.trailing, 5) - Text("With") - } - .frame(width: viewModel.findModePickerWidth, alignment: .leading) - Divider() - }, - clearable: true - ) - .controlSize(.small) - .focused($isReplaceFieldFocused) - .onChange(of: isReplaceFieldFocused) { newValue in - viewModel.setFocus(newValue || isFindFieldFocused) - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.replaceButtonClicked) { - Text("Replace") - .opacity( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 ? 0.33 : 1 - ) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - // TODO: disable if there is not an active match - .disabled( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 - ) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.replaceAllButtonClicked) { - Text("All") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - } + VStack(alignment: .leading, spacing: 4) { + doneNextControls + if viewModel.mode == .replace { + Spacer(minLength: 0) + replaceControls } - .padding(.horizontal, 5) } + .fixedSize() } + .padding(.horizontal, 5) .frame(height: viewModel.panelHeight) .background(.bar) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) + .onChange(of: focus) { newValue in + viewModel.isFocused = newValue != nil } - .onChange(of: viewModel.replaceText) { newValue in - viewModel.onReplaceTextChange(newValue) + .onChange(of: viewModel.findText) { _ in + viewModel.findTextDidChange() } - .onChange(of: viewModel.mode) { newMode in - viewModel.onModeChange(newMode) + .onChange(of: viewModel.wrapAround) { _ in + viewModel.find() } - .onChange(of: viewModel.wrapAround) { newValue in - viewModel.onWrapAroundChange(newValue) - } - .onChange(of: viewModel.matchCase) { newValue in - viewModel.onMatchCaseChange(newValue) + .onChange(of: viewModel.matchCase) { _ in + viewModel.find() } .onChange(of: viewModel.isFocused) { newValue in - isFindFieldFocused = newValue - if !newValue { - viewModel.removeEmphasis() + if newValue { + if focus == nil { + focus = .find + } + if !viewModel.findText.isEmpty { + // Restore emphases when focus is regained and we have search text + viewModel.addMatchEmphases(flashCurrent: false) + } + } else { + viewModel.clearMatchEmphases() + } + } + } + + @ViewBuilder private var doneNextControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.moveToPreviousMatch() + } label: { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button { + viewModel.moveToNextMatch() + } label: { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button { + viewModel.dismiss?() + } label: { + Text("Done") + .padding(.horizontal, 5) + } + .buttonStyle(PanelButtonStyle()) + } + } + + @ViewBuilder private var replaceControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace(all: false) + } label: { + Text("Replace") + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) + } + // TODO: disable if there is not an active match + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) + .frame(maxWidth: .infinity) + + Divider().overlay(Color(nsColor: .tertiaryLabelColor)) + + Button { + viewModel.replace(all: true) + } label: { + Text("All") + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + } + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .frame(maxWidth: .infinity) } + .controlGroupStyle(PanelControlGroupStyle()) } + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift deleted file mode 100644 index fdc55b716..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// FindPanelViewModel.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import SwiftUI -import Combine - -enum FindPanelMode: CaseIterable { - case find - case replace - - var displayName: String { - switch self { - case .find: - return "Find" - case .replace: - return "Replace" - } - } -} - -class FindPanelViewModel: ObservableObject { - @Published var findText: String = "" - @Published var replaceText: String = "" - @Published var mode: FindPanelMode = .find - @Published var wrapAround: Bool = true - @Published var matchCount: Int = 0 - @Published var isFocused: Bool = false - @Published var findModePickerWidth: CGFloat = 0 - @Published var findControlsWidth: CGFloat = 0 - @Published var matchCase: Bool = false - - var panelHeight: CGFloat { - return mode == .replace ? 56 : 28 - } - - private weak var delegate: FindPanelDelegate? - - init(delegate: FindPanelDelegate?) { - self.delegate = delegate - } - - func startObservingFindText() { - if !findText.isEmpty { - delegate?.findPanelDidUpdate(findText) - } - } - - func onFindTextChange(_ text: String) { - delegate?.findPanelDidUpdate(text) - } - - func onReplaceTextChange(_ text: String) { - delegate?.findPanelDidUpdateReplaceText(text) - } - - func onModeChange(_ mode: FindPanelMode) { - delegate?.findPanelDidUpdateMode(mode) - } - - func onWrapAroundChange(_ wrapAround: Bool) { - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } - - func onMatchCaseChange(_ matchCase: Bool) { - delegate?.findPanelDidUpdateMatchCase(matchCase) - } - - func onSubmit() { - delegate?.findPanelOnSubmit() - } - - func onDismiss() { - delegate?.findPanelOnDismiss() - } - - func setFocus(_ focused: Bool) { - isFocused = focused - if focused && !findText.isEmpty { - // Restore emphases when focus is regained and we have search text - delegate?.findPanelDidUpdate(findText) - } - } - - func updateMatchCount(_ count: Int) { - matchCount = count - } - - func removeEmphasis() { - delegate?.findPanelClearEmphasis() - } - - func prevButtonClicked() { - delegate?.findPanelPrevButtonClicked() - } - - func nextButtonClicked() { - delegate?.findPanelNextButtonClicked() - } - - func replaceButtonClicked() { - delegate?.findPanelReplaceButtonClicked() - } - - func replaceAllButtonClicked() { - delegate?.findPanelReplaceAllButtonClicked() - } - - func toggleWrapAround() { - wrapAround.toggle() - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift new file mode 100644 index 000000000..00a528ee2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -0,0 +1,64 @@ +// +// FindSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct FindSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + findModePickerWidth = newWidth + } + }) + .focusable(false) + Divider() + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color(nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .find) + .onSubmit { + viewModel.moveToNextMatch() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift new file mode 100644 index 000000000..9e40721c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -0,0 +1,35 @@ +// +// ReplaceSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct ReplaceSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + HStack(spacing: 0) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: findModePickerWidth, alignment: .leading) + Divider() + }, + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .replace) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift new file mode 100644 index 000000000..74d411e45 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -0,0 +1,37 @@ +// +// FindPanelViewModel+Emphasis.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import CodeEditTextView + +extension FindPanelViewModel { + func addMatchEmphases(flashCurrent: Bool) { + guard let target = target, let emphasisManager = target.emphasisManager else { + return + } + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphases = findMatches.enumerated().map { index, range in + Emphasis( + range: range, + style: .standard, + flash: flashCurrent && index == currentFindMatchIndex, + inactive: index != currentFindMatchIndex, + selectInDocument: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) + } + + func clearMatchEmphases() { + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift new file mode 100644 index 000000000..438df586a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -0,0 +1,83 @@ +// +// FindPanelViewModel+Find.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation + +extension FindPanelViewModel { + // MARK: - Find + + /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis + /// manager's emphases. + func find() { + // Don't find if target or emphasisManager isn't ready or the query is empty + guard let target = target, isFocused, !findText.isEmpty else { + updateMatches([]) + return + } + + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: findText) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { + updateMatches([]) + return + } + + let text = target.text + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + + updateMatches(matches.map(\.range)) + + // Find the nearest match to the current cursor position + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 + addMatchEmphases(flashCurrent: false) + } + + // MARK: - Get Nearest Emphasis Index + + private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = target?.cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = matchRanges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = matchRanges[mid].location + let diff = abs(midStart - start) + + // If it's an exact match, return immediately + if diff == 0 { + return mid + } + + // If this is the closest so far, update the best index + if diff < bestDiff { + bestDiff = diff + bestIndex = mid + } + + // Move left or right based on the cursor position + if midStart < start { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return bestIndex >= 0 ? bestIndex : nil + } + +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift new file mode 100644 index 000000000..66b0d6b2b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -0,0 +1,64 @@ +// +// FindPanelViewModel+Move.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import AppKit + +extension FindPanelViewModel { + func moveToNextMatch() { + moveMatch(forwards: true) + } + + func moveToPreviousMatch() { + moveMatch(forwards: false) + } + + private func moveMatch(forwards: Bool) { + guard let target = target else { return } + + guard !findMatches.isEmpty else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + // From here on out we want to emphasize the result no matter what + defer { addMatchEmphases(flashCurrent: isTargetFirstResponder) } + + guard let currentFindMatchIndex else { + self.currentFindMatchIndex = 0 + return + } + + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 + guard !isAtLimit || wrapAround else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + self.currentFindMatchIndex = if forwards { + (currentFindMatchIndex + 1) % findMatches.count + } else { + (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count + } + if isAtLimit { + showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + } + } + + private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { + if error { + NSSound.beep() + } + BezelNotification.show( + symbolName: error ? + forwards ? "arrow.up.to.line" : "arrow.down.to.line" + : forwards + ? "arrow.trianglehead.topright.capsulepath.clockwise" + : "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: targetView + ) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift new file mode 100644 index 000000000..278765b1f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -0,0 +1,69 @@ +// +// FindPanelViewModel+Replace.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation +import CodeEditTextView + +extension FindPanelViewModel { + /// Replace one or all ``findMatches`` with the contents of ``replaceText``. + /// - Parameter all: If true, replaces all matches instead of just the selected one. + func replace(all: Bool) { + guard let target = target, + let currentFindMatchIndex, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { + return + } + + if all { + textViewController.textView.undoManager?.beginUndoGrouping() + textViewController.textView.textStorage.beginEditing() + + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) + } + + textViewController.textView.textStorage.endEditing() + textViewController.textView.undoManager?.endUndoGrouping() + + if let lastMatch = sortedMatches.last { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) + } + + updateMatches([]) + } else { + replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) + updateMatches(findMatches) + } + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + /// Replace a single match in the text view, updating all other find matches with any length changes. + /// - Parameters: + /// - index: The index of the match to replace in the `matches` array. + /// - textView: The text view to replace characters in. + /// - matches: The array of matches to use and update. + private func replaceMatch(index: Int, textView: TextView, matches: inout [NSRange]) { + let range = matches[index] + // Set cursor positions to the match range + textView.replaceCharacters(in: range, with: replaceText) + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - range.length + + // Update all match ranges after the current match + for idx in matches.dropFirst(index + 1).indices { + matches[idx].location -= lengthDiff + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift new file mode 100644 index 000000000..19f62018b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -0,0 +1,99 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine +import CodeEditTextView + +class FindPanelViewModel: ObservableObject { + weak var target: FindPanelTarget? + var dismiss: (() -> Void)? + + @Published var findMatches: [NSRange] = [] + @Published var currentFindMatchIndex: Int? + @Published var isShowingFindPanel: Bool = false + + @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find { + didSet { + self.target?.findPanelModeDidChange(to: mode) + } + } + + @Published var isFocused: Bool = false + + @Published var matchCase: Bool = false + @Published var wrapAround: Bool = true + + /// The height of the find panel. + var panelHeight: CGFloat { + return mode == .replace ? 54 : 28 + } + + /// The number of current find matches. + var matchCount: Int { + findMatches.count + } + + var isTargetFirstResponder: Bool { + target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView + } + + init(target: FindPanelTarget) { + self.target = target + + // Add notification observer for text changes + if let textViewController = target as? TextViewController { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: TextView.textDidChangeNotification, + object: textViewController.textView + ) + } + } + + // MARK: - Update Matches + + func updateMatches(_ newMatches: [NSRange]) { + findMatches = newMatches + currentFindMatchIndex = newMatches.isEmpty ? nil : 0 + } + + // MARK: - Text Listeners + + /// Find target's text content changed, we need to re-search the contents and emphasize results. + @objc private func textDidChange() { + // Only update if we have find text + if !findText.isEmpty { + find() + } + } + + /// The contents of the find search field changed, trigger related events. + func findTextDidChange() { + // Check if this update was triggered by a return key without shift + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .keyDown, + currentEvent.keyCode == 36, // Return key + !currentEvent.modifierFlags.contains(.shift) { + return // Skip find for regular return key + } + + // If the textview is first responder, exit fast + if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { + // If the text view has focus, just clear visual emphases but keep our find matches + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + return + } + + // Clear existing emphases before performing new find + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + find() + } +} diff --git a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift index cda97dbca..9c66afc67 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift @@ -33,8 +33,6 @@ struct PanelTextField: View var onClear: (() -> Void) - var hasValue: Bool - init( _ label: String, text: Binding, @@ -43,8 +41,7 @@ struct PanelTextField: View @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, helperText: String? = nil, clearable: Bool? = false, - onClear: (() -> Void)? = {}, - hasValue: Bool? = false + onClear: (() -> Void)? = {} ) { self.label = label _text = text @@ -54,15 +51,14 @@ struct PanelTextField: View self.helperText = helperText ?? nil self.clearable = clearable ?? false self.onClear = onClear ?? {} - self.hasValue = hasValue ?? false } @ViewBuilder public func selectionBackground( _ isFocused: Bool = false ) -> some View { - if self.controlActive != .inactive || !text.isEmpty || hasValue { - if isFocused || !text.isEmpty || hasValue { + if self.controlActive != .inactive || !text.isEmpty { + if isFocused || !text.isEmpty { Color(.textBackgroundColor) } else { if colorScheme == .light { @@ -135,7 +131,7 @@ struct PanelTextField: View ) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .stroke(isFocused || !text.isEmpty ? .tertiary : .quaternary, lineWidth: 1.25) .clipShape(RoundedRectangle(cornerRadius: 6)) .disabled(true) .edgesIgnoringSafeArea(.all) From 35df1a218a1ada5f4359afd7a8d5f8e844497c74 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:16:02 -0500 Subject: [PATCH 24/37] Fix Fialing Test --- .../TextViewControllerTests.swift | 5 ++- .../FindPanelViewModelTests.swift | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) rename Tests/CodeEditSourceEditorTests/{ => Controller}/TextViewControllerTests.swift (99%) create mode 100644 Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift similarity index 99% rename from Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift rename to Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index 956a763d9..8088920ce 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -162,12 +162,13 @@ final class TextViewControllerTests: XCTestCase { controller.findViewController?.showFindPanel(animated: false) // Extra insets do not effect find panel's insets + let findModel = try XCTUnwrap(controller.findViewController) try assertInsetsEqual( scrollView.contentInsets, - NSEdgeInsets(top: 10 + FindPanel.height, left: 0, bottom: 10, right: 0) + NSEdgeInsets(top: 10 + findModel.viewModel.panelHeight, left: 0, bottom: 10, right: 0) ) XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) - XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - FindPanel.height) + XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - findModel.viewModel.panelHeight) } func test_editorOverScroll_ZeroCondition() throws { diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift new file mode 100644 index 000000000..8f98681bd --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift @@ -0,0 +1,40 @@ +// +// 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] = [] + + @MainActor init() { + findPanelTargetView = NSView() + } + + 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) + } +} From 86d20a782e7c6896bafc50a01e35469170ea6a53 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 29 Apr 2025 14:58:54 -0500 Subject: [PATCH 25/37] Fixed most of the bugs introduced in the find replace refactor. --- .../Find/PanelView/FindPanelView.swift | 4 +- .../FindPanelViewModel+Emphasis.swift | 27 ++++++++++ .../ViewModel/FindPanelViewModel+Find.swift | 7 +-- .../ViewModel/FindPanelViewModel+Move.swift | 10 +++- .../FindPanelViewModel+Replace.swift | 52 ++++++++++++------- .../Find/ViewModel/FindPanelViewModel.swift | 7 --- 6 files changed, 73 insertions(+), 34 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 7b8fb551e..55ccfcd06 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -106,7 +106,7 @@ struct FindPanelView: View { HStack(spacing: 4) { ControlGroup { Button { - viewModel.replace(all: false) + viewModel.replace() } label: { Text("Replace") .opacity( @@ -126,7 +126,7 @@ struct FindPanelView: View { Divider().overlay(Color(nsColor: .tertiaryLabelColor)) Button { - viewModel.replace(all: true) + viewModel.replaceAll() } label: { Text("All") .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift index 74d411e45..7757ca1bd 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -31,6 +31,33 @@ extension FindPanelViewModel { emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) } + func flashCurrentMatch() { + guard let target = target, + let emphasisManager = target.emphasisManager, + let currentFindMatchIndex else { + return + } + + let currentMatch = findMatches[currentFindMatchIndex] + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphasis = ( + Emphasis( + range: currentMatch, + style: .standard, + flash: true, + inactive: false, + selectInDocument: true + ) + ) + + // Add the emphasis + emphasisManager.addEmphases([emphasis], for: EmphasisGroup.find) + } + func clearMatchEmphases() { target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index 438df586a..efbf416f4 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -15,7 +15,7 @@ extension FindPanelViewModel { func find() { // Don't find if target or emphasisManager isn't ready or the query is empty guard let target = target, isFocused, !findText.isEmpty else { - updateMatches([]) + self.findMatches = [] return } @@ -24,14 +24,15 @@ extension FindPanelViewModel { let escapedQuery = NSRegularExpression.escapedPattern(for: findText) guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { - updateMatches([]) + self.findMatches = [] + self.currentFindMatchIndex = 0 return } let text = target.text let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - updateMatches(matches.map(\.range)) + self.findMatches = matches.map(\.range) // Find the nearest match to the current cursor position currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift index 66b0d6b2b..726598b7c 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -25,7 +25,13 @@ extension FindPanelViewModel { } // From here on out we want to emphasize the result no matter what - defer { addMatchEmphases(flashCurrent: isTargetFirstResponder) } + defer { + if isTargetFirstResponder { + flashCurrentMatch() + } else { + addMatchEmphases(flashCurrent: isTargetFirstResponder) + } + } guard let currentFindMatchIndex else { self.currentFindMatchIndex = 0 @@ -54,7 +60,7 @@ extension FindPanelViewModel { } BezelNotification.show( symbolName: error ? - forwards ? "arrow.up.to.line" : "arrow.down.to.line" + forwards ? "arrow.down.to.line" : "arrow.up.to.line" : forwards ? "arrow.trianglehead.topright.capsulepath.clockwise" : "arrow.trianglehead.bottomleft.capsulepath.clockwise", diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index 278765b1f..e8016555e 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -11,7 +11,7 @@ import CodeEditTextView extension FindPanelViewModel { /// Replace one or all ``findMatches`` with the contents of ``replaceText``. /// - Parameter all: If true, replaces all matches instead of just the selected one. - func replace(all: Bool) { + func replace() { guard let target = target, let currentFindMatchIndex, !findMatches.isEmpty, @@ -19,31 +19,43 @@ extension FindPanelViewModel { return } - if all { - textViewController.textView.undoManager?.beginUndoGrouping() - textViewController.textView.textStorage.beginEditing() + replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) - var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) - for (idx, _) in sortedMatches.enumerated().reversed() { - replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) - } + self.findMatches = findMatches.enumerated().filter({ $0.offset != currentFindMatchIndex }).map(\.element) + self.currentFindMatchIndex = findMatches.isEmpty ? nil : (currentFindMatchIndex) % findMatches.count - textViewController.textView.textStorage.endEditing() - textViewController.textView.undoManager?.endUndoGrouping() + // Update the emphases + addMatchEmphases(flashCurrent: true) + } - if let lastMatch = sortedMatches.last { - target.setCursorPositions( - [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], - scrollToVisible: true - ) - } + func replaceAll() { + guard let target = target, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { + return + } + + textViewController.textView.undoManager?.beginUndoGrouping() + textViewController.textView.textStorage.beginEditing() + + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) + } + + textViewController.textView.textStorage.endEditing() + textViewController.textView.undoManager?.endUndoGrouping() - updateMatches([]) - } else { - replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) - updateMatches(findMatches) + if let lastMatch = sortedMatches.last { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) } + self.findMatches = [] + self.currentFindMatchIndex = nil + // Update the emphases addMatchEmphases(flashCurrent: true) } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 19f62018b..968b3ef7c 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -58,13 +58,6 @@ class FindPanelViewModel: ObservableObject { } } - // MARK: - Update Matches - - func updateMatches(_ newMatches: [NSRange]) { - findMatches = newMatches - currentFindMatchIndex = newMatches.isEmpty ? nil : 0 - } - // MARK: - Text Listeners /// Find target's text content changed, we need to re-search the contents and emphasize results. From d23caf62cfa9c93eebb1e0b3b5cdc864b100f746 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 2 May 2025 11:13:58 -0500 Subject: [PATCH 26/37] Made find panel responsive to width. When too narrow control text turns to icons. --- .../Find/PanelView/FindControls.swift | 0 .../Find/PanelView/FindPanelContent.swift | 35 +++++++++++++++++++ .../Find/PanelView/ReplaceControls.swift | 0 3 files changed, 35 insertions(+) create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift new file mode 100644 index 000000000..b37341783 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift @@ -0,0 +1,35 @@ +struct FindPanelContent: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + var findModePickerWidth: Binding + var condensed: Bool + + var body: some View { + HStack(spacing: 5) { + VStack(alignment: .leading, spacing: 4) { + FindSearchField( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: findModePickerWidth, + condensed: condensed + ) + if viewModel.mode == .replace { + ReplaceSearchField( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: findModePickerWidth, + condensed: condensed + ) + } + } + VStack(alignment: .leading, spacing: 4) { + FindControls(viewModel: viewModel, condensed: condensed) + if viewModel.mode == .replace { + Spacer(minLength: 0) + ReplaceControls(viewModel: viewModel, condensed: condensed) + } + } + .fixedSize() + } + } +} \ No newline at end of file diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift new file mode 100644 index 000000000..e69de29bb From b683d1cf375c071201697fa0361a319a1e01b51d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 2 May 2025 11:42:00 -0500 Subject: [PATCH 27/37] Remove Hard Dependency on `TextViewController` --- Package.resolved | 9 +++++++++ .../Find/FindPanelTarget.swift | 3 +-- .../Find/PanelView/FindSearchField.swift | 1 + .../Find/PanelView/ReplaceSearchField.swift | 1 + .../FindPanelViewModel+Emphasis.swift | 6 +++--- .../ViewModel/FindPanelViewModel+Find.swift | 2 +- .../ViewModel/FindPanelViewModel+Replace.swift | 18 ++++++++---------- .../Find/ViewModel/FindPanelViewModel.swift | 4 ++-- .../MinimapView+DocumentVisibleView.swift | 2 +- 9 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Package.resolved b/Package.resolved index b646b2e64..1d320e4a6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeeditsymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", + "state" : { + "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", + "version" : "0.2.3" + } + }, { "identity" : "codeedittextview", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index 90a286715..640b00166 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -9,8 +9,7 @@ import AppKit import CodeEditTextView protocol FindPanelTarget: AnyObject { - var emphasisManager: EmphasisManager? { get } - var text: String { get } + var textView: TextView! { get } var findPanelTargetView: NSView { get } var cursorPositions: [CursorPosition] { get } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index 00a528ee2..1821bdcf2 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -56,6 +56,7 @@ struct FindSearchField: View { clearable: true ) .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) .focused($focus, equals: .find) .onSubmit { viewModel.moveToNextMatch() diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift index 9e40721c6..68537a245 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -30,6 +30,7 @@ struct ReplaceSearchField: View { clearable: true ) .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) .focused($focus, equals: .replace) } } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift index 7757ca1bd..adcebcd8e 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -9,7 +9,7 @@ import CodeEditTextView extension FindPanelViewModel { func addMatchEmphases(flashCurrent: Bool) { - guard let target = target, let emphasisManager = target.emphasisManager else { + guard let target = target, let emphasisManager = target.textView.emphasisManager else { return } @@ -33,7 +33,7 @@ extension FindPanelViewModel { func flashCurrentMatch() { guard let target = target, - let emphasisManager = target.emphasisManager, + let emphasisManager = target.textView.emphasisManager, let currentFindMatchIndex else { return } @@ -59,6 +59,6 @@ extension FindPanelViewModel { } func clearMatchEmphases() { - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) } } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index efbf416f4..1f0fa344e 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -29,7 +29,7 @@ extension FindPanelViewModel { return } - let text = target.text + let text = target.textView.string let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) self.findMatches = matches.map(\.range) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index e8016555e..429846e56 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -14,12 +14,11 @@ extension FindPanelViewModel { func replace() { guard let target = target, let currentFindMatchIndex, - !findMatches.isEmpty, - let textViewController = target as? TextViewController else { + !findMatches.isEmpty else { return } - replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) + replaceMatch(index: currentFindMatchIndex, textView: target.textView, matches: &findMatches) self.findMatches = findMatches.enumerated().filter({ $0.offset != currentFindMatchIndex }).map(\.element) self.currentFindMatchIndex = findMatches.isEmpty ? nil : (currentFindMatchIndex) % findMatches.count @@ -30,21 +29,20 @@ extension FindPanelViewModel { func replaceAll() { guard let target = target, - !findMatches.isEmpty, - let textViewController = target as? TextViewController else { + !findMatches.isEmpty else { return } - textViewController.textView.undoManager?.beginUndoGrouping() - textViewController.textView.textStorage.beginEditing() + target.textView.undoManager?.beginUndoGrouping() + target.textView.textStorage.beginEditing() var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) for (idx, _) in sortedMatches.enumerated().reversed() { - replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) + replaceMatch(index: idx, textView: target.textView, matches: &sortedMatches) } - textViewController.textView.textStorage.endEditing() - textViewController.textView.undoManager?.endUndoGrouping() + target.textView.textStorage.endEditing() + target.textView.undoManager?.endUndoGrouping() if let lastMatch = sortedMatches.last { target.setCursorPositions( diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 968b3ef7c..c22b9fb23 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -81,12 +81,12 @@ class FindPanelViewModel: ObservableObject { // If the textview is first responder, exit fast if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { // If the text view has focus, just clear visual emphases but keep our find matches - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) return } // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) find() } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index 70fe4d9e6..cd3059eb6 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -24,7 +24,7 @@ extension MinimapView { /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that /// percentage. The ``scrollView`` is only modified if the minimap is longer than the container view. func updateDocumentVisibleViewPosition() { - guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { + guard let textView = textView, let editorScrollView = textView.enclosingScrollView else { return } From 923cb88dd2a4b09bcdc68cc7d7481d077aade488 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 2 May 2025 11:48:53 -0500 Subject: [PATCH 28/37] Added to the last commit, forgot to stage all. --- .../Find/PanelView/FindControls.swift | 56 +++++++++++ .../Find/PanelView/FindPanelContent.swift | 11 ++- .../Find/PanelView/FindPanelView.swift | 99 +++---------------- .../Find/PanelView/FindSearchField.swift | 58 ++++++++--- .../Find/PanelView/ReplaceControls.swift | 63 ++++++++++++ .../Find/PanelView/ReplaceSearchField.swift | 19 +++- 6 files changed, 198 insertions(+), 108 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift index e69de29bb..6f923c0b2 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -0,0 +1,56 @@ +// +// FindControls.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/30/25. +// + +import SwiftUI + +struct FindControls: View { + @ObservedObject var viewModel: FindPanelViewModel + var condensed: Bool + + var body: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.moveToPreviousMatch() + } label: { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, condensed ? 0 : 5) + } + .help("Previous Match") + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button { + viewModel.moveToNextMatch() + } label: { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, condensed ? 0 : 5) + } + .help("Next Match") + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button { + viewModel.dismiss?() + } label: { + Group { + if condensed { + Image(systemName: "xmark") + } else { + Text("Done") + } + } + .help(condensed ? "Done" : "") + .padding(.horizontal, condensed ? 0 : 5) + } + .buttonStyle(PanelButtonStyle()) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift index b37341783..05d1f3dfb 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift @@ -1,3 +1,12 @@ +// +// FindPanelContent.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 5/2/25. +// + +import SwiftUI + struct FindPanelContent: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? @@ -32,4 +41,4 @@ struct FindPanelContent: View { .fixedSize() } } -} \ No newline at end of file +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 55ccfcd06..586616593 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -22,21 +22,19 @@ struct FindPanelView: View { @FocusState private var focus: FindPanelFocus? var body: some View { - HStack(spacing: 5) { - VStack(alignment: .leading, spacing: 4) { - FindSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) - if viewModel.mode == .replace { - ReplaceSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) - } - } - VStack(alignment: .leading, spacing: 4) { - doneNextControls - if viewModel.mode == .replace { - Spacer(minLength: 0) - replaceControls - } - } - .fixedSize() + ViewThatFits { + FindPanelContent( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: $findModePickerWidth, + condensed: false + ) + FindPanelContent( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: $findModePickerWidth, + condensed: true + ) } .padding(.horizontal, 5) .frame(height: viewModel.panelHeight) @@ -67,77 +65,6 @@ struct FindPanelView: View { } } } - - @ViewBuilder private var doneNextControls: some View { - HStack(spacing: 4) { - ControlGroup { - Button { - viewModel.moveToPreviousMatch() - } label: { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button { - viewModel.moveToNextMatch() - } label: { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button { - viewModel.dismiss?() - } label: { - Text("Done") - .padding(.horizontal, 5) - } - .buttonStyle(PanelButtonStyle()) - } - } - - @ViewBuilder private var replaceControls: some View { - HStack(spacing: 4) { - ControlGroup { - Button { - viewModel.replace() - } label: { - Text("Replace") - .opacity( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 ? 0.33 : 1 - ) - } - // TODO: disable if there is not an active match - .disabled( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 - ) - .frame(maxWidth: .infinity) - - Divider().overlay(Color(nsColor: .tertiaryLabelColor)) - - Button { - viewModel.replaceAll() - } label: { - Text("All") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) - } - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) - .frame(maxWidth: .infinity) - } - .controlGroupStyle(PanelControlGroupStyle()) - } - .fixedSize(horizontal: false, vertical: true) - } } private struct FindModePickerWidthPreferenceKey: PreferenceKey { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index 1821bdcf2..dc8864e8c 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -11,26 +11,49 @@ struct FindSearchField: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? @Binding var findModePickerWidth: CGFloat + var condensed: Bool var body: some View { PanelTextField( "Text", text: $viewModel.findText, leadingAccessories: { - FindModePicker( - mode: $viewModel.mode, - wrapAround: $viewModel.wrapAround - ) - .background(GeometryReader { geometry in - Color.clear.onAppear { - findModePickerWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - findModePickerWidth = newWidth + if condensed { + Color.clear + .frame(width: 12, height: 12) + .foregroundStyle(.secondary) + .padding(.leading, 8) + .overlay(alignment: .leading) { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + } + .clipped() + .overlay(alignment: .trailing) { + Image(systemName: "chevron.down") + .foregroundStyle(.secondary) + .font(.system(size: 5, weight: .black)) + .padding(.leading, 4).padding(.trailing, -4) + } + } else { + HStack(spacing: 0) { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + findModePickerWidth = newWidth + } + }) + .focusable(false) + Divider() } - }) - .focusable(false) - Divider() + } }, trailingAccessories: { Divider() @@ -41,10 +64,11 @@ struct FindSearchField: View { weight: viewModel.matchCase ? .bold : .medium )) .foregroundStyle( - Color(nsColor: viewModel.matchCase + Color( + nsColor: viewModel.matchCase ? .controlAccentColor : .labelColor - ) + ) ) .frame(width: 30, height: 20) }) @@ -52,7 +76,9 @@ struct FindSearchField: View { }, helperText: viewModel.findText.isEmpty ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + : condensed + ? "\(viewModel.matchCount)" + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", clearable: true ) .controlSize(.small) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift index e69de29bb..215f4bab8 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -0,0 +1,63 @@ +// +// ReplaceControls.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/30/25. +// + +import SwiftUI + +struct ReplaceControls: View { + @ObservedObject var viewModel: FindPanelViewModel + var condensed: Bool + + var body: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace() + } label: { + Group { + if condensed { + Image(systemName: "arrow.turn.up.right") + } else { + Text("Replace") + } + } + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) + } + .help(condensed ? "Replace" : "") + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) + .frame(maxWidth: .infinity) + + Divider().overlay(Color(nsColor: .tertiaryLabelColor)) + + Button { + viewModel.replaceAll() + } label: { + Group { + if condensed { + Image(systemName: "text.insert") + } else { + Text("All") + } + } + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + } + .help(condensed ? "Replace All" : "") + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .frame(maxWidth: .infinity) + } + .controlGroupStyle(PanelControlGroupStyle()) + } + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift index 68537a245..6fefe9b7e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -11,21 +11,30 @@ struct ReplaceSearchField: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? @Binding var findModePickerWidth: CGFloat + var condensed: Bool var body: some View { PanelTextField( "Text", text: $viewModel.replaceText, leadingAccessories: { - HStack(spacing: 0) { + if condensed { Image(systemName: "pencil") .foregroundStyle(.secondary) .padding(.leading, 8) - .padding(.trailing, 5) - Text("With") + } else { + HStack(spacing: 0) { + HStack(spacing: 0) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: findModePickerWidth, alignment: .leading) + Divider() + } } - .frame(width: findModePickerWidth, alignment: .leading) - Divider() }, clearable: true ) From 9abe5aec6cc52e27618dc5fcced2c55b53b0833b Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 5 May 2025 21:32:40 -0500 Subject: [PATCH 29/37] Maintain find matches when typing, respect wrap around preference when replacing --- .../Find/ViewModel/FindPanelViewModel+Find.swift | 10 +++++++--- .../Find/ViewModel/FindPanelViewModel+Replace.swift | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index 1f0fa344e..ea75fff2c 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -13,8 +13,8 @@ extension FindPanelViewModel { /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis /// manager's emphases. func find() { - // Don't find if target or emphasisManager isn't ready or the query is empty - guard let target = target, isFocused, !findText.isEmpty else { + // Don't find if target isn't ready or the query is empty + guard let target = target, !findText.isEmpty else { self.findMatches = [] return } @@ -36,7 +36,11 @@ extension FindPanelViewModel { // Find the nearest match to the current cursor position currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 - addMatchEmphases(flashCurrent: false) + + // Only add emphasis layers if the find panel is focused + if isFocused { + addMatchEmphases(flashCurrent: false) + } } // MARK: - Get Nearest Emphasis Index diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index 429846e56..6d23af408 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -21,7 +21,16 @@ extension FindPanelViewModel { replaceMatch(index: currentFindMatchIndex, textView: target.textView, matches: &findMatches) self.findMatches = findMatches.enumerated().filter({ $0.offset != currentFindMatchIndex }).map(\.element) - self.currentFindMatchIndex = findMatches.isEmpty ? nil : (currentFindMatchIndex) % findMatches.count + + // Update currentFindMatchIndex based on wrapAround setting + if findMatches.isEmpty { + self.currentFindMatchIndex = nil + } else if wrapAround { + self.currentFindMatchIndex = currentFindMatchIndex % findMatches.count + } else { + // If we're at the end and not wrapping, stay at the end + self.currentFindMatchIndex = min(currentFindMatchIndex, findMatches.count - 1) + } // Update the emphases addMatchEmphases(flashCurrent: true) From 080f0cccf49444e51ae582b06dcab95e744ae6ec Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 5 May 2025 22:08:08 -0500 Subject: [PATCH 30/37] Fixed issue where reformatting guide was not allowing text view to get focus when switching away from the find panel. --- .../ReformattingGuide/ReformattingGuideView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index bb395ee28..4841454fa 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -28,6 +28,10 @@ class ReformattingGuideView: NSView { fatalError("init(coder:) has not been implemented") } + override func hitTest(_ point: NSPoint) -> NSView? { + return nil + } + // Draw the reformatting guide line and shaded area override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) From e840a648877748ddb0f56a1e0b6370b411e6a502 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 5 May 2025 22:13:58 -0500 Subject: [PATCH 31/37] Fixed failing test --- Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift index 8f98681bd..ba2eb1530 100644 --- a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift @@ -17,9 +17,11 @@ struct FindPanelViewModelTests { 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) { } From 9161bef86ac7520227dbc10b19bc1ef9676957ed Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 6 May 2025 11:13:08 -0500 Subject: [PATCH 32/37] Added file header --- .../ReformattingGuide/ReformattingGuideView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 4841454fa..01b8108e4 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -1,3 +1,10 @@ +// +// FindViewController+Toggle.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/28/25. +// + import AppKit import CodeEditTextView From 83b986549384452541c071beda7f1a0e713fdfac Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 7 May 2025 16:32:52 -0500 Subject: [PATCH 33/37] Clean Up Boolean Statements in Views --- .../Find/PanelView/FindControls.swift | 26 +++++++++++------ .../Find/PanelView/FindPanelHostingView.swift | 2 +- .../Find/PanelView/FindSearchField.swift | 16 +++++++---- .../Find/PanelView/ReplaceControls.swift | 28 +++++++++---------- .../Find/ViewModel/FindPanelViewModel.swift | 4 +++ 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift index 6f923c0b2..e7b7116e8 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -11,6 +11,14 @@ struct FindControls: View { @ObservedObject var viewModel: FindPanelViewModel var condensed: Bool + var imageOpacity: CGFloat { + viewModel.matchesEmpty ? 0.33 : 1 + } + + var dynamicPadding: CGFloat { + condensed ? 0 : 5 + } + var body: some View { HStack(spacing: 4) { ControlGroup { @@ -18,25 +26,27 @@ struct FindControls: View { viewModel.moveToPreviousMatch() } label: { Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, condensed ? 0 : 5) + .opacity(imageOpacity) + .padding(.horizontal, dynamicPadding) } .help("Previous Match") - .disabled(viewModel.matchCount == 0) + .disabled(viewModel.matchesEmpty) + Divider() .overlay(Color(nsColor: .tertiaryLabelColor)) Button { viewModel.moveToNextMatch() } label: { Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, condensed ? 0 : 5) + .opacity(imageOpacity) + .padding(.horizontal, dynamicPadding) } .help("Next Match") - .disabled(viewModel.matchCount == 0) + .disabled(viewModel.matchesEmpty) } .controlGroupStyle(PanelControlGroupStyle()) .fixedSize() + Button { viewModel.dismiss?() } label: { @@ -47,8 +57,8 @@ struct FindControls: View { Text("Done") } } - .help(condensed ? "Done" : "") - .padding(.horizontal, condensed ? 0 : 5) + .help("Done") + .padding(.horizontal, dynamicPadding) } .buttonStyle(PanelButtonStyle()) } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift index a02e4f7c6..bbc0a3269 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -9,7 +9,7 @@ import SwiftUI import AppKit import Combine -// NSView wrapper for using SwiftUI view in AppKit +/// A subclass of `NSHostingView` that hosts a `FindPanelView` in an AppKit context. final class FindPanelHostingView: NSHostingView { private weak var viewModel: FindPanelViewModel? diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index dc8864e8c..a9fd0f194 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -13,6 +13,16 @@ struct FindSearchField: View { @Binding var findModePickerWidth: CGFloat var condensed: Bool + private var helperText: String? { + if viewModel.findText.isEmpty { + nil + } else if condensed { + "\(viewModel.matchCount)" + } else { + "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")" + } + } + var body: some View { PanelTextField( "Text", @@ -74,11 +84,7 @@ struct FindSearchField: View { }) .toggleStyle(.icon) }, - helperText: viewModel.findText.isEmpty - ? nil - : condensed - ? "\(viewModel.matchCount)" - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + helperText: helperText, clearable: true ) .controlSize(.small) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift index 215f4bab8..92140eaa3 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -11,6 +11,14 @@ struct ReplaceControls: View { @ObservedObject var viewModel: FindPanelViewModel var condensed: Bool + var shouldDisableSingle: Bool { + !viewModel.isFocused || viewModel.findText.isEmpty || viewModel.matchesEmpty + } + + var shouldDisableAll: Bool { + viewModel.findText.isEmpty || viewModel.matchesEmpty + } + var body: some View { HStack(spacing: 4) { ControlGroup { @@ -24,18 +32,10 @@ struct ReplaceControls: View { Text("Replace") } } - .opacity( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 ? 0.33 : 1 - ) + .opacity(shouldDisableSingle ? 0.33 : 1) } - .help(condensed ? "Replace" : "") - .disabled( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 - ) + .help("Replace") + .disabled(shouldDisableSingle) .frame(maxWidth: .infinity) Divider().overlay(Color(nsColor: .tertiaryLabelColor)) @@ -50,10 +50,10 @@ struct ReplaceControls: View { Text("All") } } - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + .opacity(shouldDisableAll ? 0.33 : 1) } - .help(condensed ? "Replace All" : "") - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .help("Replace All") + .disabled(shouldDisableAll) .frame(maxWidth: .infinity) } .controlGroupStyle(PanelControlGroupStyle()) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index c22b9fb23..d6975d112 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -40,6 +40,10 @@ class FindPanelViewModel: ObservableObject { findMatches.count } + var matchesEmpty: Bool { + matchCount == 0 + } + var isTargetFirstResponder: Bool { target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView } From 5875e40c69b84de9e8a50b3a736ed25eddeb46b4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 8 May 2025 09:21:01 -0500 Subject: [PATCH 34/37] Revert Help Modifier Changes --- .../CodeEditSourceEditor/Find/PanelView/FindControls.swift | 2 +- .../CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift index e7b7116e8..b79c854d3 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -57,7 +57,7 @@ struct FindControls: View { Text("Done") } } - .help("Done") + .help(condensed ? "Done" : "") .padding(.horizontal, dynamicPadding) } .buttonStyle(PanelButtonStyle()) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift index 92140eaa3..a9ea0fe5c 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -34,7 +34,7 @@ struct ReplaceControls: View { } .opacity(shouldDisableSingle ? 0.33 : 1) } - .help("Replace") + .help(condensed ? "Replace" : "") .disabled(shouldDisableSingle) .frame(maxWidth: .infinity) @@ -52,7 +52,7 @@ struct ReplaceControls: View { } .opacity(shouldDisableAll ? 0.33 : 1) } - .help("Replace All") + .help(condensed ? "Replace All" : "") .disabled(shouldDisableAll) .frame(maxWidth: .infinity) } From 4efb3e5aa29b09130b7372431216a81e73187bbe Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 8 May 2025 23:20:09 -0500 Subject: [PATCH 35/37] Wrote documentation for find subviews. --- .../Find/PanelView/FindControls.swift | 11 +++++++++++ .../Find/PanelView/FindModePicker.swift | 11 +++++++++++ .../Find/PanelView/FindPanelContent.swift | 10 ++++++++++ .../Find/PanelView/FindPanelHostingView.swift | 13 ++++++++++++- .../Find/PanelView/FindPanelView.swift | 15 +++++++++++++++ .../Find/PanelView/FindSearchField.swift | 11 +++++++++++ .../Find/PanelView/ReplaceControls.swift | 11 +++++++++++ .../Find/PanelView/ReplaceSearchField.swift | 10 ++++++++++ 8 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift index b79c854d3..6ed9d30dd 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -7,6 +7,17 @@ import SwiftUI +/// A SwiftUI view that provides the navigation controls for the find panel. +/// +/// The `FindControls` view is responsible for: +/// - Displaying previous/next match navigation buttons +/// - Showing a done button to dismiss the find panel +/// - Adapting button appearance based on match count +/// - Supporting both condensed and full layouts +/// - Providing tooltips for button actions +/// +/// The view is part of the find panel's control section and works in conjunction with +/// the find text field to provide navigation through search results. struct FindControls: View { @ObservedObject var viewModel: FindPanelViewModel var condensed: Bool diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index 8245681aa..e7a076a13 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -7,6 +7,17 @@ import SwiftUI +/// A SwiftUI view that provides a mode picker for the find panel. +/// +/// The `FindModePicker` view is responsible for: +/// - Displaying a dropdown menu to switch between find and replace modes +/// - Managing the wrap around option for search +/// - Providing a visual indicator (magnifying glass icon) for the mode picker +/// - Adapting its appearance based on the control's active state +/// - Handling mode selection and wrap around toggling +/// +/// The view works in conjunction with the find panel to manage the current search mode +/// and wrap around settings. struct FindModePicker: NSViewRepresentable { @Binding var mode: FindPanelMode @Binding var wrapAround: Bool diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift index 05d1f3dfb..383d2305d 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift @@ -7,6 +7,16 @@ import SwiftUI +/// A SwiftUI view that provides the main content layout for the find and replace panel. +/// +/// The `FindPanelContent` view is responsible for: +/// - Arranging the find and replace text fields in a vertical stack +/// - Arranging the control buttons in a vertical stack +/// - Handling the layout differences between find and replace modes +/// - Supporting both full and condensed layouts +/// +/// The view is designed to be used within `FindPanelView` and adapts its layout based on the +/// available space and current mode (find or replace). struct FindPanelContent: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift index bbc0a3269..dedb9bdbe 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -9,7 +9,18 @@ import SwiftUI import AppKit import Combine -/// A subclass of `NSHostingView` that hosts a `FindPanelView` in an AppKit context. +/// A subclass of `NSHostingView` that hosts the SwiftUI `FindPanelView` in an +/// AppKit context. +/// +/// The `FindPanelHostingView` class is responsible for: +/// - Bridging between SwiftUI and AppKit by hosting the FindPanelView +/// - Managing keyboard event monitoring for the escape key +/// - Handling the dismissal of the find panel +/// - Providing proper view lifecycle management +/// - Ensuring proper cleanup of event monitors +/// +/// This class is essential for integrating the SwiftUI-based find panel into the AppKit-based +/// text editor. final class FindPanelHostingView: NSHostingView { private weak var viewModel: FindPanelViewModel? diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 586616593..bdbe1f172 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -9,9 +9,23 @@ import SwiftUI import AppKit import CodeEditSymbols +/// A SwiftUI view that provides a find and replace interface for the text editor. +/// +/// The `FindPanelView` is the main container view for the find and replace functionality. It manages: +/// - The find/replace mode switching +/// - Focus management between find and replace fields +/// - Panel height adjustments based on mode +/// - Search text changes and match highlighting +/// - Case sensitivity and wrap-around settings +/// +/// The view automatically adapts its layout based on available space using `ViewThatFits`, providing +/// both a full and condensed layout option. struct FindPanelView: View { + /// Represents the current focus state of the find panel enum FindPanelFocus: Equatable { + /// The find text field is focused case find + /// The replace text field is focused case replace } @@ -67,6 +81,7 @@ struct FindPanelView: View { } } +/// A preference key used to track the width of the find mode picker private struct FindModePickerWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index a9fd0f194..365e48992 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -7,6 +7,17 @@ import SwiftUI +/// A SwiftUI view that provides the search text field for the find panel. +/// +/// The `FindSearchField` view is responsible for: +/// - Displaying and managing the find text input field +/// - Showing the find mode picker (find/replace) in both condensed and full layouts +/// - Providing case sensitivity toggle +/// - Displaying match count information +/// - Handling keyboard navigation (Enter to find next) +/// +/// The view adapts its layout based on the `condensed` parameter, providing a more compact +/// interface when space is limited. struct FindSearchField: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift index a9ea0fe5c..c99bdcbdd 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -7,6 +7,17 @@ import SwiftUI +/// A SwiftUI view that provides the replace controls for the find panel. +/// +/// The `ReplaceControls` view is responsible for: +/// - Displaying replace and replace all buttons +/// - Managing button states based on find text and match count +/// - Adapting button appearance between condensed and full layouts +/// - Providing tooltips for button actions +/// - Handling replace operations through the view model +/// +/// The view is only shown when the find panel is in replace mode and works in conjunction +/// with the replace text field to perform text replacements. struct ReplaceControls: View { @ObservedObject var viewModel: FindPanelViewModel var condensed: Bool diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift index 6fefe9b7e..ca9f71317 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -7,6 +7,16 @@ import SwiftUI +/// A SwiftUI view that provides the replace text field for the find panel. +/// +/// The `ReplaceSearchField` view is responsible for: +/// - Displaying and managing the replace text input field +/// - Showing a visual indicator (pencil icon) for the replace field +/// - Adapting its layout between condensed and full modes +/// - Maintaining focus state for keyboard navigation +/// +/// The view is only shown when the find panel is in replace mode and adapts its layout +/// based on the `condensed` parameter to match the find field's appearance. struct ReplaceSearchField: View { @ObservedObject var viewModel: FindPanelViewModel @FocusState.Binding var focus: FindPanelView.FindPanelFocus? From 4600f9bf9cddc5a774f5695c785d27bffce45559 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 9 May 2025 10:00:50 -0500 Subject: [PATCH 36/37] Added previews for find views --- .../Find/PanelView/FindControls.swift | 40 ++++++++++++++ .../Find/PanelView/FindPanelView.swift | 52 +++++++++++++++++++ .../Find/PanelView/FindSearchField.swift | 48 +++++++++++++++++ .../Find/PanelView/ReplaceControls.swift | 43 +++++++++++++++ .../Find/PanelView/ReplaceSearchField.swift | 44 ++++++++++++++++ 5 files changed, 227 insertions(+) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift index 6ed9d30dd..ede8476da 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -75,3 +75,43 @@ struct FindControls: View { } } } + +#Preview("Find Controls - Full") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: false + ) + .padding() +} + +#Preview("Find Controls - Condensed") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: true + ) + .padding() +} + +#Preview("Find Controls - No Matches") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + return vm + }(), + condensed: false + ) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index bdbe1f172..c32be4b8b 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -8,6 +8,7 @@ import SwiftUI import AppKit import CodeEditSymbols +import CodeEditTextView /// A SwiftUI view that provides a find and replace interface for the text editor. /// @@ -88,3 +89,54 @@ private struct FindModePickerWidthPreferenceKey: PreferenceKey { value = nextValue() } } + +/// A mock target for previews that implements the FindPanelTarget protocol +class MockFindPanelTarget: FindPanelTarget { + var textView: TextView! + var findPanelTargetView: NSView = NSView() + var cursorPositions: [CursorPosition] = [] + + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) {} + func updateCursorPosition() {} + func findPanelWillShow(panelHeight: CGFloat) {} + func findPanelWillHide(panelHeight: CGFloat) {} + func findPanelModeDidChange(to mode: FindPanelMode) {} +} + +#Preview("Find Mode") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 400) + .padding() +} + +#Preview("Replace Mode") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.mode = .replace + vm.findText = "example" + vm.replaceText = "test" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 400) + .padding() +} + +#Preview("Condensed Layout") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 300) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift index 365e48992..0d81ad81f 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -106,3 +106,51 @@ struct FindSearchField: View { } } } + +#Preview("Find Search Field - Full") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} + +#Preview("Find Search Field - Condensed") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: true + ) + .frame(width: 200) + .padding() +} + +#Preview("Find Search Field - Empty") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: FindPanelViewModel(target: MockFindPanelTarget()), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift index c99bdcbdd..6b62348f0 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -72,3 +72,46 @@ struct ReplaceControls: View { .fixedSize(horizontal: false, vertical: true) } } + +#Preview("Replace Controls - Full") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: false + ) + .padding() +} + +#Preview("Replace Controls - Condensed") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: true + ) + .padding() +} + +#Preview("Replace Controls - No Matches") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + return vm + }(), + condensed: false + ) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift index ca9f71317..87e470b26 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -53,3 +53,47 @@ struct ReplaceSearchField: View { .focused($focus, equals: .replace) } } + +#Preview("Replace Search Field - Full") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.replaceText = "replacement" + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} + +#Preview("Replace Search Field - Condensed") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.replaceText = "replacement" + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: true + ) + .frame(width: 200) + .padding() +} + +#Preview("Replace Search Field - Empty") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: FindPanelViewModel(target: MockFindPanelTarget()), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} From dd8a6577d41579e2e02938f0466fc9f83253a010 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 9 May 2025 10:31:12 -0500 Subject: [PATCH 37/37] Update ReformattingGuideView.swift Co-authored-by: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> --- .../ReformattingGuide/ReformattingGuideView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 01b8108e4..68bbdebac 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -1,5 +1,5 @@ // -// FindViewController+Toggle.swift +// ReformattingGuideView.swift // CodeEditSourceEditor // // Created by Austin Condiff on 4/28/25.