diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 2ce04dadc..328c585b6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; }; + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */; }; + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */; }; 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */; }; 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */; }; 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365312B8A7B94004A1D18 /* ContentView.swift */; }; @@ -41,6 +43,8 @@ buildActionMask = 2147483647; files = ( 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */, + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */, + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,6 +144,8 @@ name = CodeEditSourceEditorExample; packageProductDependencies = ( 61621C602C74FB2200494A4A /* CodeEditSourceEditor */, + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */, + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */, ); productName = CodeEditSourceEditorExample; productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */; @@ -412,6 +418,14 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6C1365222B8A7B94004A1D18 /* Project object */; 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 a7d1aaa89..70b1e3e5a 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" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "02202a8d925dc902f18626e953b3447e320253d1", + "version" : "0.8.1" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift index ac078e338..48aac83d5 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift @@ -13,5 +13,6 @@ struct CodeEditSourceEditorExampleApp: App { DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in ContentView(document: file.$document, fileURL: file.fileURL) } + .windowToolbarStyle(.unifiedCompact) } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift index 676eece9a..e55bd05f9 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift @@ -10,7 +10,7 @@ import AppKit import CodeEditSourceEditor extension EditorTheme { - static var standard: EditorTheme { + static var light: EditorTheme { EditorTheme( text: Attribute(color: NSColor(hex: "000000")), insertionPoint: NSColor(hex: "000000"), @@ -25,9 +25,29 @@ extension EditorTheme { variables: Attribute(color: NSColor(hex: "0F68A0")), values: Attribute(color: NSColor(hex: "6C36A9")), numbers: Attribute(color: NSColor(hex: "1C00CF")), - strings: Attribute(color: NSColor(hex: "C41A16"), bold: true, italic: true), + strings: Attribute(color: NSColor(hex: "C41A16")), characters: Attribute(color: NSColor(hex: "1C00CF")), - comments: Attribute(color: NSColor(hex: "267507"), italic: true) + comments: Attribute(color: NSColor(hex: "267507")) + ) + } + static var dark: EditorTheme { + EditorTheme( + text: Attribute(color: NSColor(hex: "FFFFFF")), + insertionPoint: NSColor(hex: "007AFF"), + invisibles: Attribute(color: NSColor(hex: "53606E")), + background: NSColor(hex: "292A30"), + lineHighlight: NSColor(hex: "2F3239"), + selection: NSColor(hex: "646F83"), + keywords: Attribute(color: NSColor(hex: "FF7AB2"), bold: true), + commands: Attribute(color: NSColor(hex: "78C2B3")), + types: Attribute(color: NSColor(hex: "6BDFFF")), + attributes: Attribute(color: NSColor(hex: "CC9768")), + variables: Attribute(color: NSColor(hex: "4EB0CC")), + values: Attribute(color: NSColor(hex: "B281EB")), + numbers: Attribute(color: NSColor(hex: "D9C97C")), + strings: Attribute(color: NSColor(hex: "FF8170")), + characters: Attribute(color: NSColor(hex: "D9C97C")), + comments: Attribute(color: NSColor(hex: "7F8C98")) ) } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index ea55c35b5..e40d0c905 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -11,16 +11,21 @@ import CodeEditLanguages import CodeEditTextView struct ContentView: View { + @Environment(\.colorScheme) + var colorScheme + @Binding var document: CodeEditSourceEditorExampleDocument let fileURL: URL? @State private var language: CodeLanguage = .default - @State private var theme: EditorTheme = .standard - @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + @State private var theme: EditorTheme = .light + @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true - @State private var cursorPositions: [CursorPosition] = [] + @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] @AppStorage("systemCursor") private var useSystemCursor: Bool = false @State private var isInLongParse = false + @State private var settingsIsPresented: Bool = false + @State private var treeSitterClient = TreeSitterClient() init(document: Binding, fileURL: URL?) { self._document = document @@ -28,65 +33,99 @@ struct ContentView: View { } var body: some View { - VStack(spacing: 0) { - HStack { - Text("Language") - LanguagePicker(language: $language) - .frame(maxWidth: 100) - Toggle("Wrap Lines", isOn: $wrapLines) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") + GeometryReader { proxy in + CodeEditSourceEditor( + $document.text, + language: language, + theme: theme, + font: font, + tabWidth: 4, + lineHeight: 1.2, + wrapLines: wrapLines, + cursorPositions: $cursorPositions, + useThemeBackground: true, + highlightProviders: [treeSitterClient], + contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), + useSystemCursor: useSystemCursor + ) + .overlay(alignment: .bottom) { + HStack { + Menu { + Toggle("Wrap Lines", isOn: $wrapLines) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + } + } label: {} + .background { + Image(systemName: "switch.2") + .foregroundStyle(.secondary) + .font(.system(size: 13.5, weight: .regular)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 18, alignment: .center) + Spacer() + Group { + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") + } + } else { + Text(getLabel(cursorPositions)) + } + } + .foregroundStyle(.secondary) + Divider() + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) } - Spacer() - Text(getLabel(cursorPositions)) - } - .padding(4) - .zIndex(2) - .background(Color(NSColor.windowBackgroundColor)) - Divider() - ZStack { - if isInLongParse { + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { VStack { - HStack { - Spacer() - Text("Parsing document...") - Spacer() - } - .padding(4) - .background(Color(NSColor.windowBackgroundColor)) - Spacer() + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } + } } - .zIndex(2) - .transition(.opacity) } - CodeEditSourceEditor( - $document.text, - language: language, - theme: theme, - font: font, - tabWidth: 4, - lineHeight: 1.2, - wrapLines: wrapLines, - cursorPositions: $cursorPositions, - useSystemCursor: useSystemCursor - ) + .zIndex(2) + .onAppear { + self.language = detectLanguage(fileURL: fileURL) ?? .default + self.theme = colorScheme == .dark ? .dark : .light + } } - } - .onAppear { - self.language = detectLanguage(fileURL: fileURL) ?? .default - } - .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in - withAnimation(.easeIn(duration: 0.1)) { - isInLongParse = true + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in + withAnimation(.easeIn(duration: 0.1)) { + isInLongParse = true + } } - } - .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in - withAnimation(.easeIn(duration: 0.1)) { - isInLongParse = false + .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in + withAnimation(.easeIn(duration: 0.1)) { + isInLongParse = false + } + } + .onChange(of: colorScheme) { _, newValue in + if newValue == .dark { + theme = .dark + } else { + theme = .light + } } } } @@ -105,7 +144,7 @@ struct ContentView: View { /// - Returns: A string describing the user's location in a document. func getLabel(_ cursorPositions: [CursorPosition]) -> String { if cursorPositions.isEmpty { - return "" + return "No cursor" } // More than one selection, display the number of selections. diff --git a/Package.resolved b/Package.resolved index 343da7ca1..6446cba69 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "02202a8d925dc902f18626e953b3447e320253d1", - "version" : "0.8.1" + "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", + "version" : "0.8.2" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "bea71d23db993c58934ee704f798a66d7b8cb626", - "version" : "0.57.0" + "revision" : "3825ebf8d55bb877c91bc897e8e3d0c001f16fba", + "version" : "0.58.2" } }, { diff --git a/Package.swift b/Package.swift index b1cd5e283..c0d9431e2 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.8.1" + from: "0.8.2" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index b2f0dd8bc..4d0c64e5d 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -10,6 +10,10 @@ import SwiftUI import CodeEditTextView import CodeEditLanguages +// This type is messy, but it needs *so* many parameters that this is pretty much unavoidable. +// swiftlint:disable file_length +// swiftlint:disable type_body_length + /// A SwiftUI View that provides source editing functionality. public struct CodeEditSourceEditor: NSViewControllerRepresentable { package enum TextAPI { @@ -35,12 +39,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// built-in `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. + /// - additionalTextInsets: An additional amount to inset the text of the editor by. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. + /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager @@ -59,10 +64,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -80,10 +86,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis if #available(macOS 14, *) { self.useSystemCursor = useSystemCursor } else { @@ -116,8 +123,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. - /// See `BracketPairHighlight` for more information. Defaults to `nil` + /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. + /// See `BracketPairEmphasis` for more information. Defaults to `nil` /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -134,10 +141,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -155,10 +163,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis if #available(macOS 14, *) { self.useSystemCursor = useSystemCursor } else { @@ -181,10 +190,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var useThemeBackground: Bool private var highlightProviders: [any HighlightProviding] private var contentInsets: NSEdgeInsets? + private var additionalTextInsets: NSEdgeInsets? private var isEditable: Bool private var isSelectable: Bool private var letterSpacing: Double - private var bracketPairHighlight: BracketPairHighlight? + private var bracketPairEmphasis: BracketPairEmphasis? private var useSystemCursor: Bool private var undoManager: CEUndoManager? package var coordinators: [any TextViewCoordinator] @@ -206,11 +216,12 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: useThemeBackground, highlightProviders: highlightProviders, contentInsets: contentInsets, + additionalTextInsets: additionalTextInsets, isEditable: isEditable, isSelectable: isSelectable, letterSpacing: letterSpacing, useSystemCursor: useSystemCursor, - bracketPairHighlight: bracketPairHighlight, + bracketPairEmphasis: bracketPairEmphasis, undoManager: undoManager, coordinators: coordinators ) @@ -263,16 +274,17 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// Update the parameters of the controller. /// - Parameter controller: The controller to update. func updateControllerParams(controller: TextViewController) { + updateTextProperties(controller) + updateEditorProperties(controller) + updateThemeAndLanguage(controller) + updateHighlighting(controller) + } + + private func updateTextProperties(_ controller: TextViewController) { if controller.font != font { controller.font = font } - controller.wrapLines = wrapLines - controller.useThemeBackground = useThemeBackground - controller.lineHeightMultiple = lineHeight - controller.editorOverscroll = editorOverscroll - controller.contentInsets = contentInsets - if controller.isEditable != isEditable { controller.isEditable = isEditable } @@ -280,14 +292,15 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.isSelectable != isSelectable { controller.isSelectable = isSelectable } + } - if controller.language.id != language.id { - controller.language = language - } - - if controller.theme != theme { - controller.theme = theme - } + private func updateEditorProperties(_ controller: TextViewController) { + controller.wrapLines = wrapLines + controller.useThemeBackground = useThemeBackground + controller.lineHeightMultiple = lineHeight + controller.editorOverscroll = editorOverscroll + controller.contentInsets = contentInsets + controller.additionalTextInsets = additionalTextInsets if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -304,12 +317,26 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.useSystemCursor != useSystemCursor { controller.useSystemCursor = useSystemCursor } + } + private func updateThemeAndLanguage(_ controller: TextViewController) { + if controller.language.id != language.id { + controller.language = language + } + + if controller.theme != theme { + controller.theme = theme + } + } + + private func updateHighlighting(_ controller: TextViewController) { if !areHighlightProvidersEqual(controller: controller) { controller.setHighlightProviders(highlightProviders) } - controller.bracketPairHighlight = bracketPairHighlight + if controller.bracketPairEmphasis != bracketPairEmphasis { + controller.bracketPairEmphasis = bracketPairEmphasis + } } /// Checks if the controller needs updating. @@ -324,12 +351,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.lineHeightMultiple == lineHeight && controller.editorOverscroll == editorOverscroll && controller.contentInsets == contentInsets && + controller.additionalTextInsets == additionalTextInsets && controller.language.id == language.id && controller.theme == theme && controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight && + controller.bracketPairEmphasis == bracketPairEmphasis && controller.useSystemCursor == useSystemCursor && areHighlightProvidersEqual(controller: controller) } @@ -359,7 +387,7 @@ public struct CodeEditTextView: View { isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] ) { @@ -370,3 +398,6 @@ public struct CodeEditTextView: View { EmptyView() } } + +// swiftlint:enable type_body_length +// swiftlint:enable file_length diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift b/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift new file mode 100644 index 000000000..cf71c2fbd --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift @@ -0,0 +1,184 @@ +// +// BezelNotification.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/17/25. +// + +import AppKit +import SwiftUI + +/// A utility class for showing temporary bezel notifications with SF Symbols +final class BezelNotification { + private static var shared = BezelNotification() + private var window: NSWindow? + private var hostingView: NSHostingView? + private var frameObserver: NSObjectProtocol? + private var targetView: NSView? + private var hideTimer: DispatchWorkItem? + + private init() {} + + deinit { + if let observer = frameObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Shows a bezel notification with the given SF Symbol name + /// - Parameters: + /// - symbolName: The name of the SF Symbol to display + /// - over: The view to center the bezel over + /// - duration: How long to show the bezel for (defaults to 0.75 seconds) + static func show(symbolName: String, over view: NSView, duration: TimeInterval = 0.75) { + shared.showBezel(symbolName: symbolName, over: view, duration: duration) + } + + private func showBezel(symbolName: String, over view: NSView, duration: TimeInterval) { + // Cancel any existing hide timer + hideTimer?.cancel() + hideTimer = nil + + // Close existing window if any + cleanup() + + self.targetView = view + + // Create the window and view + let bezelContent = BezelView(symbolName: symbolName) + let hostingView = NSHostingView(rootView: bezelContent) + self.hostingView = hostingView + + let window = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel, .hudWindow], + backing: .buffered, + defer: true + ) + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.level = .floating + window.contentView = hostingView + window.isMovable = false + window.isReleasedWhenClosed = false + + // Make it a child window that moves with the parent + if let parentWindow = view.window { + parentWindow.addChildWindow(window, ordered: .above) + } + + self.window = window + + // Size and position the window + let size = NSSize(width: 110, height: 110) + hostingView.frame.size = size + + // Initial position + updateBezelPosition() + + // Observe frame changes + frameObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: view, + queue: .main + ) { [weak self] _ in + self?.updateBezelPosition() + } + + // Show immediately without fade + window.alphaValue = 1 + window.orderFront(nil) + + // Schedule hide + let timer = DispatchWorkItem { [weak self] in + self?.dismiss() + } + self.hideTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: timer) + } + + private func updateBezelPosition() { + guard let window = window, + let view = targetView else { return } + + let size = NSSize(width: 110, height: 110) + + // Position relative to the view's content area + let visibleRect: NSRect + if let scrollView = view.enclosingScrollView { + // Get the visible rect in the scroll view's coordinate space + visibleRect = scrollView.contentView.visibleRect + } else { + visibleRect = view.bounds + } + + // Convert visible rect to window coordinates + let viewFrameInWindow = view.enclosingScrollView?.contentView.convert(visibleRect, to: nil) + ?? view.convert(visibleRect, to: nil) + guard let screenFrame = view.window?.convertToScreen(viewFrameInWindow) else { return } + + // Calculate center position relative to the visible content area + let xPos = screenFrame.midX - (size.width / 2) + let yPos = screenFrame.midY - (size.height / 2) + + // Update frame + let bezelFrame = NSRect(origin: NSPoint(x: xPos, y: yPos), size: size) + window.setFrame(bezelFrame, display: true) + } + + private func cleanup() { + // Cancel any existing hide timer + hideTimer?.cancel() + hideTimer = nil + + // Remove frame observer + if let observer = frameObserver { + NotificationCenter.default.removeObserver(observer) + frameObserver = nil + } + + // Remove child window relationship + if let window = window, let parentWindow = window.parent { + parentWindow.removeChildWindow(window) + } + + // Close and clean up window + window?.orderOut(nil) // Ensure window is removed from screen + window?.close() + window = nil + + // Clean up hosting view + hostingView?.removeFromSuperview() + hostingView = nil + + // Clear target view reference + targetView = nil + } + + private func dismiss() { + guard let window = window else { return } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.15 + window.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + self?.cleanup() + }) + } +} + +/// The SwiftUI view for the bezel content +private struct BezelView: View { + let symbolName: String + + var body: some View { + Image(systemName: symbolName) + .imageScale(.large) + .font(.system(size: 56, weight: .thin)) + .foregroundStyle(.secondary) + .frame(width: 110, height: 110) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerSize: CGSize(width: 18.0, height: 18.0))) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift b/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift new file mode 100644 index 000000000..f9a1e6eb0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift @@ -0,0 +1,72 @@ +// +// EffectView.swift +// CodeEditModules/CodeEditUI +// +// Created by Rehatbir Singh on 15/03/2022. +// + +import SwiftUI + +/// A SwiftUI Wrapper for `NSVisualEffectView` +/// +/// ## Usage +/// ```swift +/// EffectView(material: .headerView, blendingMode: .withinWindow) +/// ``` +struct EffectView: NSViewRepresentable { + private let material: NSVisualEffectView.Material + private let blendingMode: NSVisualEffectView.BlendingMode + private let emphasized: Bool + + /// Initializes the + /// [`NSVisualEffectView`](https://developer.apple.com/documentation/appkit/nsvisualeffectview) + /// with a + /// [`Material`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/material) and + /// [`BlendingMode`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/blendingmode) + /// + /// By setting the + /// [`emphasized`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/1644721-isemphasized) + /// flag, the emphasized state of the material will be used if available. + /// + /// - Parameters: + /// - material: The material to use. Defaults to `.headerView`. + /// - blendingMode: The blending mode to use. Defaults to `.withinWindow`. + /// - emphasized:A Boolean value indicating whether to emphasize the look of the material. Defaults to `false`. + init( + _ material: NSVisualEffectView.Material = .headerView, + blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, + emphasized: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.emphasized = emphasized + } + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.isEmphasized = emphasized + view.state = .followsWindowActiveState + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } + + /// Returns the system selection style as an ``EffectView`` if the `condition` is met. + /// Otherwise it returns `Color.clear` + /// + /// - Parameter condition: The condition of when to apply the background. Defaults to `true`. + /// - Returns: A View + @ViewBuilder + static func selectionBackground(_ condition: Bool = true) -> some View { + if condition { + EffectView(.selection, blendingMode: .withinWindow, emphasized: true) + } else { + Color.clear + } + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift b/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift new file mode 100644 index 000000000..dfc6ff785 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift @@ -0,0 +1,120 @@ +// +// IconButtonStyle.swift +// CodeEdit +// +// Created by Austin Condiff on 11/9/23. +// + +import SwiftUI + +struct IconButtonStyle: ButtonStyle { + var isActive: Bool? + var font: Font? + var size: CGSize? + + init(isActive: Bool? = nil, font: Font? = nil, size: CGFloat? = nil) { + self.isActive = isActive + self.font = font + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(isActive: Bool? = nil, font: Font? = nil, size: CGSize? = nil) { + self.isActive = isActive + self.font = font + self.size = size + } + + init(isActive: Bool? = nil, font: Font? = nil) { + self.isActive = isActive + self.font = font + self.size = nil + } + + func makeBody(configuration: ButtonStyle.Configuration) -> some View { + IconButton( + configuration: configuration, + isActive: isActive, + font: font, + size: size + ) + } + + struct IconButton: View { + let configuration: ButtonStyle.Configuration + var isActive: Bool + var font: Font + var size: CGSize? + @Environment(\.controlActiveState) + private var controlActiveState + @Environment(\.isEnabled) + private var isEnabled: Bool + @Environment(\.colorScheme) + private var colorScheme + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGFloat?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGSize?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = size ?? nil + } + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = nil + } + + var body: some View { + configuration.label + .font(font) + .foregroundColor( + isActive + ? Color(.controlAccentColor) + : Color(.secondaryLabelColor) + ) + .frame(width: size?.width, height: size?.height, alignment: .center) + .contentShape(Rectangle()) + .brightness( + configuration.isPressed + ? colorScheme == .dark + ? 0.5 + : isActive ? -0.25 : -0.75 + : 0 + ) + .opacity(controlActiveState == .inactive ? 0.5 : 1) + .symbolVariant(isActive ? .fill : .none) + } + } +} + +extension ButtonStyle where Self == IconButtonStyle { + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGFloat? = 24 + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font, size: size) + } + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGSize? = CGSize(width: 24, height: 24) + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font, size: size) + } + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font) + } + static var icon: IconButtonStyle { .init() } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift b/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift new file mode 100644 index 000000000..2382dfc34 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift @@ -0,0 +1,59 @@ +// +// IconToggleStyle.swift +// CodeEdit +// +// Created by Austin Condiff on 11/9/23. +// + +import SwiftUI + +struct IconToggleStyle: ToggleStyle { + var font: Font? + var size: CGSize? + + @State var isPressing = false + + init(font: Font? = nil, size: CGFloat? = nil) { + self.font = font + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(font: Font? = nil, size: CGSize? = nil) { + self.font = font + self.size = size + } + + init(font: Font? = nil) { + self.font = font + self.size = nil + } + + func makeBody(configuration: ToggleStyle.Configuration) -> some View { + Button( + action: { configuration.isOn.toggle() }, + label: { configuration.label } + ) + .buttonStyle(.icon(isActive: configuration.isOn, font: font, size: size)) + } +} + +extension ToggleStyle where Self == IconToggleStyle { + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGFloat? = 24 + ) -> IconToggleStyle { + return IconToggleStyle(font: font, size: size) + } + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGSize? = CGSize(width: 24, height: 24) + ) -> IconToggleStyle { + return IconToggleStyle(font: font, size: size) + } + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) + ) -> IconToggleStyle { + return IconToggleStyle(font: font) + } + static var icon: IconToggleStyle { .init() } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift new file mode 100644 index 000000000..b836c5307 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift @@ -0,0 +1,71 @@ +// +// PanelStyles.swift +// CodeEdit +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI + +private struct InsideControlGroupKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var isInsideControlGroup: Bool { + get { self[InsideControlGroupKey.self] } + set { self[InsideControlGroupKey.self] = newValue } + } +} + +struct PanelControlGroupStyle: ControlGroupStyle { + @Environment(\.controlActiveState) private var controlActiveState + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 0) { + configuration.content + .buttonStyle(PanelButtonStyle()) + .environment(\.isInsideControlGroup, true) + .padding(.vertical, 1) + } + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) + ) + .cornerRadius(4) + .clipped() + } +} + +struct PanelButtonStyle: ButtonStyle { + @Environment(\.colorScheme) var colorScheme + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.isEnabled) private var isEnabled + @Environment(\.isInsideControlGroup) private var isInsideControlGroup + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12, weight: .regular)) + .foregroundColor(Color(.controlTextColor)) + .padding(.horizontal, 6) + .frame(height: isInsideControlGroup ? 16 : 18) + .background( + configuration.isPressed + ? colorScheme == .light + ? Color.black.opacity(0.06) + : Color.white.opacity(0.24) + : Color.clear + ) + .overlay( + Group { + if !isInsideControlGroup { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) + } + } + ) + .cornerRadius(isInsideControlGroup ? 0 : 4) + .clipped() + .contentShape(Rectangle()) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift new file mode 100644 index 000000000..beefdd7d4 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift @@ -0,0 +1,139 @@ +// +// PanelTextField.swift +// CodeEdit +// +// Created by Austin Condiff on 11/2/23. +// + +import SwiftUI +import Combine + +struct PanelTextField: View { + @Environment(\.colorScheme) + var colorScheme + + @Environment(\.controlActiveState) + private var controlActive + + @FocusState private var isFocused: Bool + + var label: String + + @Binding private var text: String + + let axis: Axis + + let leadingAccessories: LeadingAccessories? + + let trailingAccessories: TrailingAccessories? + + let helperText: String? + + var clearable: Bool + + var onClear: (() -> Void) + + var hasValue: Bool + + init( + _ label: String, + text: Binding, + axis: Axis? = .horizontal, + @ViewBuilder leadingAccessories: () -> LeadingAccessories? = { EmptyView() }, + @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, + helperText: String? = nil, + clearable: Bool? = false, + onClear: (() -> Void)? = {}, + hasValue: Bool? = false + ) { + self.label = label + _text = text + self.axis = axis ?? .horizontal + self.leadingAccessories = leadingAccessories() + self.trailingAccessories = trailingAccessories() + 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 { + Color(.textBackgroundColor) + } else { + if colorScheme == .light { + Color.black.opacity(0.06) + } else { + Color.white.opacity(0.24) + } + } + } else { + if colorScheme == .light { + Color.clear + } else { + Color.white.opacity(0.14) + } + } + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if let leading = leadingAccessories { + leading + .frame(height: 20) + } + HStack { + TextField(label, text: $text, axis: axis) + .textFieldStyle(.plain) + .focused($isFocused) + .controlSize(.small) + .padding(.horizontal, 8) + .padding(.vertical, 3.5) + .foregroundStyle(.primary) + if let helperText { + Text(helperText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if clearable == true { + Button { + self.text = "" + onClear() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 20, height: 20))) + .opacity(text.isEmpty ? 0 : 1) + .disabled(text.isEmpty) + } + if let trailing = trailingAccessories { + trailing + } + } + .fixedSize(horizontal: false, vertical: true) + .buttonStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 28, height: 20))) + .toggleStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 28, height: 20))) + .frame(minHeight: 22) + .background( + selectionBackground(isFocused) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .edgesIgnoringSafeArea(.all) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(true) + .edgesIgnoringSafeArea(.all) + ) + + .onTapGesture { + isFocused = true + } + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift new file mode 100644 index 000000000..87a3c49cb --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -0,0 +1,161 @@ +// +// TextViewController+EmphasizeBracket.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/26/23. +// + +import AppKit +import CodeEditTextView + +extension TextViewController { + /// Emphasizes bracket pairs using the current selection. + internal func emphasizeSelectionPairs() { + guard let bracketPairEmphasis else { return } + textView.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) + for range in textView.selectionManager.textSelections.map({ $0.range }) { + if range.isEmpty, + range.location > 0, // Range is not the beginning of the document + let precedingCharacter = textView.textStorage.substring( + from: NSRange(location: range.location - 1, length: 1) // The preceding character exists + ) { + for pair in BracketPairs.emphasisValues { + if precedingCharacter == pair.0 { + // Walk forwards + emphasizeForwards(pair, range: range, emphasisType: bracketPairEmphasis) + } else if precedingCharacter == pair.1 && range.location - 1 > 0 { + // Walk backwards + emphasizeBackwards(pair, range: range, emphasisType: bracketPairEmphasis) + } + } + } + } + } + + private func emphasizeForwards(_ pair: (String, String), range: NSRange, emphasisType: BracketPairEmphasis) { + if let characterIndex = findClosingPair( + pair.0, + pair.1, + from: range.location, + limit: min((textView.visibleTextRange ?? .zero).max + 4096, textView.documentRange.max), + reverse: false + ) { + emphasizeCharacter(characterIndex) + if emphasisType.emphasizesSourceBracket { + emphasizeCharacter(range.location - 1) + } + } + } + + private func emphasizeBackwards(_ pair: (String, String), range: NSRange, emphasisType: BracketPairEmphasis) { + if let characterIndex = findClosingPair( + pair.1, + pair.0, + from: range.location - 1, + limit: max((textView.visibleTextRange?.location ?? 0) - 4096, textView.documentRange.location), + reverse: true + ) { + emphasizeCharacter(characterIndex) + if emphasisType.emphasizesSourceBracket { + emphasizeCharacter(range.location - 1) + } + } + } + + /// # Dev Note + /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each + /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed + /// and this lookup would be much faster. + + /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. + /// + /// ```pseudocode + /// { -- Start + /// { + /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. + /// } -- Found + /// ``` + /// + /// - Parameters: + /// - open: The opening pair to look for. + /// - close: The closing pair to look for. + /// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward + /// the index should be `1` + /// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this + /// is the maximum index. + /// - reverse: Set to `true` to walk backwards from `from`. + /// - Returns: The index of the found closing pair, if any. + internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { + // Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. + var options: NSString.EnumerationOptions = .byCaretPositions + if reverse { + options = options.union(.reverse) + } + var closeCount = 0 + var index: Int? + textView.textStorage.mutableString.enumerateSubstrings( + in: reverse ? + NSRange(location: limit, length: from - limit) : + NSRange(location: from, length: limit - from), + options: options, + using: { substring, range, _, stop in + if substring == close { + closeCount += 1 + } else if substring == open { + closeCount -= 1 + } + + if closeCount < 0 { + index = range.location + stop.pointee = true + } + } + ) + return index + } + + /// Adds a temporary emphasis effect to the character at the given location. + /// - Parameters: + /// - location: The location of the character to emphasize + /// - scrollToRange: Set to true to scroll to the given range when emphasizing. Defaults to `false`. + private func emphasizeCharacter(_ location: Int, scrollToRange: Bool = false) { + guard let bracketPairEmphasis = bracketPairEmphasis else { + return + } + + let range = NSRange(location: location, length: 1) + + switch bracketPairEmphasis { + case .flash: + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .standard, + flash: true, + inactive: false + ), + for: EmphasisGroup.brackets + ) + case .bordered(let borderColor): + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .outline(color: borderColor), + flash: false, + inactive: false + ), + for: EmphasisGroup.brackets + ) + case .underline(let underlineColor): + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .underline(color: underlineColor), + flash: false, + inactive: false + ), + for: EmphasisGroup.brackets + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift new file mode 100644 index 000000000..36e4b45f7 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -0,0 +1,25 @@ +// +// TextViewController+FindPanelTarget.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/16/25. +// + +import Foundation +import CodeEditTextView + +extension TextViewController: FindPanelTarget { + func findPanelWillShow(panelHeight: CGFloat) { + scrollView.contentInsets.top += panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + + func findPanelWillHide(panelHeight: CGFloat) { + scrollView.contentInsets.top -= panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + + var emphasisManager: EmphasisManager? { + textView?.emphasisManager + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift deleted file mode 100644 index a8297eb6a..000000000 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// TextViewController+HighlightRange.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/26/23. -// - -import AppKit - -extension TextViewController { - /// Highlights bracket pairs using the current selection. - internal func highlightSelectionPairs() { - guard bracketPairHighlight != nil else { return } - removeHighlightLayers() - for range in textView.selectionManager.textSelections.map({ $0.range }) { - if range.isEmpty, - range.location > 0, // Range is not the beginning of the document - let precedingCharacter = textView.textStorage.substring( - from: NSRange(location: range.location - 1, length: 1) // The preceding character exists - ) { - for pair in BracketPairs.highlightValues { - if precedingCharacter == pair.0 { - // Walk forwards - if let characterIndex = findClosingPair( - pair.0, - pair.1, - from: range.location, - limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, - NSMaxRange(textView.documentRange)), - reverse: false - ) { - highlightCharacter(characterIndex) - if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightCharacter(range.location - 1) - } - } - } else if precedingCharacter == pair.1 && range.location - 1 > 0 { - // Walk backwards - if let characterIndex = findClosingPair( - pair.1, - pair.0, - from: range.location - 1, - limit: max((textView.visibleTextRange?.location ?? 0) - 4096, - textView.documentRange.location), - reverse: true - ) { - highlightCharacter(characterIndex) - if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightCharacter(range.location - 1) - } - } - } - } - } - } - } - - /// # Dev Note - /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each - /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed - /// and this lookup would be much faster. - - /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. - /// - /// ```pseudocode - /// { -- Start - /// { - /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. - /// } -- Found - /// ``` - /// - /// - Parameters: - /// - open: The opening pair to look for. - /// - close: The closing pair to look for. - /// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward - /// the index should be `1` - /// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this - /// is the maximum index. - /// - reverse: Set to `true` to walk backwards from `from`. - /// - Returns: The index of the found closing pair, if any. - internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { - // Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. - var options: NSString.EnumerationOptions = .byCaretPositions - if reverse { - options = options.union(.reverse) - } - var closeCount = 0 - var index: Int? - textView.textStorage.mutableString.enumerateSubstrings( - in: reverse ? - NSRange(location: limit, length: from - limit) : - NSRange(location: from, length: limit - from), - options: options, - using: { substring, range, _, stop in - if substring == close { - closeCount += 1 - } else if substring == open { - closeCount -= 1 - } - - if closeCount < 0 { - index = range.location - stop.pointee = true - } - } - ) - return index - } - - /// Adds a temporary highlight effect to the character at the given location. - /// - Parameters: - /// - location: The location of the character to highlight - /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - private func highlightCharacter(_ location: Int, scrollToRange: Bool = false) { - guard let bracketPairHighlight = bracketPairHighlight, - var rectToHighlight = textView.layoutManager.rectForOffset(location) else { - return - } - let layer = CAShapeLayer() - - switch bracketPairHighlight { - case .flash: - rectToHighlight.size.width += 4 - rectToHighlight.origin.x -= 2 - - layer.cornerRadius = 3.0 - layer.backgroundColor = NSColor(hex: 0xFEFA80, alpha: 1.0).cgColor - layer.shadowColor = .black - layer.shadowOpacity = 0.3 - layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowRadius = 3.0 - layer.opacity = 0.0 - case .bordered(let borderColor): - layer.borderColor = borderColor.cgColor - layer.cornerRadius = 2.5 - layer.borderWidth = 0.5 - layer.opacity = 1.0 - case .underline(let underlineColor): - layer.lineWidth = 1.0 - layer.lineCap = .round - layer.strokeColor = underlineColor.cgColor - layer.opacity = 1.0 - } - - switch bracketPairHighlight { - case .flash, .bordered: - layer.frame = rectToHighlight - case .underline: - let path = CGMutablePath() - let pathY = rectToHighlight.maxY - (rectToHighlight.height * (lineHeightMultiple - 1))/4 - path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) - path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) - layer.path = path - } - - // Insert above selection but below text - textView.layer?.insertSublayer(layer, at: 1) - - if bracketPairHighlight == .flash { - addFlashAnimation(to: layer, rectToHighlight: rectToHighlight) - } - - highlightLayers.append(layer) - - // Scroll the last rect into view, makes a small assumption that the last rect is the lowest visually. - if scrollToRange { - textView.scrollToVisible(rectToHighlight) - } - } - - /// Adds a flash animation to the given layer. - /// - Parameters: - /// - layer: The layer to add the animation to. - /// - rectToHighlight: The layer's bounding rect to animate. - private func addFlashAnimation(to layer: CALayer, rectToHighlight: CGRect) { - CATransaction.begin() - CATransaction.setCompletionBlock { [weak self] in - if let index = self?.highlightLayers.firstIndex(of: layer) { - self?.highlightLayers.remove(at: index) - } - layer.removeFromSuperlayer() - } - let duration = 0.75 - let group = CAAnimationGroup() - group.duration = duration - - let opacityAnim = CAKeyframeAnimation(keyPath: "opacity") - opacityAnim.duration = duration - opacityAnim.values = [1.0, 1.0, 0.0] - opacityAnim.keyTimes = [0.1, 0.8, 0.9] - - let positionAnim = CAKeyframeAnimation(keyPath: "position") - positionAnim.keyTimes = [0.0, 0.05, 0.1] - positionAnim.values = [ - NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y), - NSPoint(x: rectToHighlight.origin.x - 2, y: rectToHighlight.origin.y - 2), - NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y) - ] - positionAnim.duration = duration - - var betweenSize = rectToHighlight - betweenSize.size.width += 4 - betweenSize.size.height += 4 - let boundsAnim = CAKeyframeAnimation(keyPath: "bounds") - boundsAnim.keyTimes = [0.0, 0.05, 0.1] - boundsAnim.values = [rectToHighlight, betweenSize, rectToHighlight] - boundsAnim.duration = duration - - group.animations = [opacityAnim, boundsAnim] - layer.add(group, forKey: nil) - CATransaction.commit() - } - - /// Safely removes all highlight layers. - internal func removeHighlightLayers() { - highlightLayers.forEach { layer in - layer.removeFromSuperlayer() - } - highlightLayers.removeAll() - } -} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 34eb0dd42..a4e2cf76d 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -11,20 +11,15 @@ import AppKit extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { - scrollView = NSScrollView() - textView.postsFrameChangedNotifications = true - textView.translatesAutoresizingMaskIntoConstraints = false + super.loadView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.contentView.postsFrameChangedNotifications = true - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = !wrapLines + scrollView = NSScrollView() scrollView.documentView = textView - scrollView.contentView.postsBoundsChangedNotifications = true gutterView = GutterView( font: font.rulerFont, - textColor: .secondaryLabelColor, + textColor: theme.text.color.withAlphaComponent(0.35), + selectedTextColor: theme.text.color, textView: textView, delegate: self ) @@ -34,7 +29,15 @@ extension TextViewController { for: .horizontal ) - self.view = scrollView + let findViewController = FindViewController(target: self, childView: scrollView) + addChild(findViewController) + self.findViewController = findViewController + self.view.addSubview(findViewController.view) + findViewController.view.viewDidMoveToSuperview() + self.findViewController = findViewController + + findViewController.topPadding = contentInsets?.top + if let _undoManager { textView.setUndoManager(_undoManager) } @@ -46,10 +49,10 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { @@ -74,9 +77,7 @@ extension TextViewController { ) { [weak self] _ in self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() - } + self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) } NotificationCenter.default.addObserver( @@ -94,7 +95,7 @@ extension TextViewController { queue: .main ) { [weak self] _ in self?.updateCursorPosition() - self?.highlightSelectionPairs() + self?.emphasizeSelectionPairs() } textView.updateFrameIfNeeded() @@ -106,6 +107,10 @@ extension TextViewController { if self.systemAppearance != newValue.name { self.systemAppearance = newValue.name + + // Reset content insets and gutter position when appearance changes + self.styleScrollView() + self.gutterView.frame.origin.y = -self.scrollView.contentInsets.top } } .store(in: &cancellables) @@ -113,19 +118,25 @@ extension TextViewController { if let localEventMonitor = self.localEvenMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard self?.view.window?.firstResponder == self?.textView else { return event } + setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + } - let tabKey: UInt16 = 0x30 - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + func setUpKeyBindings(eventMonitor: inout Any?) { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + guard let self = self else { return event } - if event.keyCode == tabKey { - return self?.handleTab(event: event, modifierFalgs: modifierFlags) - } else { - return self?.handleCommand(event: event, modifierFlags: modifierFlags) - } + // Check if this window is key and if the text view is the first responder + let isKeyWindow = self.view.window?.isKeyWindow ?? false + let isFirstResponder = self.view.window?.firstResponder === self.textView + + // Only handle commands if this is the key window and text view is first responder + guard isKeyWindow && isFirstResponder else { return event } + + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) } } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue @@ -139,6 +150,13 @@ extension TextViewController { case (commandKey, "]"): handleIndent() return nil + case (commandKey, "f"): + _ = self.textView.resignFirstResponder() + self.findViewController?.showFindPanel() + return nil + case (0, "\u{1b}"): // Escape key + self.findViewController?.findPanel.dismiss() + return nil case (_, _): return event } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index ea1dacd36..7fa819bbf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -19,10 +19,13 @@ extension TextViewController { /// Style the text view. package func styleTextView() { + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() textView.selectionManager.highlightSelectedLine = isEditable textView.selectionManager.insertionPointColor = theme.insertionPoint + textView.enclosingScrollView?.backgroundColor = useThemeBackground ? theme.background : .clear paragraphStyle = generateParagraphStyle() textView.typingAttributes = attributesFor(nil) } @@ -43,13 +46,14 @@ extension TextViewController { /// Style the gutter view. package func styleGutterView() { + // Note: If changing this value, also change in ``findPanelWillShow/Hide()`` gutterView.frame.origin.y = -scrollView.contentInsets.top gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua ? NSColor.quaternaryLabelColor : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) gutterView.highlightSelectedLines = isEditable gutterView.font = font.rulerFont - gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor + gutterView.backgroundColor = useThemeBackground ? theme.background : .windowBackgroundColor if self.isEditable == false { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear @@ -58,15 +62,22 @@ extension TextViewController { /// Style the scroll view. package func styleScrollView() { - guard let scrollView = view as? NSScrollView else { return } - scrollView.drawsBackground = useThemeBackground - scrollView.backgroundColor = useThemeBackground ? theme.background : .clear + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = !wrapLines + + scrollView.contentView.postsBoundsChangedNotifications = true if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets } else { scrollView.automaticallyAdjustsContentInsets = true } - scrollView.contentInsets.bottom = contentInsets?.bottom ?? 0 + + scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 + scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 + + scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 05820fae6..002e3807b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -21,7 +21,7 @@ extension TextViewController { ("'", "'") ] - static let highlightValues: [(String, String)] = [ + static let emphasisValues: [(String, String)] = [ ("{", "}"), ("[", "]"), ("(", ")") diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 926c8ab10..9337ece4a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -20,12 +20,19 @@ public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + weak var findViewController: FindViewController? + var scrollView: NSScrollView! - private(set) public var textView: TextView! + + // SEARCH + var stackview: NSStackView! + var searchField: NSTextField! + var prevButton: NSButton! + var nextButton: NSButton! + + var textView: TextView! var gutterView: GutterView! - internal var _undoManager: CEUndoManager? - /// Internal reference to any injected layers in the text view. - internal var highlightLayers: [CALayer] = [] + internal var _undoManager: CEUndoManager! internal var systemAppearance: NSAppearance.Name? package var localEvenMonitor: Any? @@ -62,6 +69,8 @@ public class TextViewController: NSViewController { ) textView.selectionManager.selectedLineBackgroundColor = theme.selection highlighter?.invalidate() + gutterView.textColor = theme.text.color.withAlphaComponent(0.35) + gutterView.selectedLineTextColor = theme.text.color } } @@ -116,8 +125,23 @@ public class TextViewController: NSViewController { /// The provided highlight provider. public var highlightProviders: [HighlightProviding] - /// Optional insets to offset the text view in the scroll view by. - public var contentInsets: NSEdgeInsets? + /// Optional insets to offset the text view and find panel in the scroll view by. + public var contentInsets: NSEdgeInsets? { + didSet { + styleScrollView() + findViewController?.topPadding = contentInsets?.top + } + } + + /// An additional amount to inset text by. Horizontal values are ignored. + /// + /// This value does not affect decorations like the find panel, but affects things that are relative to text, such + /// as line numbers and of course the text itself. + public var additionalTextInsets: NSEdgeInsets? { + didSet { + styleScrollView() + } + } /// Whether or not text view is editable by user public var isEditable: Bool { @@ -143,9 +167,9 @@ public class TextViewController: NSViewController { } /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairHighlight: BracketPairHighlight? { + public var bracketPairEmphasis: BracketPairEmphasis? { didSet { - highlightSelectionPairs() + emphasizeSelectionPairs() } } @@ -217,11 +241,12 @@ public class TextViewController: NSViewController { useThemeBackground: Bool, highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets?, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool, isSelectable: Bool, letterSpacing: Double, useSystemCursor: Bool, - bracketPairHighlight: BracketPairHighlight?, + bracketPairEmphasis: BracketPairEmphasis?, undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [] ) { @@ -237,10 +262,11 @@ public class TextViewController: NSViewController { self.useThemeBackground = useThemeBackground self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis self._undoManager = undoManager super.init(nibName: nil, bundle: nil) diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift similarity index 57% rename from Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift rename to Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift index 3a20fedd2..7da0e9945 100644 --- a/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift @@ -1,5 +1,5 @@ // -// BracketPairHighlight.swift +// BracketPairEmphasis.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/3/23. @@ -7,20 +7,20 @@ import AppKit -/// An enum representing the type of highlight to use for bracket pairs. -public enum BracketPairHighlight: Equatable { - /// Highlight both the opening and closing character in a pair with a bounding box. +/// An enum representing the type of emphasis to use for bracket pairs. +public enum BracketPairEmphasis: Equatable { + /// Emphasize both the opening and closing character in a pair with a bounding box. /// The boxes will stay on screen until the cursor moves away from the bracket pair. case bordered(color: NSColor) - /// Flash a yellow highlight box on only the opposite character in the pair. - /// This is closely matched to Xcode's flash highlight for bracket pairs, and animates in and out over the course + /// Flash a yellow emphasis box on only the opposite character in the pair. + /// This is closely matched to Xcode's flash emphasis for bracket pairs, and animates in and out over the course /// of `0.75` seconds. case flash - /// Highlight both the opening and closing character in a pair with an underline. + /// Emphasize both the opening and closing character in a pair with an underline. /// The underline will stay on screen until the cursor moves away from the bracket pair. case underline(color: NSColor) - public static func == (lhs: BracketPairHighlight, rhs: BracketPairHighlight) -> Bool { + public static func == (lhs: BracketPairEmphasis, rhs: BracketPairEmphasis) -> Bool { switch (lhs, rhs) { case (.flash, .flash): return true @@ -33,8 +33,8 @@ public enum BracketPairHighlight: Equatable { } } - /// Returns `true` if the highlight should act on both the opening and closing bracket. - var highlightsSourceBracket: Bool { + /// Returns `true` if the emphasis should act on both the opening and closing bracket. + var emphasizesSourceBracket: Bool { switch self { case .bordered, .underline: return true diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift index fe3c06643..38153e154 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift @@ -1,5 +1,5 @@ // -// HighlighterTextView+createReadBlock.swift +// TextView+createReadBlock.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/20/23. diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift new file mode 100644 index 000000000..2fb440929 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -0,0 +1,18 @@ +// +// 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 findPanelPrevButtonClicked() + func findPanelNextButtonClicked() + func findPanelUpdateMatchCount(_ count: Int) + func findPanelClearEmphasis() +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift new file mode 100644 index 000000000..af0facadd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -0,0 +1,21 @@ +// +// FindPanelTarget.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import Foundation +import CodeEditTextView + +protocol FindPanelTarget: AnyObject { + var emphasisManager: EmphasisManager? { get } + var text: String { get } + + var cursorPositions: [CursorPosition] { get } + func setCursorPositions(_ positions: [CursorPosition]) + func updateCursorPosition() + + func findPanelWillShow(panelHeight: CGFloat) + func findPanelWillHide(panelHeight: CGFloat) +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift new file mode 100644 index 000000000..7b0ded2a2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -0,0 +1,191 @@ +// +// FindViewController+Delegate.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/3/25. +// + +import AppKit +import CodeEditTextView + +extension FindViewController: FindPanelDelegate { + 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 findPanelPrevButtonClicked() { + guard let textViewController = target as? TextViewController, + let emphasisManager = target?.emphasisManager else { return } + + // Check if there are any matches + if findMatches.isEmpty { + 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 { + BezelNotification.show( + symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: textViewController.textView + ) + } + + // 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 + ) + + 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) + } + + func findPanelNextButtonClicked() { + guard let textViewController = target as? TextViewController, + let emphasisManager = target?.emphasisManager else { return } + + // 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", + over: textViewController.textView + ) + 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 { + BezelNotification.show( + symbolName: "arrow.triangle.capsulepath", + over: textViewController.textView + ) + } + + // 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 + ) + + 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) + } + + 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 new file mode 100644 index 000000000..d67054f39 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -0,0 +1,125 @@ +// +// 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 + } + + let findOptions: NSRegularExpression.Options = smartCase(str: findText) ? [] : [.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 + } + + private 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() { } + + // 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 + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift new file mode 100644 index 000000000..99645ce08 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -0,0 +1,119 @@ +// +// FindViewController+Toggle.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/3/25. +// + +import AppKit + +extension FindViewController { + /// Show the find panel + /// + /// Performs the following: + /// - Makes the find panel the first responder. + /// - Sets the find panel to be just outside the visible area (`resolvedTopPadding - FindPanel.height`). + /// - Animates the find panel into position (resolvedTopPadding). + /// - Makes the find panel the first responder. + func showFindPanel(animated: Bool = true) { + if isShowingFindPanel { + // If panel is already showing, just focus the text field + _ = findPanel?.becomeFirstResponder() + return + } + + 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 + 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) + setFindPanelConstraintShow() + } onComplete: { } + + _ = findPanel?.becomeFirstResponder() + findPanel?.addEventMonitor() + } + + /// Hide the find panel + /// + /// Performs the following: + /// - Resigns the find panel from first responder. + /// - Animates the find panel just outside the visible area (`resolvedTopPadding - FindPanel.height`). + /// - Hides the find panel. + /// - Sets the text view to be the first responder. + func hideFindPanel(animated: Bool = true) { + isShowingFindPanel = false + _ = findPanel?.resignFirstResponder() + findPanel?.removeEventMonitor() + + conditionalAnimated(animated) { + target?.findPanelWillHide(panelHeight: FindPanel.height) + setFindPanelConstraintHide() + } onComplete: { [weak self] in + self?.findPanel.isHidden = true + } + + // Set first responder back to text view + if let textViewController = target as? TextViewController { + _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + } + } + + /// Performs an animation with a completion handler, conditionally animating the changes. + /// - Parameters: + /// - animated: Determines if the changes are performed in an animation context. + /// - animatable: Perform the changes to be animated in this callback. Implicit animation will be enabled. + /// - onComplete: Called when the changes are complete, animated or not. + private func conditionalAnimated(_ animated: Bool, animatable: () -> Void, onComplete: @escaping () -> Void) { + if animated { + withAnimation(animatable, onComplete: onComplete) + } else { + animatable() + onComplete() + } + } + + /// Runs the `animatable` callback in an animation context with implicit animation enabled. + /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this + /// callback to have them animated. + private func withAnimation(_ animatable: () -> Void, onComplete: @escaping () -> Void) { + NSAnimationContext.runAnimationGroup { animator in + animator.duration = 0.15 + animator.allowsImplicitAnimation = true + + animatable() + + view.updateConstraints() + view.layoutSubtreeIfNeeded() + } completionHandler: { + onComplete() + } + } + + /// Sets the find panel constraint to show the find panel. + /// Can be animated using implicit animation. + func setFindPanelConstraintShow() { + // Update the find panel's top to be equal to the view's top. + findPanelVerticalConstraint.constant = resolvedTopPadding + findPanelVerticalConstraint.isActive = true + } + + /// Sets the find panel constraint to hide the find panel. + /// Can be animated using implicit animation. + func setFindPanelConstraintHide() { + // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. + + // 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.isActive = true + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift new file mode 100644 index 000000000..4d9172c92 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -0,0 +1,116 @@ +// +// FindViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit +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? + + /// 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 { + setFindPanelConstraintShow() + } + } + } + + var childView: NSView + var findPanel: FindPanel! + var findMatches: [NSRange] = [] + + var currentFindMatchIndex: Int = 0 + var findText: String = "" + 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) + } + + init(target: FindPanelTarget, childView: NSView) { + self.target = target + self.childView = childView + 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() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + + // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to + // the find panel's bottom + // The find panel is constrained to the top of the view. + // The find panel's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. + // When visible, it's set to 0. + + view.clipsToBounds = false + view.addSubview(findPanel) + view.addSubview(childView) + + // Ensure find panel is always on top + findPanel.wantsLayer = true + findPanel.layer?.zPosition = 1000 + + findPanelVerticalConstraint = findPanel.topAnchor.constraint(equalTo: view.topAnchor) + + NSLayoutConstraint.activate([ + // Constrain find panel + findPanelVerticalConstraint, + findPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Constrain child view + childView.topAnchor.constraint(equalTo: view.topAnchor), + childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + override func viewWillAppear() { + super.viewWillAppear() + if isShowingFindPanel { // Update constraints for initial state + findPanel.isHidden = false + setFindPanelConstraintShow() + } else { + findPanel.isHidden = true + setFindPanelConstraintHide() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift new file mode 100644 index 000000000..86506018e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -0,0 +1,122 @@ +// +// 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 { + static let height: CGFloat = 28 + + 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) + } + + // MARK: - Search Text Management + + func updateSearchText(_ text: String) { + findQueryText = text + viewModel.findText = text + findDelegate?.findPanelDidUpdate(text) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift new file mode 100644 index 000000000..d18b33cc5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -0,0 +1,79 @@ +// +// FindPanelView.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import AppKit + +struct FindPanelView: View { + @Environment(\.controlActiveState) var activeState + @ObservedObject var viewModel: FindPanelViewModel + @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() + } + } + .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) + } + .buttonStyle(PanelButtonStyle()) + } + } + .padding(.horizontal, 5) + .frame(height: FindPanel.height) + .background(.bar) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift new file mode 100644 index 000000000..e8435f7a8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -0,0 +1,63 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine + +class FindPanelViewModel: ObservableObject { + @Published var findText: String = "" + @Published var matchCount: Int = 0 + @Published var isFocused: Bool = false + + 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 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() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 31568d4a1..8832eb337 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -60,7 +60,7 @@ public class GutterView: NSView { var highlightSelectedLines: Bool = true @Invalidating(.display) - var selectedLineTextColor: NSColor? = .textColor + var selectedLineTextColor: NSColor? = .labelColor @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) @@ -81,11 +81,13 @@ public class GutterView: NSView { public init( font: NSFont, textColor: NSColor, + selectedTextColor: NSColor?, textView: TextView, delegate: GutterViewDelegate? = nil ) { self.font = font self.textColor = textColor + self.selectedLineTextColor = selectedTextColor ?? .secondaryLabelColor self.textView = textView self.delegate = delegate @@ -201,15 +203,6 @@ public class GutterView: NSView { context.saveGState() - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - ContextSetHiddenSmoothingStyle(context, 16) - context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) for linePosition in textView.layoutManager.visibleLines() { if selectionRangeMap.intersects(integersIn: linePosition.range) { diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviding.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviding.swift diff --git a/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift b/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift new file mode 100644 index 000000000..37a52b93c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift @@ -0,0 +1,11 @@ +// +// EmphasisGroup.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/7/25. +// + +enum EmphasisGroup { + static let brackets = "codeedit.bracketPairs" + static let find = "codeedit.find" +} diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 7513df574..173f1cad3 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -63,7 +63,7 @@ enum Mock { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairHighlight: .flash + bracketPairEmphasis: .flash ) } diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index 639c1a885..b40e5c566 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -31,7 +31,7 @@ final class TextViewControllerTests: XCTestCase { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairHighlight: .flash + bracketPairEmphasis: .flash ) controller.loadView() @@ -84,7 +84,7 @@ final class TextViewControllerTests: XCTestCase { // MARK: Insets func test_editorInsets() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + let scrollView = try XCTUnwrap(controller.scrollView) scrollView.frame = .init( x: .zero, y: .zero, @@ -131,8 +131,44 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.gutterView.frame.origin.y, -16) } + func test_additionalInsets() throws { + let scrollView = try XCTUnwrap(controller.scrollView) + scrollView.frame = .init( + x: .zero, + y: .zero, + width: 100, + height: 100 + ) + + func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { + XCTAssertEqual(lhs.top, rhs.top) + XCTAssertEqual(lhs.right, rhs.right) + XCTAssertEqual(lhs.bottom, rhs.bottom) + XCTAssertEqual(lhs.left, rhs.left) + } + + controller.contentInsets = nil + controller.additionalTextInsets = nil + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) + XCTAssertEqual(controller.gutterView.frame.origin.y, 0) + + controller.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + controller.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) + + controller.findViewController?.showFindPanel(animated: false) + + // Extra insets do not effect find panel's insets + try assertInsetsEqual( + scrollView.contentInsets, + NSEdgeInsets(top: 10 + FindPanel.height, left: 0, bottom: 10, right: 0) + ) + XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) + XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - FindPanel.height) + } + func test_editorOverScroll_ZeroCondition() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + let scrollView = try XCTUnwrap(controller.scrollView) scrollView.frame = .zero // editorOverscroll: 0 @@ -213,41 +249,46 @@ final class TextViewControllerTests: XCTestCase { // MARK: Bracket Highlights - func test_bracketHighlights() { + func test_bracketHighlights() throws { + let textView = try XCTUnwrap(controller.textView) + let emphasisManager = try XCTUnwrap(textView.emphasisManager) + func getEmphasisCount() -> Int { emphasisManager.getEmphases(for: EmphasisGroup.brackets).count } + controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() let _ = controller.textView.becomeFirstResponder() - controller.bracketPairHighlight = nil + controller.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") + + XCTAssertEqual(getEmphasisCount(), 0, "Controller added bracket emphasis when setting is set to `nil`") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - controller.bracketPairHighlight = .bordered(color: .black) + controller.bracketPairEmphasis = .bordered(color: .black) controller.textView.setNeedsDisplay() controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for bordered.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.bracketPairHighlight = .underline(color: .black) + controller.bracketPairEmphasis = .underline(color: .black) controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for underline.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.bracketPairHighlight = .flash + controller.bracketPairEmphasis = .flash controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one emphasis for flash animation.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one layer for flash animation.") let exp = expectation(description: "Test after 0.8 seconds") let result = XCTWaiter.wait(for: [exp], timeout: 0.8) if result == XCTWaiter.Result.timedOut { - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove emphasis after flash animation.") } else { XCTFail("Delay interrupted") }