├── .github ├── CodeEditSourceEditor-Icon-128@2x.png ├── CodeEditTextView-Icon-128@2x.png ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── pull_request_template.md ├── scripts │ ├── build-docc.sh │ └── tests.sh └── workflows │ ├── CI-pull-request.yml │ ├── CI-push.yml │ ├── add-to-project.yml │ ├── build-documentation.yml │ ├── swiftlint.yml │ └── tests.yml ├── .gitignore ├── .gittattributes ├── .spi.yml ├── .swiftlint.yml ├── Example └── CodeEditSourceEditorExample │ ├── CodeEditSourceEditorExample.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── CodeEditSourceEditorExample.xcscheme │ └── CodeEditSourceEditorExample │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── CodeEditSourceEditorExample.entitlements │ ├── CodeEditSourceEditorExampleApp.swift │ ├── Documents │ └── CodeEditSourceEditorExampleDocument.swift │ ├── Extensions │ ├── EditorTheme+Default.swift │ ├── NSColor+Hex.swift │ └── String+Lines.swift │ ├── Info.plist │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── Views │ ├── ContentView.swift │ ├── IndentPicker.swift │ ├── LanguagePicker.swift │ └── StatusBar.swift ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CodeEditSourceEditor │ ├── CodeEditSourceEditor │ ├── CodeEditSourceEditor+Coordinator.swift │ └── CodeEditSourceEditor.swift │ ├── Controller │ ├── TextViewController+Cursor.swift │ ├── TextViewController+EmphasizeBracket.swift │ ├── TextViewController+FindPanelTarget.swift │ ├── TextViewController+GutterViewDelegate.swift │ ├── TextViewController+Highlighter.swift │ ├── TextViewController+IndentLines.swift │ ├── TextViewController+LoadView.swift │ ├── TextViewController+ReloadUI.swift │ ├── TextViewController+StyleViews.swift │ ├── TextViewController+TextFormation.swift │ ├── TextViewController+TextViewDelegate.swift │ ├── TextViewController+ToggleComment.swift │ ├── TextViewController+ToggleCommentCache.swift │ └── TextViewController.swift │ ├── Documentation.docc │ ├── CodeEditSourceEditor.md │ ├── Documentation.md │ ├── Resources │ │ ├── codeeditsourceeditor-logo@2x.png │ │ └── preview.png │ └── TextViewCoordinators.md │ ├── Enums │ ├── BracketPairEmphasis.swift │ ├── CaptureModifier.swift │ ├── CaptureModifierSet.swift │ ├── CaptureName.swift │ └── IndentOption.swift │ ├── Extensions │ ├── Color+Hex.swift │ ├── DispatchQueue+dispatchMainIfNot.swift │ ├── IndexSet+NSRange.swift │ ├── NSEdgeInsets+Equatable.swift │ ├── NSEdgeInsets+Helpers.swift │ ├── NSFont+LineHeight.swift │ ├── NSFont+RulerFont.swift │ ├── NSRange+ │ │ ├── NSRange+InputEdit.swift │ │ ├── NSRange+NSTextRange.swift │ │ ├── NSRange+String.swift │ │ ├── NSRange+TSRange.swift │ │ └── NSRange+isEmpty.swift │ ├── NSScrollView+percentScrolled.swift │ ├── Node+filterChildren.swift │ ├── Range+Length.swift │ ├── Result+ThrowOrReturn.swift │ ├── String+ │ │ ├── String+Groups.swift │ │ └── String+encoding.swift │ ├── TextMutation+isEmpty.swift │ ├── TextView+ │ │ ├── TextView+Menu.swift │ │ ├── TextView+Point.swift │ │ ├── TextView+TextFormation.swift │ │ └── TextView+createReadBlock.swift │ ├── Tree+prettyPrint.swift │ └── TreeSitterLanguage+TagFilter.swift │ ├── Filters │ ├── DeleteWhitespaceFilter.swift │ ├── TabReplacementFilter.swift │ └── TagFilter.swift │ ├── Find │ ├── FindMethod.swift │ ├── FindPanelMode.swift │ ├── FindPanelTarget.swift │ ├── FindViewController+Toggle.swift │ ├── FindViewController.swift │ ├── PanelView │ │ ├── FindControls.swift │ │ ├── FindMethodPicker.swift │ │ ├── FindModePicker.swift │ │ ├── FindPanelContent.swift │ │ ├── FindPanelHostingView.swift │ │ ├── FindPanelView.swift │ │ ├── FindSearchField.swift │ │ ├── ReplaceControls.swift │ │ └── ReplaceSearchField.swift │ └── ViewModel │ │ ├── FindPanelViewModel+Emphasis.swift │ │ ├── FindPanelViewModel+Find.swift │ │ ├── FindPanelViewModel+Move.swift │ │ ├── FindPanelViewModel+Replace.swift │ │ └── FindPanelViewModel.swift │ ├── Gutter │ └── GutterView.swift │ ├── Highlighting │ ├── HighlightProviding │ │ ├── HighlightProviderState.swift │ │ └── HighlightProviding.swift │ ├── HighlightRange.swift │ ├── Highlighter.swift │ ├── StyledRangeContainer │ │ └── StyledRangeContainer.swift │ └── VisibleRangeProvider.swift │ ├── Minimap │ ├── MinimapContentView.swift │ ├── MinimapLineFragmentView.swift │ ├── MinimapLineRenderer.swift │ ├── MinimapView+DocumentVisibleView.swift │ ├── MinimapView+DragVisibleView.swift │ ├── MinimapView+TextLayoutManagerDelegate.swift │ ├── MinimapView+TextSelectionManagerDelegate.swift │ └── MinimapView.swift │ ├── RangeStore │ ├── RangeStore+Coalesce.swift │ ├── RangeStore+FindIndex.swift │ ├── RangeStore+OffsetMetric.swift │ ├── RangeStore+StoredRun.swift │ ├── RangeStore.swift │ ├── RangeStoreElement.swift │ └── RangeStoreRun.swift │ ├── ReformattingGuide │ └── ReformattingGuideView.swift │ ├── SupportingViews │ ├── BezelNotification.swift │ ├── EffectView.swift │ ├── FlippedNSView.swift │ ├── ForwardingScrollView.swift │ ├── IconButtonStyle.swift │ ├── IconToggleStyle.swift │ ├── PanelStyles.swift │ └── PanelTextField.swift │ ├── TextViewCoordinator │ ├── CombineCoordinator.swift │ └── TextViewCoordinator.swift │ ├── Theme │ ├── EditorTheme.swift │ └── ThemeAttributesProviding.swift │ ├── TreeSitter │ ├── Atomic.swift │ ├── LanguageLayer.swift │ ├── TreeSitterClient+Edit.swift │ ├── TreeSitterClient+Highlight.swift │ ├── TreeSitterClient+Query.swift │ ├── TreeSitterClient.swift │ ├── TreeSitterExecutor.swift │ └── TreeSitterState.swift │ └── Utils │ ├── CursorPosition.swift │ ├── EmphasisGroup.swift │ └── WeakCoordinator.swift └── Tests └── CodeEditSourceEditorTests ├── CaptureModifierSetTests.swift ├── CodeEditSourceEditorTests.swift ├── Controller ├── TextViewController+IndentTests.swift └── TextViewControllerTests.swift ├── FindPanelTests.swift ├── Highlighting ├── HighlightProviderStateTest.swift ├── HighlighterTests.swift ├── StyledRangeContainerTests.swift └── VisibleRangeProviderTests.swift ├── Mock.swift ├── RangeStoreTests.swift ├── TagEditingTests.swift └── TreeSitterClientTests.swift /.github/CodeEditSourceEditor-Icon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeEditApp/CodeEditSourceEditor/7830486869832d2d3943719af91a068468a865dd/.github/CodeEditSourceEditor-Icon-128@2x.png -------------------------------------------------------------------------------- /.github/CodeEditTextView-Icon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeEditApp/CodeEditSourceEditor/7830486869832d2d3943719af91a068468a865dd/.github/CodeEditTextView-Icon-128@2x.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report 2 | description: Something is not working as expected. 3 | title: 🐞 4 | labels: bug 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Description 10 | placeholder: >- 11 | A clear and concise description of what the bug is... 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: To Reproduce 18 | description: >- 19 | Steps to reliably reproduce the behavior. 20 | placeholder: | 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: Expected Behavior 31 | placeholder: >- 32 | A clear and concise description of what you expected to happen... 33 | validations: 34 | required: true 35 | 36 | - type: textarea 37 | attributes: 38 | label: Version Information 39 | description: >- 40 | click on the version number on the welcome screen 41 | value: | 42 | CodeEditSourceEditor: [e.g. 0.x.y] 43 | macOS: [e.g. 13.2.1] 44 | Xcode: [e.g. 14.2] 45 | 46 | - type: textarea 47 | attributes: 48 | label: Additional Context 49 | placeholder: >- 50 | Any other context or considerations about the bug... 51 | 52 | - type: textarea 53 | attributes: 54 | label: Screenshots 55 | placeholder: >- 56 | If applicable, please provide relevant screenshots or screen recordings... 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature request 2 | description: Suggest an idea for this project 3 | title: ✨ 4 | labels: enhancement 5 | 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Description 10 | placeholder: >- 11 | A clear and concise description of what you would like to happen... 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: Alternatives Considered 18 | placeholder: >- 19 | Any alternative solutions or features you've considered... 20 | 21 | - type: textarea 22 | attributes: 23 | label: Additional Context 24 | placeholder: >- 25 | Any other context or considerations about the feature request... 26 | 27 | - type: textarea 28 | attributes: 29 | label: Screenshots 30 | placeholder: >- 31 | If applicable, please provide relevant screenshots or screen recordings... 32 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description 4 | 5 | 6 | 7 | ### Related Issues 8 | 9 | 10 | 11 | 12 | 13 | * #ISSUE_NUMBER 14 | 15 | ### Checklist 16 | 17 | 18 | 19 | - [ ] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) 20 | - [ ] The issues this PR addresses are related to each other 21 | - [ ] My changes generate no new warnings 22 | - [ ] My code builds and runs on my machine 23 | - [ ] My changes are all related to the related issue above 24 | - [ ] I documented my code 25 | 26 | ### Screenshots 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/scripts/build-docc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export LC_CTYPE=en_US.UTF-8 4 | 5 | set -o pipefail && xcodebuild clean docbuild -scheme CodeEditSourceEditor \ 6 | -destination generic/platform=macos \ 7 | -skipPackagePluginValidation \ 8 | OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path CodeEditSourceEditor --output-path ./docs" | xcpretty 9 | -------------------------------------------------------------------------------- /.github/scripts/tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ARCH="" 4 | 5 | if [ $1 = "arm" ] 6 | then 7 | ARCH="arm64" 8 | else 9 | ARCH="x86_64" 10 | fi 11 | 12 | echo "Building with arch: ${ARCH}" 13 | 14 | export LC_CTYPE=en_US.UTF-8 15 | 16 | set -o pipefail && arch -"${ARCH}" xcodebuild \ 17 | -scheme CodeEditSourceEditor \ 18 | -derivedDataPath ".build" \ 19 | -destination "platform=macOS,arch=${ARCH},name=My Mac" \ 20 | -skipPackagePluginValidation \ 21 | clean test | xcpretty 22 | -------------------------------------------------------------------------------- /.github/workflows/CI-pull-request.yml: -------------------------------------------------------------------------------- 1 | name: CI - Pull Request 2 | on: 3 | pull_request: 4 | branches: 5 | - 'main' 6 | workflow_dispatch: 7 | jobs: 8 | swiftlint: 9 | name: SwiftLint 10 | uses: ./.github/workflows/swiftlint.yml 11 | secrets: inherit 12 | test: 13 | name: Testing CodeEditSourceEditor 14 | needs: swiftlint 15 | uses: ./.github/workflows/tests.yml 16 | secrets: inherit 17 | -------------------------------------------------------------------------------- /.github/workflows/CI-push.yml: -------------------------------------------------------------------------------- 1 | name: CI - Push to main 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | workflow_dispatch: 7 | jobs: 8 | swiftlint: 9 | name: SwiftLint 10 | uses: ./.github/workflows/swiftlint.yml 11 | secrets: inherit 12 | test: 13 | name: Testing CodeEditSourceEditor 14 | needs: swiftlint 15 | uses: ./.github/workflows/tests.yml 16 | secrets: inherit 17 | build_documentation: 18 | name: Build Documentation 19 | needs: [swiftlint, test] 20 | uses: ./.github/workflows/build-documentation.yml 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | add-to-project: 10 | name: Add new issues labeled with enhancement or bug to project 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/add-to-project@v0.4.0 14 | with: 15 | # You can target a repository in a different organization 16 | # to the issue 17 | project-url: https://github.com/orgs/CodeEditApp/projects/3 18 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 19 | labeled: enhancement, bug 20 | label-operator: OR 21 | -------------------------------------------------------------------------------- /.github/workflows/build-documentation.yml: -------------------------------------------------------------------------------- 1 | name: build-documentation 2 | on: 3 | workflow_dispatch: 4 | workflow_call: 5 | jobs: 6 | build-docc: 7 | runs-on: [self-hosted, macOS] 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v3 11 | - name: Build Documentation 12 | run: exec ./.github/scripts/build-docc.sh 13 | - name: Init new repo in dist folder and commit generated files 14 | run: | 15 | cd docs 16 | git init 17 | git add -A 18 | git config --local user.email "action@github.com" 19 | git config --local user.name "GitHub Action" 20 | git commit -m 'deploy' 21 | 22 | - name: Force push to destination branch 23 | uses: ad-m/github-push-action@v0.8.0 24 | with: 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | branch: docs 27 | force: true 28 | directory: ./docs 29 | 30 | ############################ 31 | ##### IMPORTANT NOTICE ##### 32 | ############################ 33 | # This was used to build the documentation catalog until 34 | # it didn't produce the 'documentation' directory anymore. 35 | # 36 | # - uses: fwcd/swift-docc-action@v1.0.2 37 | # with: 38 | # target: CodeEditTextView 39 | # output: ./docs 40 | # hosting-base-path: CodeEditTextView 41 | # disable-indexing: 'true' 42 | # transform-for-static-hosting: 'true' 43 | # 44 | # The command that this plugin uses is: 45 | # 46 | # swift package --allow-writing-to-directory ./docs generate-documentation \ 47 | # --target CodeEditTextView 48 | # --output-path ./docs 49 | # --hosting-base-path CodeEditTextView 50 | # --disable-indexing 51 | # --transform-for-static-hosting 52 | # 53 | # We now use xcodebuild to build the documentation catalog instead. 54 | # 55 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | on: 3 | workflow_dispatch: 4 | workflow_call: 5 | jobs: 6 | SwiftLint: 7 | runs-on: [self-hosted, macOS] 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: GitHub Action for SwiftLint with --strict 11 | run: swiftlint --reporter github-actions-logging --strict 12 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | workflow_dispatch: 4 | workflow_call: 5 | jobs: 6 | code-edit-text-view-tests: 7 | name: Testing CodeEditSourceEditor 8 | runs-on: [self-hosted, macOS] 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v3 12 | - name: Testing Package 13 | run: exec ./.github/scripts/tests.sh arm 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | .idea/ 11 | -------------------------------------------------------------------------------- /.gittattributes: -------------------------------------------------------------------------------- 1 | .github/** linguist-vendored 2 | Sources/CodeEditTextView/Documentation.docc/** linguist-documentation 3 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://codeeditapp.github.io/CodeEditSourceEditor/documentation/codeeditsourceeditor" 4 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - .build 3 | 4 | disabled_rules: 5 | - todo 6 | - trailing_comma 7 | - nesting 8 | - optional_data_string_conversion 9 | 10 | type_name: 11 | excluded: 12 | - ID 13 | 14 | identifier_name: 15 | min_length: 2 16 | allowed_symbols: ['_'] 17 | excluded: 18 | - c 19 | - id 20 | - vc 21 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "codeeditlanguages", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", 7 | "state" : { 8 | "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", 9 | "version" : "0.1.20" 10 | } 11 | }, 12 | { 13 | "identity" : "codeeditsymbols", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", 16 | "state" : { 17 | "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", 18 | "version" : "0.2.3" 19 | } 20 | }, 21 | { 22 | "identity" : "codeedittextview", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", 25 | "state" : { 26 | "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", 27 | "version" : "0.11.1" 28 | } 29 | }, 30 | { 31 | "identity" : "rearrange", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/ChimeHQ/Rearrange", 34 | "state" : { 35 | "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", 36 | "version" : "1.8.1" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-collections", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-collections.git", 43 | "state" : { 44 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 45 | "version" : "1.2.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-custom-dump", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 52 | "state" : { 53 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 54 | "version" : "1.3.3" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftlintplugin", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/lukepistrol/SwiftLintPlugin", 61 | "state" : { 62 | "revision" : "ea6d3ca895b49910f790e98e4b4ca658e0fe490e", 63 | "version" : "0.54.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swifttreesitter", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", 70 | "state" : { 71 | "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", 72 | "version" : "0.9.0" 73 | } 74 | }, 75 | { 76 | "identity" : "textformation", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/ChimeHQ/TextFormation", 79 | "state" : { 80 | "revision" : "b1ce9a14bd86042bba4de62236028dc4ce9db6a1", 81 | "version" : "0.9.0" 82 | } 83 | }, 84 | { 85 | "identity" : "textstory", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/ChimeHQ/TextStory", 88 | "state" : { 89 | "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", 90 | "version" : "0.9.0" 91 | } 92 | }, 93 | { 94 | "identity" : "tree-sitter", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/tree-sitter/tree-sitter", 97 | "state" : { 98 | "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", 99 | "version" : "0.23.2" 100 | } 101 | }, 102 | { 103 | "identity" : "xctest-dynamic-overlay", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 106 | "state" : { 107 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 108 | "version" : "1.5.2" 109 | } 110 | } 111 | ], 112 | "version" : 2 113 | } 114 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/xcshareddata/xcschemes/CodeEditSourceEditorExample.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 57 | 63 | 64 | 65 | 66 | 72 | 74 | 80 | 81 | 82 | 83 | 85 | 86 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeEditSourceEditorExampleApp.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct CodeEditSourceEditorExampleApp: App { 12 | var body: some Scene { 13 | DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in 14 | ContentView(document: file.$document, fileURL: file.fileURL) 15 | } 16 | .windowToolbarStyle(.unifiedCompact) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Documents/CodeEditSourceEditorExampleDocument.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeEditSourceEditorExampleDocument.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | 11 | struct CodeEditSourceEditorExampleDocument: FileDocument { 12 | var text: String 13 | 14 | init(text: String = "") { 15 | self.text = text 16 | } 17 | 18 | static var readableContentTypes: [UTType] { 19 | [ 20 | .item 21 | ] 22 | } 23 | 24 | init(configuration: ReadConfiguration) throws { 25 | guard let data = configuration.file.regularFileContents else { 26 | throw CocoaError(.fileReadCorruptFile) 27 | } 28 | text = String(decoding: data, as: UTF8.self) 29 | } 30 | 31 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 32 | let data = Data(text.utf8) 33 | return .init(regularFileWithContents: data) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorTheme+Default.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import CodeEditSourceEditor 11 | 12 | extension EditorTheme { 13 | static var light: EditorTheme { 14 | EditorTheme( 15 | text: Attribute(color: NSColor(hex: "000000")), 16 | insertionPoint: NSColor(hex: "000000"), 17 | invisibles: Attribute(color: NSColor(hex: "D6D6D6")), 18 | background: NSColor(hex: "FFFFFF"), 19 | lineHighlight: NSColor(hex: "ECF5FF"), 20 | selection: NSColor(hex: "B2D7FF"), 21 | keywords: Attribute(color: NSColor(hex: "9B2393"), bold: true), 22 | commands: Attribute(color: NSColor(hex: "326D74")), 23 | types: Attribute(color: NSColor(hex: "0B4F79")), 24 | attributes: Attribute(color: NSColor(hex: "815F03")), 25 | variables: Attribute(color: NSColor(hex: "0F68A0")), 26 | values: Attribute(color: NSColor(hex: "6C36A9")), 27 | numbers: Attribute(color: NSColor(hex: "1C00CF")), 28 | strings: Attribute(color: NSColor(hex: "C41A16")), 29 | characters: Attribute(color: NSColor(hex: "1C00CF")), 30 | comments: Attribute(color: NSColor(hex: "267507")) 31 | ) 32 | } 33 | static var dark: EditorTheme { 34 | EditorTheme( 35 | text: Attribute(color: NSColor(hex: "FFFFFF")), 36 | insertionPoint: NSColor(hex: "007AFF"), 37 | invisibles: Attribute(color: NSColor(hex: "53606E")), 38 | background: NSColor(hex: "292A30"), 39 | lineHighlight: NSColor(hex: "2F3239"), 40 | selection: NSColor(hex: "646F83"), 41 | keywords: Attribute(color: NSColor(hex: "FF7AB2"), bold: true), 42 | commands: Attribute(color: NSColor(hex: "78C2B3")), 43 | types: Attribute(color: NSColor(hex: "6BDFFF")), 44 | attributes: Attribute(color: NSColor(hex: "CC9768")), 45 | variables: Attribute(color: NSColor(hex: "4EB0CC")), 46 | values: Attribute(color: NSColor(hex: "B281EB")), 47 | numbers: Attribute(color: NSColor(hex: "D9C97C")), 48 | strings: Attribute(color: NSColor(hex: "FF8170")), 49 | characters: Attribute(color: NSColor(hex: "D9C97C")), 50 | comments: Attribute(color: NSColor(hex: "7F8C98")) 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/NSColor+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor+Hex.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import AppKit 9 | 10 | extension NSColor { 11 | 12 | /// Initializes a `NSColor` from a HEX String (e.g.: `#1D2E3F`) and an optional alpha value. 13 | /// - Parameters: 14 | /// - hex: A String of a HEX representation of a color (format: `#1D2E3F`) 15 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 16 | convenience init(hex: String, alpha: Double = 1.0) { 17 | let hex = hex.trimmingCharacters(in: .alphanumerics.inverted) 18 | var int: UInt64 = 0 19 | Scanner(string: hex).scanHexInt64(&int) 20 | self.init(hex: Int(int), alpha: alpha) 21 | } 22 | 23 | /// Initializes a `NSColor` from an Int (e.g.: `0x1D2E3F`)and an optional alpha value. 24 | /// - Parameters: 25 | /// - hex: An Int of a HEX representation of a color (format: `0x1D2E3F`) 26 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 27 | convenience init(hex: Int, alpha: Double = 1.0) { 28 | let red = (hex >> 16) & 0xFF 29 | let green = (hex >> 8) & 0xFF 30 | let blue = hex & 0xFF 31 | self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha) 32 | } 33 | 34 | /// Returns an Int representing the `NSColor` in hex format (e.g.: 0x112233) 35 | var hex: Int { 36 | guard let components = cgColor.components, components.count >= 3 else { return 0 } 37 | 38 | let red = lround((Double(components[0]) * 255.0)) << 16 39 | let green = lround((Double(components[1]) * 255.0)) << 8 40 | let blue = lround((Double(components[2]) * 255.0)) 41 | 42 | return red | green | blue 43 | } 44 | 45 | /// Returns a HEX String representing the `NSColor` (e.g.: #112233) 46 | var hexString: String { 47 | let color = self.hex 48 | 49 | return "#" + String(format: "%06x", color) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/String+Lines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Lines.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | /// Calculates the first `n` lines and returns them as a new string. 12 | /// - Parameters: 13 | /// - lines: The number of lines to return. 14 | /// - maxLength: The maximum number of characters to copy. 15 | /// - Returns: A new string containing the lines. 16 | func getFirstLines(_ lines: Int = 1, maxLength: Int = 512) -> String { 17 | var string = "" 18 | var foundLines = 0 19 | var totalLength = 0 20 | for char in self.lazy { 21 | if char.isNewline { 22 | foundLines += 1 23 | } 24 | totalLength += 1 25 | if foundLines >= lines || totalLength >= maxLength { 26 | break 27 | } 28 | string.append(char) 29 | } 30 | return string 31 | } 32 | 33 | /// Calculates the last `n` lines and returns them as a new string. 34 | /// - Parameters: 35 | /// - lines: The number of lines to return. 36 | /// - maxLength: The maximum number of characters to copy. 37 | /// - Returns: A new string containing the lines. 38 | func getLastLines(_ lines: Int = 1, maxLength: Int = 512) -> String { 39 | var string = "" 40 | var foundLines = 0 41 | var totalLength = 0 42 | for char in self.lazy.reversed() { 43 | if char.isNewline { 44 | foundLines += 1 45 | } 46 | totalLength += 1 47 | if foundLines >= lines || totalLength >= maxLength { 48 | break 49 | } 50 | string = String(char) + string 51 | } 52 | return string 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | LSItemContentTypes 11 | 12 | com.example.plain-text 13 | 14 | NSUbiquitousDocumentUserActivityType 15 | $(PRODUCT_BUNDLE_IDENTIFIER).example-document 16 | 17 | 18 | UTImportedTypeDeclarations 19 | 20 | 21 | UTTypeConformsTo 22 | 23 | public.plain-text 24 | 25 | UTTypeDescription 26 | Example Text 27 | UTTypeIdentifier 28 | com.example.plain-text 29 | UTTypeTagSpecification 30 | 31 | public.filename-extension 32 | 33 | exampletext 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import CodeEditSourceEditor 10 | import CodeEditLanguages 11 | import CodeEditTextView 12 | 13 | struct ContentView: View { 14 | @Environment(\.colorScheme) 15 | var colorScheme 16 | 17 | @Binding var document: CodeEditSourceEditorExampleDocument 18 | let fileURL: URL? 19 | 20 | @State private var language: CodeLanguage = .default 21 | @State private var theme: EditorTheme = .light 22 | @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) 23 | @AppStorage("wrapLines") private var wrapLines: Bool = true 24 | @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] 25 | @AppStorage("systemCursor") private var useSystemCursor: Bool = false 26 | @State private var isInLongParse = false 27 | @State private var settingsIsPresented: Bool = false 28 | @State private var treeSitterClient = TreeSitterClient() 29 | @AppStorage("showMinimap") private var showMinimap: Bool = true 30 | @State private var indentOption: IndentOption = .spaces(count: 4) 31 | @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 32 | @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false 33 | 34 | init(document: Binding, fileURL: URL?) { 35 | self._document = document 36 | self.fileURL = fileURL 37 | } 38 | 39 | var body: some View { 40 | GeometryReader { proxy in 41 | CodeEditSourceEditor( 42 | $document.text, 43 | language: language, 44 | theme: theme, 45 | font: font, 46 | tabWidth: 4, 47 | indentOption: indentOption, 48 | lineHeight: 1.2, 49 | wrapLines: wrapLines, 50 | editorOverscroll: 0.3, 51 | cursorPositions: $cursorPositions, 52 | useThemeBackground: true, 53 | highlightProviders: [treeSitterClient], 54 | contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), 55 | additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), 56 | useSystemCursor: useSystemCursor, 57 | showMinimap: showMinimap, 58 | reformatAtColumn: reformatAtColumn, 59 | showReformattingGuide: showReformattingGuide 60 | ) 61 | .overlay(alignment: .bottom) { 62 | StatusBar( 63 | fileURL: fileURL, 64 | document: $document, 65 | wrapLines: $wrapLines, 66 | useSystemCursor: $useSystemCursor, 67 | cursorPositions: $cursorPositions, 68 | isInLongParse: $isInLongParse, 69 | language: $language, 70 | theme: $theme, 71 | showMinimap: $showMinimap, 72 | indentOption: $indentOption, 73 | reformatAtColumn: $reformatAtColumn, 74 | showReformattingGuide: $showReformattingGuide 75 | ) 76 | } 77 | .ignoresSafeArea() 78 | .frame(maxWidth: .infinity, maxHeight: .infinity) 79 | .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in 80 | withAnimation(.easeIn(duration: 0.1)) { 81 | isInLongParse = true 82 | } 83 | } 84 | .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in 85 | withAnimation(.easeIn(duration: 0.1)) { 86 | isInLongParse = false 87 | } 88 | } 89 | .onChange(of: colorScheme) { _, newValue in 90 | if newValue == .dark { 91 | theme = .dark 92 | } else { 93 | theme = .light 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | #Preview { 101 | ContentView(document: .constant(CodeEditSourceEditorExampleDocument()), fileURL: nil) 102 | } 103 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/IndentPicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CodeEditSourceEditor 3 | 4 | struct IndentPicker: View { 5 | @Binding var indentOption: IndentOption 6 | let enabled: Bool 7 | 8 | private let possibleIndents: [IndentOption] = [ 9 | .spaces(count: 4), 10 | .spaces(count: 2), 11 | .tab 12 | ] 13 | 14 | var body: some View { 15 | Picker( 16 | "Indent", 17 | selection: $indentOption 18 | ) { 19 | ForEach(possibleIndents, id: \.optionDescription) { indent in 20 | Text(indent.optionDescription) 21 | .tag(indent) 22 | } 23 | } 24 | .labelsHidden() 25 | .disabled(!enabled) 26 | } 27 | } 28 | 29 | extension IndentOption { 30 | var optionDescription: String { 31 | switch self { 32 | case .spaces(count: let count): 33 | return "Spaces (\(count))" 34 | case .tab: 35 | return "Tab" 36 | } 37 | } 38 | } 39 | 40 | #Preview { 41 | IndentPicker(indentOption: .constant(.spaces(count: 4)), enabled: true) 42 | } 43 | -------------------------------------------------------------------------------- /Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/LanguagePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguagePicker.swift 3 | // CodeEditSourceEditorExample 4 | // 5 | // Created by Khan Winter on 2/24/24. 6 | // 7 | 8 | import SwiftUI 9 | import CodeEditLanguages 10 | 11 | struct LanguagePicker: View { 12 | @Binding var language: CodeLanguage 13 | 14 | var body: some View { 15 | Picker( 16 | "Language", 17 | selection: $language 18 | ) { 19 | ForEach([.default] + CodeLanguage.allLanguages, id: \.id) { language in 20 | Text(language.id.rawValue) 21 | .tag(language) 22 | } 23 | } 24 | .labelsHidden() 25 | } 26 | } 27 | 28 | #Preview { 29 | LanguagePicker(language: .constant(.swift)) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CodeEdit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "codeeditlanguages", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", 7 | "state" : { 8 | "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", 9 | "version" : "0.1.20" 10 | } 11 | }, 12 | { 13 | "identity" : "codeeditsymbols", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", 16 | "state" : { 17 | "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", 18 | "version" : "0.2.3" 19 | } 20 | }, 21 | { 22 | "identity" : "codeedittextview", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", 25 | "state" : { 26 | "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", 27 | "version" : "0.11.1" 28 | } 29 | }, 30 | { 31 | "identity" : "rearrange", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/ChimeHQ/Rearrange", 34 | "state" : { 35 | "revision" : "8f97f721d8a08c6e01ab9f7460e53819bef72dfa", 36 | "version" : "1.5.3" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-collections", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-collections.git", 43 | "state" : { 44 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 45 | "version" : "1.2.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-custom-dump", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 52 | "state" : { 53 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 54 | "version" : "1.3.3" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftlintplugin", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/lukepistrol/SwiftLintPlugin", 61 | "state" : { 62 | "revision" : "3825ebf8d55bb877c91bc897e8e3d0c001f16fba", 63 | "version" : "0.58.2" 64 | } 65 | }, 66 | { 67 | "identity" : "swifttreesitter", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", 70 | "state" : { 71 | "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", 72 | "version" : "0.9.0" 73 | } 74 | }, 75 | { 76 | "identity" : "textformation", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/ChimeHQ/TextFormation", 79 | "state" : { 80 | "revision" : "b1ce9a14bd86042bba4de62236028dc4ce9db6a1", 81 | "version" : "0.9.0" 82 | } 83 | }, 84 | { 85 | "identity" : "textstory", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/ChimeHQ/TextStory", 88 | "state" : { 89 | "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", 90 | "version" : "0.9.0" 91 | } 92 | }, 93 | { 94 | "identity" : "tree-sitter", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/tree-sitter/tree-sitter", 97 | "state" : { 98 | "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", 99 | "version" : "0.23.2" 100 | } 101 | }, 102 | { 103 | "identity" : "xctest-dynamic-overlay", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 106 | "state" : { 107 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 108 | "version" : "1.5.2" 109 | } 110 | } 111 | ], 112 | "version" : 2 113 | } 114 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CodeEditSourceEditor", 8 | platforms: [.macOS(.v13)], 9 | products: [ 10 | // A source editor with useful features for code editing. 11 | .library( 12 | name: "CodeEditSourceEditor", 13 | targets: ["CodeEditSourceEditor"] 14 | ) 15 | ], 16 | dependencies: [ 17 | // A fast, efficient, text view for code. 18 | .package( 19 | url: "https://github.com/CodeEditApp/CodeEditTextView.git", 20 | from: "0.11.1" 21 | ), 22 | // tree-sitter languages 23 | .package( 24 | url: "https://github.com/CodeEditApp/CodeEditLanguages.git", 25 | exact: "0.1.20" 26 | ), 27 | // CodeEditSymbols 28 | .package( 29 | url: "https://github.com/CodeEditApp/CodeEditSymbols.git", 30 | exact: "0.2.3" 31 | ), 32 | // SwiftLint 33 | .package( 34 | url: "https://github.com/lukepistrol/SwiftLintPlugin", 35 | from: "0.2.2" 36 | ), 37 | // Rules for indentation, pair completion, whitespace 38 | .package( 39 | url: "https://github.com/ChimeHQ/TextFormation", 40 | from: "0.8.2" 41 | ), 42 | .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0") 43 | ], 44 | targets: [ 45 | // A source editor with useful features for code editing. 46 | .target( 47 | name: "CodeEditSourceEditor", 48 | dependencies: [ 49 | "CodeEditTextView", 50 | "CodeEditLanguages", 51 | "TextFormation", 52 | "CodeEditSymbols" 53 | ], 54 | plugins: [ 55 | .plugin(name: "SwiftLint", package: "SwiftLintPlugin") 56 | ] 57 | ), 58 | 59 | // Tests for the source editor 60 | .testTarget( 61 | name: "CodeEditSourceEditorTests", 62 | dependencies: [ 63 | "CodeEditSourceEditor", 64 | "CodeEditLanguages", 65 | .product(name: "CustomDump", package: "swift-custom-dump") 66 | ], 67 | plugins: [ 68 | .plugin(name: "SwiftLint", package: "SwiftLintPlugin") 69 | ] 70 | ), 71 | ] 72 | ) 73 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeEditSourceEditor+Coordinator.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/20/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import CodeEditTextView 11 | 12 | extension CodeEditSourceEditor { 13 | @MainActor 14 | public class Coordinator: NSObject { 15 | weak var controller: TextViewController? 16 | var isUpdatingFromRepresentable: Bool = false 17 | var isUpdateFromTextView: Bool = false 18 | var text: TextAPI 19 | @Binding var cursorPositions: [CursorPosition] 20 | 21 | private(set) var highlightProviders: [any HighlightProviding] 22 | 23 | init(text: TextAPI, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]?) { 24 | self.text = text 25 | self._cursorPositions = cursorPositions 26 | self.highlightProviders = highlightProviders ?? [TreeSitterClient()] 27 | super.init() 28 | 29 | NotificationCenter.default.addObserver( 30 | self, 31 | selector: #selector(textViewDidChangeText(_:)), 32 | name: TextView.textDidChangeNotification, 33 | object: nil 34 | ) 35 | 36 | NotificationCenter.default.addObserver( 37 | self, 38 | selector: #selector(textControllerCursorsDidUpdate(_:)), 39 | name: TextViewController.cursorPositionUpdatedNotification, 40 | object: nil 41 | ) 42 | } 43 | 44 | func updateHighlightProviders(_ highlightProviders: [any HighlightProviding]?) { 45 | guard let highlightProviders else { 46 | return // Keep our default `TreeSitterClient` if they're `nil` 47 | } 48 | // Otherwise, we can replace the stored providers. 49 | self.highlightProviders = highlightProviders 50 | } 51 | 52 | @objc func textViewDidChangeText(_ notification: Notification) { 53 | guard let textView = notification.object as? TextView, 54 | let controller, 55 | controller.textView === textView else { 56 | return 57 | } 58 | if case .binding(let binding) = text { 59 | binding.wrappedValue = textView.string 60 | } 61 | } 62 | 63 | @objc func textControllerCursorsDidUpdate(_ notification: Notification) { 64 | guard let notificationController = notification.object as? TextViewController, 65 | notificationController === controller else { 66 | return 67 | } 68 | guard !isUpdatingFromRepresentable else { return } 69 | self.isUpdateFromTextView = true 70 | cursorPositions = notificationController.cursorPositions 71 | } 72 | 73 | deinit { 74 | NotificationCenter.default.removeObserver(self) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+Cursor.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Elias Wahl on 15.03.23. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | extension TextViewController { 12 | /// Sets new cursor positions. 13 | /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. 14 | public func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool = false) { 15 | if isPostingCursorNotification { return } 16 | var newSelectedRanges: [NSRange] = [] 17 | for position in positions { 18 | let line = position.line 19 | let column = position.column 20 | guard (line > 0 && column > 0) || (position.range != .notFound) else { continue } 21 | 22 | if position.range == .notFound { 23 | if textView.textStorage.length == 0 { 24 | // If the file is blank, automatically place the cursor in the first index. 25 | newSelectedRanges.append(NSRange(location: 0, length: 0)) 26 | } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { 27 | // If this is a valid line, set the new position 28 | let index = linePosition.range.lowerBound + min(linePosition.range.upperBound, column - 1) 29 | newSelectedRanges.append(NSRange(location: index, length: 0)) 30 | } 31 | } else { 32 | newSelectedRanges.append(position.range) 33 | } 34 | } 35 | textView.selectionManager.setSelectedRanges(newSelectedRanges) 36 | 37 | if scrollToVisible { 38 | textView.scrollSelectionToVisible() 39 | } 40 | } 41 | 42 | /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. 43 | func updateCursorPosition() { 44 | var positions: [CursorPosition] = [] 45 | for selectedRange in textView.selectionManager.textSelections { 46 | guard let linePosition = textView.layoutManager.textLineForOffset(selectedRange.range.location) else { 47 | continue 48 | } 49 | let column = (selectedRange.range.location - linePosition.range.location) + 1 50 | let row = linePosition.index + 1 51 | positions.append(CursorPosition(range: selectedRange.range, line: row, column: column)) 52 | } 53 | 54 | isPostingCursorNotification = true 55 | cursorPositions = positions.sorted(by: { $0.range.location < $1.range.location }) 56 | NotificationCenter.default.post(name: Self.cursorPositionUpdatedNotification, object: self) 57 | for coordinator in self.textCoordinators.values() { 58 | coordinator.textViewDidChangeSelection(controller: self, newPositions: cursorPositions) 59 | } 60 | isPostingCursorNotification = false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+FindPanelTarget.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/16/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | extension TextViewController: FindPanelTarget { 12 | var findPanelTargetView: NSView { 13 | textView 14 | } 15 | 16 | func findPanelWillShow(panelHeight: CGFloat) { 17 | updateContentInsets() 18 | } 19 | 20 | func findPanelWillHide(panelHeight: CGFloat) { 21 | updateContentInsets() 22 | } 23 | 24 | func findPanelModeDidChange(to mode: FindPanelMode) { 25 | updateContentInsets() 26 | } 27 | 28 | var emphasisManager: EmphasisManager? { 29 | textView?.emphasisManager 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+GutterViewDelegate.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/17/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TextViewController: GutterViewDelegate { 11 | public func gutterViewWidthDidUpdate(newWidth: CGFloat) { 12 | gutterView?.frame.size.width = newWidth 13 | textView?.textInsets = textViewInsets 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+Highlighter.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/14/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftTreeSitter 10 | 11 | extension TextViewController { 12 | package func setUpHighlighter() { 13 | if let highlighter { 14 | textView.removeStorageDelegate(highlighter) 15 | self.highlighter = nil 16 | } 17 | 18 | let highlighter = Highlighter( 19 | textView: textView, 20 | minimapView: minimapView, 21 | providers: highlightProviders, 22 | attributeProvider: self, 23 | language: language 24 | ) 25 | textView.addStorageDelegate(highlighter) 26 | self.highlighter = highlighter 27 | } 28 | 29 | /// Sets new highlight providers. Recognizes when objects move in the array or are removed or inserted. 30 | /// 31 | /// This is in place of a setter on the ``highlightProviders`` variable to avoid wasting resources setting up 32 | /// providers early. 33 | /// 34 | /// - Parameter newProviders: All the new providers. 35 | package func setHighlightProviders(_ newProviders: [HighlightProviding]) { 36 | highlighter?.setProviders(newProviders) 37 | highlightProviders = newProviders 38 | } 39 | } 40 | 41 | extension TextViewController: ThemeAttributesProviding { 42 | public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { 43 | [ 44 | .font: theme.fontFor(for: capture, from: font), 45 | .foregroundColor: theme.colorFor(capture), 46 | .kern: textView.kern 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+ReloadUI.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/17/25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension TextViewController { 11 | func reloadUI() { 12 | textView.isEditable = isEditable 13 | textView.isSelectable = isSelectable 14 | 15 | styleScrollView() 16 | styleTextView() 17 | styleGutterView() 18 | 19 | highlighter?.invalidate() 20 | minimapView.updateContentViewHeight() 21 | minimapView.updateDocumentVisibleViewPosition() 22 | 23 | // Update reformatting guide position 24 | if let guideView = textView.subviews.first(where: { $0 is ReformattingGuideView }) as? ReformattingGuideView { 25 | guideView.updatePosition(in: textView) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+TextViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewController+TextViewDelegate.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/14/23. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import TextStory 11 | 12 | extension TextViewController: TextViewDelegate { 13 | public func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { 14 | for coordinator in self.textCoordinators.values() { 15 | if let coordinator = coordinator as? TextViewDelegate { 16 | coordinator.textView(textView, willReplaceContentsIn: range, with: string) 17 | } 18 | } 19 | } 20 | 21 | public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { 22 | gutterView.needsDisplay = true 23 | for coordinator in self.textCoordinators.values() { 24 | if let coordinator = coordinator as? TextViewDelegate { 25 | coordinator.textView(textView, didReplaceContentsIn: range, with: string) 26 | } else { 27 | coordinator.textViewDidChangeText(controller: self) 28 | } 29 | } 30 | } 31 | 32 | public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { 33 | let mutation = TextMutation( 34 | string: string, 35 | range: range, 36 | limit: textView.textStorage.length 37 | ) 38 | 39 | return shouldApplyMutation(mutation, to: textView) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleCommentCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Tommy Ludwig on 23.08.24. 6 | // 7 | 8 | import CodeEditTextView 9 | 10 | extension TextViewController { 11 | /// A cache used to store and manage comment-related information for lines in a text view. 12 | /// This class helps in efficiently inserting or removing comment characters at specific line positions. 13 | struct CommentCache: ~Copyable { 14 | /// Holds necessary information like the lines range 15 | var lineInfos: [TextLineStorage.TextLinePosition?] = [] 16 | /// Caches the content of lines by their indices. Populated only if comment characters need to be inserted. 17 | var lineStrings: [Int: String] = [:] 18 | /// Caches the shift range factors for lines based on their indices. 19 | var shiftRangeFactors: [Int: Int] = [:] 20 | /// Insertion is necessary only if at least one of the selected 21 | /// lines does not already start with `startCommentChars`. 22 | var shouldInsertCommentChars: Bool = false 23 | var startCommentChars: String? 24 | /// The characters used to end a comment. 25 | /// This is applicable for languages (e.g., HTML) 26 | /// that require a closing comment sequence at the end of the line. 27 | var endCommentChars: String? 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md: -------------------------------------------------------------------------------- 1 | # ``CodeEditSourceEditor/CodeEditSourceEditor`` 2 | 3 | ## Usage 4 | 5 | ```swift 6 | import CodeEditSourceEditor 7 | 8 | struct ContentView: View { 9 | 10 | @State var text = "let x = 1.0" 11 | @State var theme = EditorTheme(...) 12 | @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) 13 | @State var tabWidth = 4 14 | @State var lineHeight = 1.2 15 | @State var editorOverscroll = 0.3 16 | 17 | var body: some View { 18 | CodeEditSourceEditor( 19 | $text, 20 | language: .swift, 21 | theme: $theme, 22 | font: $font, 23 | tabWidth: $tabWidth, 24 | lineHeight: $lineHeight, 25 | editorOverscroll: $editorOverscroll 26 | ) 27 | } 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``CodeEditSourceEditor`` 2 | 3 | A code editor with syntax highlighting powered by tree-sitter. 4 | 5 | ## Overview 6 | 7 | ![logo](codeeditsourceeditor-logo) 8 | 9 | An Xcode-inspired code editor view written in Swift powered by tree-sitter for [CodeEdit](https://github.com/CodeEditApp/CodeEdit). Features include syntax highlighting (based on the provided theme), code completion, find and replace, text diff, validation, current line highlighting, minimap, inline messages (warnings and errors), bracket matching, and more. 10 | 11 | ![banner](preview) 12 | 13 | This package includes both `AppKit` and `SwiftUI` components. It also relies on the [`CodeEditLanguages`](https://github.com/CodeEditApp/CodeEditLanguages) for optional syntax highlighting using tree-sitter. 14 | 15 | > **CodeEditSourceEditor is currently in development and it is not ready for production use.**
Please check back later for updates on this project. Contributors are welcome as we build out the features mentioned above! 16 | 17 | ## Currently Supported Languages 18 | 19 | See this issue [CodeEditLanguages#10](https://github.com/CodeEditApp/CodeEditLanguages/issues/10) on `CodeEditLanguages` for more information on supported languages. 20 | 21 | ## Dependencies 22 | 23 | Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! 24 | 25 | | Package | Source | Author | 26 | | :- | :- | :- | 27 | | `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | 28 | 29 | ## License 30 | 31 | Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/main/LICENSE.md). 32 | 33 | ## Topics 34 | 35 | ### Text View 36 | 37 | - ``CodeEditSourceEditor/CodeEditSourceEditor`` 38 | - ``TextViewController`` 39 | - ``GutterView`` 40 | 41 | ### Themes 42 | 43 | - ``EditorTheme`` 44 | 45 | ### Text Coordinators 46 | 47 | - 48 | - ``TextViewCoordinator`` 49 | - ``CombineCoordinator`` 50 | 51 | ### Cursors 52 | 53 | - ``CursorPosition`` 54 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeeditsourceeditor-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeEditApp/CodeEditSourceEditor/7830486869832d2d3943719af91a068468a865dd/Sources/CodeEditSourceEditor/Documentation.docc/Resources/codeeditsourceeditor-logo@2x.png -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Documentation.docc/Resources/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeEditApp/CodeEditSourceEditor/7830486869832d2d3943719af91a068468a865dd/Sources/CodeEditSourceEditor/Documentation.docc/Resources/preview.png -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Documentation.docc/TextViewCoordinators.md: -------------------------------------------------------------------------------- 1 | # TextView Coordinators 2 | 3 | Add advanced functionality to CodeEditSourceEditor. 4 | 5 | ## Overview 6 | 7 | CodeEditSourceEditor provides this API as a way to push messages up from underlying components into SwiftUI land without requiring passing callbacks for each message to the ``CodeEditSourceEditor`` initializer. 8 | 9 | They're very useful for updating UI that is directly related to the state of the editor, such as the current cursor position. For an example of how this can be useful, see the ``CombineCoordinator`` class, which implements combine publishers for the messages this protocol provides. 10 | 11 | They can also be used to get more detailed text editing notifications by conforming to the `TextViewDelegate` (from CodeEditTextView) protocol. In that case they'll receive most text change notifications. 12 | 13 | ### Make a Coordinator 14 | 15 | To create a coordinator, first create a class that conforms to the ``TextViewCoordinator`` protocol. 16 | 17 | ```swift 18 | class MyCoordinator { 19 | func prepareCoordinator(controller: TextViewController) { 20 | // Do any setup, such as keeping a (weak) reference to the controller or adding a text storage delegate. 21 | } 22 | } 23 | ``` 24 | 25 | Add any methods required for your coordinator to work, such as receiving notifications when text is edited, or 26 | 27 | ```swift 28 | class MyCoordinator { 29 | func prepareCoordinator(controller: TextViewController) { /* ... */ } 30 | 31 | func textViewDidChangeText(controller: TextViewController) { 32 | // Text was updated. 33 | } 34 | 35 | func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { 36 | // Selections were changed 37 | } 38 | } 39 | ``` 40 | 41 | If your coordinator keeps any references to anything in CodeEditSourceEditor, make sure to dereference them using the ``TextViewCoordinator/destroy()-9nzfl`` method. 42 | 43 | ```swift 44 | class MyCoordinator { 45 | func prepareCoordinator(controller: TextViewController) { /* ... */ } 46 | func textViewDidChangeText(controller: TextViewController) { /* ... */ } 47 | func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { /* ... */ } 48 | 49 | func destroy() { 50 | // Release any resources, `nil` any weak variables, remove delegates, etc. 51 | } 52 | } 53 | ``` 54 | 55 | ### Coordinator Lifecycle 56 | 57 | A coordinator makes no assumptions about initialization, leaving the developer to pass any init parameters to the coordinator. 58 | 59 | The lifecycle looks like this: 60 | - Coordinator initialized (by user, not CodeEditSourceEditor). 61 | - Coordinator given to CodeEditSourceEditor. 62 | - ``TextViewCoordinator/prepareCoordinator(controller:)`` is called. 63 | - Events occur, coordinators are notified in the order they were passed to CodeEditSourceEditor. 64 | - CodeEditSourceEditor is being closed. 65 | - ``TextViewCoordinator/destroy()-9nzfl`` is called. 66 | - CodeEditSourceEditor stops referencing the coordinator. 67 | 68 | ### TextViewDelegate Conformance 69 | 70 | If a coordinator conforms to the `TextViewDelegate` protocol from the `CodeEditTextView` package, it will receive forwarded delegate messages for the editor's text view. 71 | 72 | The messages it will receive: 73 | ```swift 74 | func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) 75 | func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) 76 | ``` 77 | 78 | It will _not_ receive the following: 79 | ```swift 80 | func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool 81 | ``` 82 | 83 | ### Example 84 | 85 | To see an example of a coordinator and they're use case, see the ``CombineCoordinator`` class. This class creates a coordinator that passes notifications on to a Combine stream. 86 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BracketPairEmphasis.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/3/23. 6 | // 7 | 8 | import AppKit 9 | 10 | /// An enum representing the type of emphasis to use for bracket pairs. 11 | public enum BracketPairEmphasis: Equatable { 12 | /// Emphasize both the opening and closing character in a pair with a bounding box. 13 | /// The boxes will stay on screen until the cursor moves away from the bracket pair. 14 | case bordered(color: NSColor) 15 | /// Flash a yellow emphasis box on only the opposite character in the pair. 16 | /// This is closely matched to Xcode's flash emphasis for bracket pairs, and animates in and out over the course 17 | /// of `0.75` seconds. 18 | case flash 19 | /// Emphasize both the opening and closing character in a pair with an underline. 20 | /// The underline will stay on screen until the cursor moves away from the bracket pair. 21 | case underline(color: NSColor) 22 | 23 | public static func == (lhs: BracketPairEmphasis, rhs: BracketPairEmphasis) -> Bool { 24 | switch (lhs, rhs) { 25 | case (.flash, .flash): 26 | return true 27 | case (.bordered(let lhsColor), .bordered(let rhsColor)): 28 | return lhsColor == rhsColor 29 | case (.underline(let lhsColor), .underline(let rhsColor)): 30 | return lhsColor == rhsColor 31 | default: 32 | return false 33 | } 34 | } 35 | 36 | /// Returns `true` if the emphasis should act on both the opening and closing bracket. 37 | var emphasizesSourceBracket: Bool { 38 | switch self { 39 | case .bordered, .underline: 40 | return true 41 | case .flash: 42 | return false 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Enums/CaptureModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureModifiers.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/24/24. 6 | // 7 | 8 | // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokenModifiers 9 | 10 | /// A collection of possible syntax capture modifiers. Represented by an integer for memory efficiency, and with the 11 | /// ability to convert to and from strings for ease of use with tools. 12 | /// 13 | /// These are useful for helping differentiate between similar types of syntax. Eg two variables may be declared like 14 | /// ```swift 15 | /// var a = 1 16 | /// let b = 1 17 | /// ``` 18 | /// ``CaptureName`` will represent both these later in code, but combined ``CaptureModifier`` themes can differentiate 19 | /// between constants (`b` in the example) and regular variables (`a` in the example). 20 | /// 21 | /// This is `Int8` raw representable for memory considerations. In large documents there can be *lots* of these created 22 | /// and passed around, so representing them with a single integer is preferable to a string to save memory. 23 | /// 24 | public enum CaptureModifier: Int8, CaseIterable, Sendable { 25 | case declaration 26 | case definition 27 | case readonly 28 | case `static` 29 | case deprecated 30 | case abstract 31 | case async 32 | case modification 33 | case documentation 34 | case defaultLibrary 35 | 36 | public var stringValue: String { 37 | switch self { 38 | case .declaration: 39 | return "declaration" 40 | case .definition: 41 | return "definition" 42 | case .readonly: 43 | return "readonly" 44 | case .static: 45 | return "static" 46 | case .deprecated: 47 | return "deprecated" 48 | case .abstract: 49 | return "abstract" 50 | case .async: 51 | return "async" 52 | case .modification: 53 | return "modification" 54 | case .documentation: 55 | return "documentation" 56 | case .defaultLibrary: 57 | return "defaultLibrary" 58 | } 59 | } 60 | 61 | // swiftlint:disable:next cyclomatic_complexity 62 | public static func fromString(_ string: String) -> CaptureModifier? { 63 | switch string { 64 | case "declaration": 65 | return .declaration 66 | case "definition": 67 | return .definition 68 | case "readonly": 69 | return .readonly 70 | case "static`": 71 | return .static 72 | case "deprecated": 73 | return .deprecated 74 | case "abstract": 75 | return .abstract 76 | case "async": 77 | return .async 78 | case "modification": 79 | return .modification 80 | case "documentation": 81 | return .documentation 82 | case "defaultLibrary": 83 | return .defaultLibrary 84 | default: 85 | return nil 86 | } 87 | } 88 | } 89 | 90 | extension CaptureModifier: CustomDebugStringConvertible { 91 | public var debugDescription: String { stringValue } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Enums/CaptureModifierSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureModifierSet.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 12/16/24. 6 | // 7 | 8 | /// A set of capture modifiers, efficiently represented by a single integer. 9 | public struct CaptureModifierSet: OptionSet, Equatable, Hashable, Sendable { 10 | public var rawValue: UInt 11 | 12 | public init(rawValue: UInt) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | public static let declaration = CaptureModifierSet(rawValue: 1 << CaptureModifier.declaration.rawValue) 17 | public static let definition = CaptureModifierSet(rawValue: 1 << CaptureModifier.definition.rawValue) 18 | public static let readonly = CaptureModifierSet(rawValue: 1 << CaptureModifier.readonly.rawValue) 19 | public static let `static` = CaptureModifierSet(rawValue: 1 << CaptureModifier.static.rawValue) 20 | public static let deprecated = CaptureModifierSet(rawValue: 1 << CaptureModifier.deprecated.rawValue) 21 | public static let abstract = CaptureModifierSet(rawValue: 1 << CaptureModifier.abstract.rawValue) 22 | public static let async = CaptureModifierSet(rawValue: 1 << CaptureModifier.async.rawValue) 23 | public static let modification = CaptureModifierSet(rawValue: 1 << CaptureModifier.modification.rawValue) 24 | public static let documentation = CaptureModifierSet(rawValue: 1 << CaptureModifier.documentation.rawValue) 25 | public static let defaultLibrary = CaptureModifierSet(rawValue: 1 << CaptureModifier.defaultLibrary.rawValue) 26 | 27 | /// All values in the set. 28 | /// 29 | /// Results will be returned in order of ``CaptureModifier``'s raw value. 30 | /// This variable ignores garbage values in the ``rawValue`` property. 31 | public var values: [CaptureModifier] { 32 | var rawValue = self.rawValue 33 | 34 | // This set is represented by an integer, where each `1` in the binary number represents a value. 35 | // We can interpret the index of the `1` as the raw value of a ``CaptureModifier`` (the index in 0b0100 would 36 | // be 2). This loops through each `1` in the `rawValue`, finds the represented modifier, and 0's out the `1` so 37 | // we can get the next one using the binary & operator (0b0110 -> 0b0100 -> 0b0000 -> finish). 38 | var values: [Int8] = [] 39 | while rawValue > 0 { 40 | values.append(Int8(rawValue.trailingZeroBitCount)) 41 | // Clears the bit at the desired index (eg: 0b110 if clearing index 0) 42 | rawValue &= ~UInt(1 << rawValue.trailingZeroBitCount) 43 | } 44 | return values.compactMap({ CaptureModifier(rawValue: $0) }) 45 | } 46 | 47 | /// Inserts the modifier into the set. 48 | public mutating func insert(_ value: CaptureModifier) { 49 | rawValue |= 1 << value.rawValue 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Enums/IndentOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndentOption.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/26/23. 6 | // 7 | 8 | /// Represents what to insert on a tab key press. 9 | public enum IndentOption: Equatable, Hashable { 10 | case spaces(count: Int) 11 | case tab 12 | 13 | var stringValue: String { 14 | switch self { 15 | case .spaces(let count): 16 | return String(repeating: " ", count: count) 17 | case .tab: 18 | return "\t" 19 | } 20 | } 21 | 22 | /// Represents the number of chacters that indent represents 23 | var charCount: Int { 24 | switch self { 25 | case .spaces(let count): 26 | count 27 | case .tab: 28 | 1 29 | } 30 | } 31 | 32 | public static func == (lhs: IndentOption, rhs: IndentOption) -> Bool { 33 | switch (lhs, rhs) { 34 | case (.tab, .tab): 35 | return true 36 | case (.spaces(let lhsCount), .spaces(let rhsCount)): 37 | return lhsCount == rhsCount 38 | default: 39 | return false 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/Color+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+HEX.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Lukas Pistrol on 27.05.22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Color { 11 | 12 | /// Initializes a `Color` from a HEX String (e.g.: `#1D2E3F`) and an optional alpha value. 13 | /// - Parameters: 14 | /// - hex: A String of a HEX representation of a color (format: `#1D2E3F`) 15 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 16 | init(hex: String, alpha: Double = 1.0) { 17 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 18 | var int: UInt64 = 0 19 | Scanner(string: hex).scanHexInt64(&int) 20 | self.init(hex: Int(int), alpha: alpha) 21 | } 22 | 23 | /// Initializes a `Color` from an Int (e.g.: `0x1D2E3F`)and an optional alpha value. 24 | /// - Parameters: 25 | /// - hex: An Int of a HEX representation of a color (format: `0x1D2E3F`) 26 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 27 | init(hex: Int, alpha: Double = 1.0) { 28 | let red = (hex >> 16) & 0xFF 29 | let green = (hex >> 8) & 0xFF 30 | let blue = hex & 0xFF 31 | self.init(.sRGB, red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, opacity: alpha) 32 | } 33 | 34 | /// Returns an Int representing the `Color` in hex format (e.g.: 0x112233) 35 | var hex: Int { 36 | guard let components = cgColor?.components, components.count >= 3 else { return 0 } 37 | 38 | let red = lround((Double(components[0]) * 255.0)) << 16 39 | let green = lround((Double(components[1]) * 255.0)) << 8 40 | let blue = lround((Double(components[2]) * 255.0)) 41 | 42 | return red | green | blue 43 | } 44 | 45 | /// Returns a HEX String representing the `Color` (e.g.: #112233) 46 | var hexString: String { 47 | let color = self.hex 48 | 49 | return "#" + String(format: "%06x", color) 50 | } 51 | 52 | /// The alpha (opacity) component of the Color (0.0 - 1.0) 53 | var alphaComponent: Double { 54 | return NSColor(self).alphaComponent 55 | } 56 | } 57 | 58 | public extension NSColor { 59 | 60 | /// Initializes a `NSColor` from a HEX String (e.g.: `#1D2E3F`) and an optional alpha value. 61 | /// - Parameters: 62 | /// - hex: A String of a HEX representation of a color (format: `#1D2E3F`) 63 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 64 | convenience init(hex: String, alpha: Double = 1.0) { 65 | let hex = hex.trimmingCharacters(in: .alphanumerics.inverted) 66 | var int: UInt64 = 0 67 | Scanner(string: hex).scanHexInt64(&int) 68 | self.init(hex: Int(int), alpha: alpha) 69 | } 70 | 71 | /// Initializes a `NSColor` from an Int (e.g.: `0x1D2E3F`)and an optional alpha value. 72 | /// - Parameters: 73 | /// - hex: An Int of a HEX representation of a color (format: `0x1D2E3F`) 74 | /// - alpha: A Double indicating the alpha value from `0.0` to `1.0` 75 | convenience init(hex: Int, alpha: Double = 1.0) { 76 | let red = (hex >> 16) & 0xFF 77 | let green = (hex >> 8) & 0xFF 78 | let blue = hex & 0xFF 79 | self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha) 80 | } 81 | 82 | /// Returns an Int representing the `NSColor` in hex format (e.g.: 0x112233) 83 | var hex: Int { 84 | guard let components = cgColor.components, components.count >= 3 else { return 0 } 85 | 86 | let red = lround((Double(components[0]) * 255.0)) << 16 87 | let green = lround((Double(components[1]) * 255.0)) << 8 88 | let blue = lround((Double(components[2]) * 255.0)) 89 | 90 | return red | green | blue 91 | } 92 | 93 | /// Returns a HEX String representing the `NSColor` (e.g.: #112233) 94 | var hexString: String { 95 | let color = self.hex 96 | 97 | return "#" + String(format: "%06x", color) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DispatchQueue+dispatchMainIfNot.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/2/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Helper methods for dispatching (sync or async) on the main queue only if the calling thread is not already the 11 | /// main queue. 12 | 13 | extension DispatchQueue { 14 | /// Executes the work item on the main thread, dispatching asynchronously if the thread is not the main thread. 15 | /// - Parameter item: The work item to execute on the main thread. 16 | static func dispatchMainIfNot(_ item: @escaping () -> Void) { 17 | if Thread.isMainThread { 18 | item() 19 | } else { 20 | DispatchQueue.main.async { 21 | item() 22 | } 23 | } 24 | } 25 | 26 | /// Executes the work item on the main thread, keeping control on the calling thread until the work item is 27 | /// executed if not already on the main thread. 28 | /// - Parameter item: The work item to execute. 29 | /// - Returns: The value of the work item. 30 | static func syncMainIfNot(_ item: @escaping () -> T) -> T { 31 | if Thread.isMainThread { 32 | return item() 33 | } else { 34 | return DispatchQueue.main.sync { 35 | return item() 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/IndexSet+NSRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexSet+NSRange.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSRange { 11 | /// Convenience getter for safely creating a `Range` from an `NSRange` 12 | var intRange: Range { 13 | self.location.. Bool { 36 | return self.contains(integersIn: range.intRange) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSEdgeInsets+Equatable.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Wouter Hennen on 29/04/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSEdgeInsets: Equatable { 11 | public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { 12 | lhs.bottom == rhs.bottom && 13 | lhs.top == rhs.top && 14 | lhs.left == rhs.left && 15 | lhs.right == rhs.right 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSEdgeInsets+Helpers.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/15/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSEdgeInsets { 11 | var vertical: CGFloat { 12 | top + bottom 13 | } 14 | 15 | var horizontal: CGFloat { 16 | left + right 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFont+LineHeight.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Lukas Pistrol on 28.05.22. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | public extension NSFont { 12 | /// The default line height of the font. 13 | var lineHeight: Double { 14 | NSLayoutManager().defaultLineHeight(for: self) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSFont+RulerFont.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Elias Wahl on 17.03.23. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | extension NSFont { 12 | var rulerFont: NSFont { 13 | let fontSize: Double = (self.pointSize - 1) + 0.25 14 | let fontAdvance: Double = self.pointSize * 0.49 + 0.1 15 | let fontWeight = NSFont.Weight(rawValue: self.pointSize * 0.00001 + 0.0001) 16 | let fontWidth = NSFont.Width(rawValue: -0.13) 17 | 18 | let font = NSFont.systemFont(ofSize: fontSize, weight: fontWeight, width: fontWidth) 19 | 20 | /// Set the open four 21 | let alt4: [NSFontDescriptor.FeatureKey: Int] = [ 22 | .selectorIdentifier: kStylisticAltOneOnSelector, 23 | .typeIdentifier: kStylisticAlternativesType 24 | ] 25 | 26 | /// Set alternate styling for 6 and 9 27 | let alt6and9: [NSFontDescriptor.FeatureKey: Int] = [ 28 | .selectorIdentifier: kStylisticAltTwoOnSelector, 29 | .typeIdentifier: kStylisticAlternativesType 30 | ] 31 | 32 | /// Make all digits monospaced 33 | let monoSpaceDigits: [NSFontDescriptor.FeatureKey: Int] = [ 34 | .selectorIdentifier: 0, 35 | .typeIdentifier: kNumberSpacingType 36 | ] 37 | 38 | let features = [alt4, alt6and9, monoSpaceDigits] 39 | let descriptor = font.fontDescriptor.addingAttributes([.featureSettings: features, .fixedAdvance: fontAdvance]) 40 | return NSFont(descriptor: descriptor, size: 0) ?? font 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+InputEdit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRange+InputEdit.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/12/22. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import SwiftTreeSitter 11 | 12 | extension InputEdit { 13 | init?(range: NSRange, delta: Int, oldEndPoint: Point, textView: TextView) { 14 | let newEndLocation = NSMaxRange(range) + delta 15 | 16 | if newEndLocation < 0 { 17 | assertionFailure("Invalid range/delta") 18 | return nil 19 | } 20 | 21 | let newRange = NSRange(location: range.location, length: range.length + delta) 22 | let startPoint = textView.pointForLocation(newRange.location) ?? .zero 23 | let newEndPoint = textView.pointForLocation(newEndLocation) ?? .zero 24 | 25 | self.init( 26 | startByte: UInt32(range.location * 2), 27 | oldEndByte: UInt32(NSMaxRange(range) * 2), 28 | newEndByte: UInt32(newEndLocation * 2), 29 | startPoint: startPoint, 30 | oldEndPoint: oldEndPoint, 31 | newEndPoint: newEndPoint 32 | ) 33 | } 34 | } 35 | 36 | extension NSRange { 37 | // swiftlint:disable line_length 38 | /// Modifies the range to account for an edit. 39 | /// Largely based on code from 40 | /// [tree-sitter](https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720) 41 | mutating func applyInputEdit(_ edit: InputEdit) { 42 | // swiftlint:enable line_length 43 | let endIndex = NSMaxRange(self) 44 | let isPureInsertion = edit.oldEndByte == edit.startByte 45 | 46 | // Edit is after the range 47 | if (edit.startByte/2) > endIndex { 48 | return 49 | } else if edit.oldEndByte/2 < location { 50 | // If the edit is entirely before this range 51 | self.location += (Int(edit.newEndByte) - Int(edit.oldEndByte))/2 52 | } else if edit.startByte/2 < location { 53 | // If the edit starts in the space before this range and extends into this range 54 | length -= Int(edit.oldEndByte)/2 - location 55 | location = Int(edit.newEndByte)/2 56 | } else if edit.startByte/2 == location && isPureInsertion { 57 | // If the edit is *only* an insertion right at the beginning of the range 58 | location = Int(edit.newEndByte)/2 59 | } else { 60 | // Otherwise, the edit is entirely within this range 61 | if edit.startByte/2 < endIndex || (edit.startByte/2 == endIndex && isPureInsertion) { 62 | length = (Int(edit.newEndByte)/2 - location) + (length - (Int(edit.oldEndByte)/2 - location)) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+NSTextRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSRange+NSTextRange.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/13/22. 6 | // 7 | 8 | import AppKit 9 | 10 | public extension NSTextRange { 11 | convenience init?(_ range: NSRange, provider: NSTextElementProvider) { 12 | let docLocation = provider.documentRange.location 13 | 14 | guard let start = provider.location?(docLocation, offsetBy: range.location) else { 15 | return nil 16 | } 17 | 18 | guard let end = provider.location?(start, offsetBy: range.length) else { 19 | return nil 20 | } 21 | 22 | self.init(location: start, end: end) 23 | } 24 | 25 | /// Creates an `NSRange` using document information from the given provider. 26 | /// - Parameter provider: The `NSTextElementProvider` to use to convert this range into an `NSRange` 27 | /// - Returns: An `NSRange` if possible 28 | func nsRange(using provider: NSTextElementProvider) -> NSRange? { 29 | guard let location = provider.offset?(from: provider.documentRange.location, to: location) else { 30 | return nil 31 | } 32 | guard let length = provider.offset?(from: self.location, to: endLocation) else { 33 | return nil 34 | } 35 | return NSRange(location: location, length: length) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/NSRange+/NSRange+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+NSRange.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Lukas Pistrol on 25.05.22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | // make string subscriptable with NSRange 12 | subscript(value: NSRange) -> Substring? { 13 | let upperBound = String.Index(utf16Offset: Int(value.upperBound), in: self) 14 | let lowerBound = String.Index(utf16Offset: Int(value.lowerBound), in: self) 15 | if upperBound <= self.endIndex { 16 | return self[lowerBound.. Bool) -> Node? { 12 | for idx in 0..(_ callback: (Node) -> T) -> [T] { 23 | var retVal: [T] = [] 24 | for idx in 0.. Bool) -> [Node] { 32 | var retVal: [Node] = [] 33 | for idx in 0.. Success { 12 | switch self { 13 | case let .success(success): 14 | return success 15 | case let .failure(failure): 16 | throw failure 17 | } 18 | } 19 | 20 | var isSuccess: Bool { 21 | if case .success = self { 22 | return true 23 | } else { 24 | return false 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/String+/String+Groups.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewlineProcessingFilter+TagHandling.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Roscoe Rubin-Rottenberg on 5/19/24. 6 | // 7 | 8 | import Foundation 9 | import TextStory 10 | import TextFormation 11 | 12 | // Helper extension to extract capture groups 13 | extension String { 14 | func groups(for regexPattern: String) -> [String]? { 15 | guard let regex = try? NSRegularExpression(pattern: regexPattern) else { return nil } 16 | let nsString = self as NSString 17 | let results = regex.matches(in: self, range: NSRange(location: 0, length: nsString.length)) 18 | return results.first.map { result in 19 | (1.. NSMenu { 23 | menu.insertItem(withTitle: "Jump To Definition", 24 | action: nil, 25 | keyEquivalent: "", 26 | at: 0) 27 | 28 | menu.insertItem(withTitle: "Show Code Actions", 29 | action: nil, 30 | keyEquivalent: "", 31 | at: 1) 32 | 33 | menu.insertItem(withTitle: "Show Quick Help", 34 | action: nil, 35 | keyEquivalent: "", 36 | at: 2) 37 | 38 | menu.insertItem(.separator(), at: 3) 39 | 40 | return menu 41 | } 42 | 43 | func codeMenu(_ menu: NSMenu) -> NSMenu { 44 | 45 | menu.insertItem(withTitle: "Refactor", 46 | action: nil, 47 | keyEquivalent: "", 48 | at: 4) 49 | 50 | menu.insertItem(withTitle: "Find", 51 | action: nil, 52 | keyEquivalent: "", 53 | at: 5) 54 | 55 | menu.insertItem(withTitle: "Navigate", 56 | action: nil, 57 | keyEquivalent: "", 58 | at: 6) 59 | 60 | menu.insertItem(.separator(), at: 7) 61 | 62 | return menu 63 | } 64 | 65 | func gitMenu(_ menu: NSMenu) -> NSMenu { 66 | menu.insertItem(withTitle: "Show Last Change For Line", 67 | action: nil, 68 | keyEquivalent: "", 69 | at: 8) 70 | 71 | menu.insertItem(withTitle: "Create Code Snippet...", 72 | action: nil, 73 | keyEquivalent: "", 74 | at: 9) 75 | 76 | menu.insertItem(withTitle: "Add Pull Request Discussion to Current Line", 77 | action: nil, 78 | keyEquivalent: "", 79 | at: 10) 80 | 81 | menu.insertItem(.separator(), at: 11) 82 | 83 | return menu 84 | } 85 | 86 | /// This removes the default menu items in the context menu based on their name.. 87 | /// 88 | /// The only problem currently is how well it would work with other languages. 89 | func removeMenus(_ menu: NSMenu) -> NSMenu { 90 | let removeItemsContaining = [ 91 | // Learn Spelling 92 | "_learnSpellingFromMenu:", 93 | 94 | // Ignore Spelling 95 | "_ignoreSpellingFromMenu:", 96 | 97 | // Spelling suggestion 98 | "_changeSpellingFromMenu:", 99 | 100 | // Search with Google 101 | "_searchWithGoogleFromMenu:", 102 | 103 | // Share, Font, Spelling and Grammar, Substitutions, Transformations 104 | // Speech, Layout Orientation 105 | "submenuAction:", 106 | 107 | // Lookup, Translate 108 | "_rvMenuItemAction" 109 | ] 110 | 111 | for item in menu.items { 112 | if let itemAction = item.action { 113 | if removeItemsContaining.contains(String(describing: itemAction)) { 114 | // Get localized item name, and remove it. 115 | let index = menu.indexOfItem(withTitle: item.title) 116 | if index >= 0 { 117 | menu.removeItem(at: index) 118 | } 119 | } 120 | } 121 | } 122 | 123 | return menu 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+Point.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView+Point.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/18/24. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import SwiftTreeSitter 11 | 12 | extension TextView { 13 | func pointForLocation(_ location: Int) -> Point? { 14 | guard let linePosition = layoutManager.textLineForOffset(location) else { return nil } 15 | let column = location - linePosition.range.location 16 | return Point(row: linePosition.index, column: column) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView+TextFormation.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/14/23. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import TextStory 11 | import TextFormation 12 | 13 | extension TextView: TextInterface { 14 | public var selectedRange: NSRange { 15 | get { 16 | return selectionManager 17 | .textSelections 18 | .sorted(by: { $0.range.lowerBound < $1.range.lowerBound }) 19 | .first? 20 | .range ?? .zero 21 | } 22 | set { 23 | selectionManager.setSelectedRange(newValue) 24 | } 25 | } 26 | 27 | public var length: Int { 28 | textStorage.length 29 | } 30 | 31 | public func substring(from range: NSRange) -> String? { 32 | return textStorage.substring(from: range) 33 | } 34 | 35 | /// Applies the mutation to the text view. 36 | /// 37 | /// If the mutation is empty it will be ignored. 38 | /// 39 | /// - Parameter mutation: The mutation to apply. 40 | public func applyMutation(_ mutation: TextMutation) { 41 | guard !mutation.isEmpty else { return } 42 | _undoManager?.registerMutation(mutation) 43 | textStorage.replaceCharacters(in: mutation.range, with: mutation.string) 44 | selectionManager.didReplaceCharacters( 45 | in: mutation.range, 46 | replacementLength: (mutation.string as NSString).length 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView+createReadBlock.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/20/23. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import SwiftTreeSitter 11 | 12 | extension TextView { 13 | /// Creates a block for safely reading data into a parser's read block. 14 | /// 15 | /// If the thread is the main queue, executes synchronously. 16 | /// Otherwise it will block the calling thread and execute the block on the main queue, returning control to the 17 | /// calling queue when the block is finished running. 18 | /// 19 | /// - Returns: A new block for reading contents for tree-sitter. 20 | func createReadBlock() -> Parser.ReadBlock { 21 | return { [weak self] byteOffset, _ in 22 | let workItem: () -> Data? = { 23 | let limit = self?.documentRange.length ?? 0 24 | let location = byteOffset / 2 25 | let end = min(location + (TreeSitterClient.Constants.charsToReadInBlock), limit) 26 | if location > end || self == nil { 27 | // Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations. 28 | return nil 29 | } 30 | let range = NSRange(location.. SwiftTreeSitter.Predicate.TextProvider { 44 | return { [weak self] range, _ in 45 | let workItem: () -> String? = { 46 | self?.textStorage.substring(from: range) 47 | } 48 | return DispatchQueue.syncMainIfNot(workItem) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/Tree+prettyPrint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tree+prettyPrint.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/16/23. 6 | // 7 | 8 | import SwiftTreeSitter 9 | 10 | #if DEBUG 11 | extension Tree { 12 | func prettyPrint() { 13 | guard let cursor = self.rootNode?.treeCursor else { 14 | print("NO ROOT NODE") 15 | return 16 | } 17 | guard cursor.currentNode != nil else { 18 | print("NO CURRENT NODE") 19 | return 20 | } 21 | 22 | func p(_ cursor: TreeCursor, depth: Int) { 23 | guard let node = cursor.currentNode else { 24 | return 25 | } 26 | 27 | let visible = node.isNamed 28 | 29 | if visible { 30 | print(String(repeating: " ", count: depth * 2), terminator: "") 31 | if let fieldName = cursor.currentFieldName { 32 | print(fieldName, ": ", separator: "", terminator: "") 33 | } 34 | print("(", node.nodeType ?? "NONE", " ", node.range, " ", separator: "", terminator: "") 35 | } 36 | 37 | if cursor.goToFirstChild() { 38 | while true { 39 | if cursor.currentNode != nil && cursor.currentNode!.isNamed { 40 | print("") 41 | } 42 | 43 | p(cursor, depth: depth + 1) 44 | 45 | if !cursor.gotoNextSibling() { 46 | break 47 | } 48 | } 49 | 50 | if !cursor.gotoParent() { 51 | fatalError("Could not go to parent, this tree may be invalid.") 52 | } 53 | } 54 | 55 | if visible { 56 | print(")", terminator: depth == 1 ? "\n": "") 57 | } 58 | } 59 | 60 | if cursor.currentNode?.childCount == 0 { 61 | if !cursor.currentNode!.isNamed { 62 | print("{\(cursor.currentNode!.nodeType ?? "NONE")}") 63 | } else { 64 | print("\"\(cursor.currentNode!.nodeType ?? "NONE")\"") 65 | } 66 | } else { 67 | p(cursor, depth: 1) 68 | } 69 | } 70 | } 71 | 72 | extension MutableTree { 73 | func prettyPrint() { 74 | guard let cursor = self.rootNode?.treeCursor else { 75 | print("NO ROOT NODE") 76 | return 77 | } 78 | guard cursor.currentNode != nil else { 79 | print("NO CURRENT NODE") 80 | return 81 | } 82 | 83 | func p(_ cursor: TreeCursor, depth: Int) { 84 | guard let node = cursor.currentNode else { 85 | return 86 | } 87 | 88 | let visible = node.isNamed 89 | 90 | if visible { 91 | print(String(repeating: " ", count: depth * 2), terminator: "") 92 | if let fieldName = cursor.currentFieldName { 93 | print(fieldName, ": ", separator: "", terminator: "") 94 | } 95 | print("(", node.nodeType ?? "NONE", " ", node.range, " ", separator: "", terminator: "") 96 | } 97 | 98 | if cursor.goToFirstChild() { 99 | while true { 100 | if cursor.currentNode != nil && cursor.currentNode!.isNamed { 101 | print("") 102 | } 103 | 104 | p(cursor, depth: depth + 1) 105 | 106 | if !cursor.gotoNextSibling() { 107 | break 108 | } 109 | } 110 | 111 | if !cursor.gotoParent() { 112 | fatalError("Could not go to parent, this tree may be invalid.") 113 | } 114 | } 115 | 116 | if visible { 117 | print(")", terminator: depth == 1 ? "\n": "") 118 | } 119 | } 120 | 121 | if cursor.currentNode?.childCount == 0 { 122 | if !cursor.currentNode!.isNamed { 123 | print("{\(cursor.currentNode!.nodeType ?? "NONE")}") 124 | } else { 125 | print("\"\(cursor.currentNode!.nodeType ?? "NONE")\"") 126 | } 127 | } else { 128 | p(cursor, depth: 1) 129 | } 130 | } 131 | } 132 | #endif 133 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Extensions/TreeSitterLanguage+TagFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TreeSitterLanguage+TagFilter.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/25/24. 6 | // 7 | 8 | import CodeEditLanguages 9 | 10 | extension TreeSitterLanguage { 11 | fileprivate static let relevantLanguages: Set = [ 12 | CodeLanguage.html.id.rawValue, 13 | CodeLanguage.javascript.id.rawValue, 14 | CodeLanguage.typescript.id.rawValue, 15 | CodeLanguage.jsx.id.rawValue, 16 | CodeLanguage.tsx.id.rawValue 17 | ] 18 | 19 | func shouldProcessTags() -> Bool { 20 | return Self.relevantLanguages.contains(self.rawValue) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Filters/DeleteWhitespaceFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeleteWhitespaceFilter.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/28/23. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import TextFormation 11 | import TextStory 12 | 13 | /// Filter for quickly deleting indent whitespace 14 | /// 15 | /// Will only delete whitespace when it's on the leading side of the line. Will delete back to the nearest tab column. 16 | /// Eg: 17 | /// ```text 18 | /// (| = column delimiter, _ = space, * = cursor) 19 | /// 20 | /// ____|___* <- delete 21 | /// ----* <- final 22 | /// ``` 23 | /// Will also move the cursor to the trailing side of the whitespace if it is not there already: 24 | /// ```text 25 | /// ____|_*___|__ <- delete 26 | /// ____|____* <- final 27 | /// ``` 28 | struct DeleteWhitespaceFilter: Filter { 29 | let indentOption: IndentOption 30 | 31 | func processMutation( 32 | _ mutation: TextMutation, 33 | in interface: TextInterface, 34 | with providers: WhitespaceProviders 35 | ) -> FilterAction { 36 | guard mutation.delta < 0 37 | && mutation.string == "" 38 | && mutation.range.length == 1 39 | && indentOption != .tab else { 40 | return .none 41 | } 42 | 43 | let lineRange = interface.lineRange(containing: mutation.range.location) 44 | guard let leadingWhitespace = interface.leadingRange(in: lineRange, within: .whitespacesWithoutNewlines), 45 | leadingWhitespace.contains(mutation.range.location) else { 46 | return .none 47 | } 48 | 49 | // Move to right of the whitespace and delete to the left-most tab column 50 | let indentLength = indentOption.stringValue.count 51 | var numberOfExtraSpaces = leadingWhitespace.length % indentLength 52 | if numberOfExtraSpaces == 0 { 53 | numberOfExtraSpaces = indentLength 54 | } 55 | 56 | interface.applyMutation( 57 | TextMutation( 58 | delete: NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: numberOfExtraSpaces), 59 | limit: mutation.limit 60 | ) 61 | ) 62 | 63 | return .discard 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Filters/TabReplacementFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabReplacementFilter.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/28/23. 6 | // 7 | 8 | import Foundation 9 | import TextFormation 10 | import TextStory 11 | 12 | /// Filter for replacing tab characters with the user-defined indentation unit. 13 | /// - Note: The undentation unit can be another tab character, this is merely a point at which this can be configured. 14 | struct TabReplacementFilter: Filter { 15 | let indentOption: IndentOption 16 | 17 | func processMutation( 18 | _ mutation: TextMutation, 19 | in interface: TextInterface, 20 | with providers: WhitespaceProviders 21 | ) -> FilterAction { 22 | if mutation.string == "\t" && indentOption != .tab && mutation.delta > 0 { 23 | interface.applyMutation( 24 | TextMutation( 25 | insert: indentOption.stringValue, 26 | at: mutation.range.location, 27 | limit: mutation.limit 28 | ) 29 | ) 30 | return .discard 31 | } else { 32 | return .none 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/FindMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindMethod.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 5/2/25. 6 | // 7 | 8 | enum FindMethod: CaseIterable { 9 | case contains 10 | case matchesWord 11 | case startsWith 12 | case endsWith 13 | case regularExpression 14 | 15 | var displayName: String { 16 | switch self { 17 | case .contains: 18 | return "Contains" 19 | case .matchesWord: 20 | return "Matches Word" 21 | case .startsWith: 22 | return "Starts With" 23 | case .endsWith: 24 | return "Ends With" 25 | case .regularExpression: 26 | return "Regular Expression" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/FindPanelMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelMode.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | enum FindPanelMode: CaseIterable { 9 | case find 10 | case replace 11 | 12 | var displayName: String { 13 | switch self { 14 | case .find: 15 | return "Find" 16 | case .replace: 17 | return "Replace" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelTarget.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/10/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | protocol FindPanelTarget: AnyObject { 12 | var textView: TextView! { get } 13 | var findPanelTargetView: NSView { get } 14 | 15 | var cursorPositions: [CursorPosition] { get } 16 | func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) 17 | func updateCursorPosition() 18 | 19 | func findPanelWillShow(panelHeight: CGFloat) 20 | func findPanelWillHide(panelHeight: CGFloat) 21 | func findPanelModeDidChange(to mode: FindPanelMode) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/FindViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindViewController.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/10/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | /// Creates a container controller for displaying and hiding a find panel with a content view. 12 | final class FindViewController: NSViewController { 13 | var viewModel: FindPanelViewModel 14 | 15 | /// The amount of padding from the top of the view to inset the find panel by. 16 | /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. 17 | var topPadding: CGFloat? { 18 | didSet { 19 | if viewModel.isShowingFindPanel { 20 | setFindPanelConstraintShow() 21 | } 22 | } 23 | } 24 | 25 | var childView: NSView 26 | var findPanel: FindPanelHostingView 27 | var findPanelVerticalConstraint: NSLayoutConstraint! 28 | 29 | /// The 'real' top padding amount. 30 | /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. 31 | var resolvedTopPadding: CGFloat { 32 | (topPadding ?? view.safeAreaInsets.top) 33 | } 34 | 35 | init(target: FindPanelTarget, childView: NSView) { 36 | viewModel = FindPanelViewModel(target: target) 37 | self.childView = childView 38 | findPanel = FindPanelHostingView(viewModel: viewModel) 39 | super.init(nibName: nil, bundle: nil) 40 | viewModel.dismiss = { [weak self] in 41 | self?.hideFindPanel() 42 | } 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | override func loadView() { 50 | super.loadView() 51 | 52 | // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to 53 | // the find panel's bottom 54 | // The find panel is constrained to the top of the view. 55 | // The find panel's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. 56 | // When visible, it's set to 0. 57 | 58 | view.clipsToBounds = false 59 | view.addSubview(findPanel) 60 | view.addSubview(childView) 61 | 62 | // Ensure find panel is always on top 63 | findPanel.wantsLayer = true 64 | findPanel.layer?.zPosition = 1000 65 | 66 | findPanelVerticalConstraint = findPanel.topAnchor.constraint(equalTo: view.topAnchor) 67 | 68 | NSLayoutConstraint.activate([ 69 | // Constrain find panel 70 | findPanelVerticalConstraint, 71 | findPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor), 72 | findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), 73 | 74 | // Constrain child view 75 | childView.topAnchor.constraint(equalTo: view.topAnchor), 76 | childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), 77 | childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 78 | childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) 79 | ]) 80 | } 81 | 82 | override func viewWillAppear() { 83 | super.viewWillAppear() 84 | if viewModel.isShowingFindPanel { // Update constraints for initial state 85 | findPanel.isHidden = false 86 | setFindPanelConstraintShow() 87 | } else { 88 | findPanel.isHidden = true 89 | setFindPanelConstraintHide() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindControls.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 4/30/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A SwiftUI view that provides the navigation controls for the find panel. 11 | /// 12 | /// The `FindControls` view is responsible for: 13 | /// - Displaying previous/next match navigation buttons 14 | /// - Showing a done button to dismiss the find panel 15 | /// - Adapting button appearance based on match count 16 | /// - Supporting both condensed and full layouts 17 | /// - Providing tooltips for button actions 18 | /// 19 | /// The view is part of the find panel's control section and works in conjunction with 20 | /// the find text field to provide navigation through search results. 21 | struct FindControls: View { 22 | @ObservedObject var viewModel: FindPanelViewModel 23 | var condensed: Bool 24 | 25 | var imageOpacity: CGFloat { 26 | viewModel.matchesEmpty ? 0.33 : 1 27 | } 28 | 29 | var dynamicPadding: CGFloat { 30 | condensed ? 0 : 5 31 | } 32 | 33 | var body: some View { 34 | HStack(spacing: 4) { 35 | ControlGroup { 36 | Button { 37 | viewModel.moveToPreviousMatch() 38 | } label: { 39 | Image(systemName: "chevron.left") 40 | .opacity(imageOpacity) 41 | .padding(.horizontal, dynamicPadding) 42 | } 43 | .help("Previous Match") 44 | .disabled(viewModel.matchesEmpty) 45 | 46 | Divider() 47 | .overlay(Color(nsColor: .tertiaryLabelColor)) 48 | Button { 49 | viewModel.moveToNextMatch() 50 | } label: { 51 | Image(systemName: "chevron.right") 52 | .opacity(imageOpacity) 53 | .padding(.horizontal, dynamicPadding) 54 | } 55 | .help("Next Match") 56 | .disabled(viewModel.matchesEmpty) 57 | } 58 | .controlGroupStyle(PanelControlGroupStyle()) 59 | .fixedSize() 60 | 61 | Button { 62 | viewModel.dismiss?() 63 | } label: { 64 | Group { 65 | if condensed { 66 | Image(systemName: "xmark") 67 | } else { 68 | Text("Done") 69 | } 70 | } 71 | .help(condensed ? "Done" : "") 72 | .padding(.horizontal, dynamicPadding) 73 | } 74 | .buttonStyle(PanelButtonStyle()) 75 | } 76 | } 77 | } 78 | 79 | #Preview("Find Controls - Full") { 80 | FindControls( 81 | viewModel: { 82 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 83 | vm.findText = "example" 84 | vm.findMatches = [NSRange(location: 0, length: 7)] 85 | vm.currentFindMatchIndex = 0 86 | return vm 87 | }(), 88 | condensed: false 89 | ) 90 | .padding() 91 | } 92 | 93 | #Preview("Find Controls - Condensed") { 94 | FindControls( 95 | viewModel: { 96 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 97 | vm.findText = "example" 98 | vm.findMatches = [NSRange(location: 0, length: 7)] 99 | vm.currentFindMatchIndex = 0 100 | return vm 101 | }(), 102 | condensed: true 103 | ) 104 | .padding() 105 | } 106 | 107 | #Preview("Find Controls - No Matches") { 108 | FindControls( 109 | viewModel: { 110 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 111 | vm.findText = "example" 112 | return vm 113 | }(), 114 | condensed: false 115 | ) 116 | .padding() 117 | } 118 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelContent.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 5/2/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A SwiftUI view that provides the main content layout for the find and replace panel. 11 | /// 12 | /// The `FindPanelContent` view is responsible for: 13 | /// - Arranging the find and replace text fields in a vertical stack 14 | /// - Arranging the control buttons in a vertical stack 15 | /// - Handling the layout differences between find and replace modes 16 | /// - Supporting both full and condensed layouts 17 | /// 18 | /// The view is designed to be used within `FindPanelView` and adapts its layout based on the 19 | /// available space and current mode (find or replace). 20 | struct FindPanelContent: View { 21 | @ObservedObject var viewModel: FindPanelViewModel 22 | @FocusState.Binding var focus: FindPanelView.FindPanelFocus? 23 | var findModePickerWidth: Binding 24 | var condensed: Bool 25 | 26 | var body: some View { 27 | HStack(spacing: 5) { 28 | VStack(alignment: .leading, spacing: 4) { 29 | FindSearchField( 30 | viewModel: viewModel, 31 | focus: $focus, 32 | findModePickerWidth: findModePickerWidth, 33 | condensed: condensed 34 | ) 35 | if viewModel.mode == .replace { 36 | ReplaceSearchField( 37 | viewModel: viewModel, 38 | focus: $focus, 39 | findModePickerWidth: findModePickerWidth, 40 | condensed: condensed 41 | ) 42 | } 43 | } 44 | VStack(alignment: .leading, spacing: 4) { 45 | FindControls(viewModel: viewModel, condensed: condensed) 46 | if viewModel.mode == .replace { 47 | Spacer(minLength: 0) 48 | ReplaceControls(viewModel: viewModel, condensed: condensed) 49 | } 50 | } 51 | .fixedSize() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelHostingView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 3/10/25. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | import Combine 11 | 12 | /// A subclass of `NSHostingView` that hosts the SwiftUI `FindPanelView` in an 13 | /// AppKit context. 14 | /// 15 | /// The `FindPanelHostingView` class is responsible for: 16 | /// - Bridging between SwiftUI and AppKit by hosting the FindPanelView 17 | /// - Managing keyboard event monitoring for the escape key 18 | /// - Handling the dismissal of the find panel 19 | /// - Providing proper view lifecycle management 20 | /// - Ensuring proper cleanup of event monitors 21 | /// 22 | /// This class is essential for integrating the SwiftUI-based find panel into the AppKit-based 23 | /// text editor. 24 | final class FindPanelHostingView: NSHostingView { 25 | private weak var viewModel: FindPanelViewModel? 26 | 27 | private var eventMonitor: Any? 28 | 29 | init(viewModel: FindPanelViewModel) { 30 | self.viewModel = viewModel 31 | super.init(rootView: FindPanelView(viewModel: viewModel)) 32 | 33 | self.translatesAutoresizingMaskIntoConstraints = false 34 | 35 | self.wantsLayer = true 36 | self.layer?.backgroundColor = .clear 37 | 38 | self.translatesAutoresizingMaskIntoConstraints = false 39 | } 40 | 41 | @MainActor @preconcurrency required init(rootView: FindPanelView) { 42 | super.init(rootView: rootView) 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | deinit { 50 | removeEventMonitor() 51 | } 52 | 53 | // MARK: - Event Monitor Management 54 | 55 | func addEventMonitor() { 56 | eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in 57 | if event.keyCode == 53 { // if esc pressed 58 | self.viewModel?.dismiss?() 59 | return nil // do not play "beep" sound 60 | } 61 | return event 62 | } 63 | } 64 | 65 | func removeEventMonitor() { 66 | if let monitor = eventMonitor { 67 | NSEvent.removeMonitor(monitor) 68 | eventMonitor = nil 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReplaceControls.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 4/30/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A SwiftUI view that provides the replace controls for the find panel. 11 | /// 12 | /// The `ReplaceControls` view is responsible for: 13 | /// - Displaying replace and replace all buttons 14 | /// - Managing button states based on find text and match count 15 | /// - Adapting button appearance between condensed and full layouts 16 | /// - Providing tooltips for button actions 17 | /// - Handling replace operations through the view model 18 | /// 19 | /// The view is only shown when the find panel is in replace mode and works in conjunction 20 | /// with the replace text field to perform text replacements. 21 | struct ReplaceControls: View { 22 | @ObservedObject var viewModel: FindPanelViewModel 23 | var condensed: Bool 24 | 25 | var shouldDisableSingle: Bool { 26 | !viewModel.isFocused || viewModel.findText.isEmpty || viewModel.matchesEmpty 27 | } 28 | 29 | var shouldDisableAll: Bool { 30 | viewModel.findText.isEmpty || viewModel.matchesEmpty 31 | } 32 | 33 | var body: some View { 34 | HStack(spacing: 4) { 35 | ControlGroup { 36 | Button { 37 | viewModel.replace() 38 | } label: { 39 | Group { 40 | if condensed { 41 | Image(systemName: "arrow.turn.up.right") 42 | } else { 43 | Text("Replace") 44 | } 45 | } 46 | .opacity(shouldDisableSingle ? 0.33 : 1) 47 | } 48 | .help(condensed ? "Replace" : "") 49 | .disabled(shouldDisableSingle) 50 | .frame(maxWidth: .infinity) 51 | 52 | Divider().overlay(Color(nsColor: .tertiaryLabelColor)) 53 | 54 | Button { 55 | viewModel.replaceAll() 56 | } label: { 57 | Group { 58 | if condensed { 59 | Image(systemName: "text.insert") 60 | } else { 61 | Text("All") 62 | } 63 | } 64 | .opacity(shouldDisableAll ? 0.33 : 1) 65 | } 66 | .help(condensed ? "Replace All" : "") 67 | .disabled(shouldDisableAll) 68 | .frame(maxWidth: .infinity) 69 | } 70 | .controlGroupStyle(PanelControlGroupStyle()) 71 | } 72 | .fixedSize(horizontal: false, vertical: true) 73 | } 74 | } 75 | 76 | #Preview("Replace Controls - Full") { 77 | ReplaceControls( 78 | viewModel: { 79 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 80 | vm.findText = "example" 81 | vm.replaceText = "replacement" 82 | vm.findMatches = [NSRange(location: 0, length: 7)] 83 | vm.currentFindMatchIndex = 0 84 | return vm 85 | }(), 86 | condensed: false 87 | ) 88 | .padding() 89 | } 90 | 91 | #Preview("Replace Controls - Condensed") { 92 | ReplaceControls( 93 | viewModel: { 94 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 95 | vm.findText = "example" 96 | vm.replaceText = "replacement" 97 | vm.findMatches = [NSRange(location: 0, length: 7)] 98 | vm.currentFindMatchIndex = 0 99 | return vm 100 | }(), 101 | condensed: true 102 | ) 103 | .padding() 104 | } 105 | 106 | #Preview("Replace Controls - No Matches") { 107 | ReplaceControls( 108 | viewModel: { 109 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 110 | vm.findText = "example" 111 | vm.replaceText = "replacement" 112 | return vm 113 | }(), 114 | condensed: false 115 | ) 116 | .padding() 117 | } 118 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReplaceSearchField.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A SwiftUI view that provides the replace text field for the find panel. 11 | /// 12 | /// The `ReplaceSearchField` view is responsible for: 13 | /// - Displaying and managing the replace text input field 14 | /// - Showing a visual indicator (pencil icon) for the replace field 15 | /// - Adapting its layout between condensed and full modes 16 | /// - Maintaining focus state for keyboard navigation 17 | /// 18 | /// The view is only shown when the find panel is in replace mode and adapts its layout 19 | /// based on the `condensed` parameter to match the find field's appearance. 20 | struct ReplaceSearchField: View { 21 | @ObservedObject var viewModel: FindPanelViewModel 22 | @FocusState.Binding var focus: FindPanelView.FindPanelFocus? 23 | @Binding var findModePickerWidth: CGFloat 24 | var condensed: Bool 25 | 26 | var body: some View { 27 | PanelTextField( 28 | "Text", 29 | text: $viewModel.replaceText, 30 | leadingAccessories: { 31 | if condensed { 32 | Image(systemName: "pencil") 33 | .foregroundStyle(.secondary) 34 | .padding(.leading, 8) 35 | } else { 36 | HStack(spacing: 0) { 37 | HStack(spacing: 0) { 38 | Image(systemName: "pencil") 39 | .foregroundStyle(.secondary) 40 | .padding(.leading, 8) 41 | .padding(.trailing, 5) 42 | Text("With") 43 | } 44 | .frame(width: findModePickerWidth, alignment: .leading) 45 | Divider() 46 | } 47 | } 48 | }, 49 | clearable: true 50 | ) 51 | .controlSize(.small) 52 | .fixedSize(horizontal: false, vertical: true) 53 | .focused($focus, equals: .replace) 54 | } 55 | } 56 | 57 | #Preview("Replace Search Field - Full") { 58 | @FocusState var focus: FindPanelView.FindPanelFocus? 59 | ReplaceSearchField( 60 | viewModel: { 61 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 62 | vm.replaceText = "replacement" 63 | return vm 64 | }(), 65 | focus: $focus, 66 | findModePickerWidth: .constant(100), 67 | condensed: false 68 | ) 69 | .frame(width: 300) 70 | .padding() 71 | } 72 | 73 | #Preview("Replace Search Field - Condensed") { 74 | @FocusState var focus: FindPanelView.FindPanelFocus? 75 | ReplaceSearchField( 76 | viewModel: { 77 | let vm = FindPanelViewModel(target: MockFindPanelTarget()) 78 | vm.replaceText = "replacement" 79 | return vm 80 | }(), 81 | focus: $focus, 82 | findModePickerWidth: .constant(100), 83 | condensed: true 84 | ) 85 | .frame(width: 200) 86 | .padding() 87 | } 88 | 89 | #Preview("Replace Search Field - Empty") { 90 | @FocusState var focus: FindPanelView.FindPanelFocus? 91 | ReplaceSearchField( 92 | viewModel: FindPanelViewModel(target: MockFindPanelTarget()), 93 | focus: $focus, 94 | findModePickerWidth: .constant(100), 95 | condensed: false 96 | ) 97 | .frame(width: 300) 98 | .padding() 99 | } 100 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelViewModel+Emphasis.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | import CodeEditTextView 9 | 10 | extension FindPanelViewModel { 11 | func addMatchEmphases(flashCurrent: Bool) { 12 | guard let target = target, let emphasisManager = target.textView.emphasisManager else { 13 | return 14 | } 15 | 16 | // Clear existing emphases 17 | emphasisManager.removeEmphases(for: EmphasisGroup.find) 18 | 19 | // Create emphasis with the nearest match as active 20 | let emphases = findMatches.enumerated().map { index, range in 21 | Emphasis( 22 | range: range, 23 | style: .standard, 24 | flash: flashCurrent && index == currentFindMatchIndex, 25 | inactive: index != currentFindMatchIndex, 26 | selectInDocument: index == currentFindMatchIndex 27 | ) 28 | } 29 | 30 | // Add all emphases 31 | emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) 32 | } 33 | 34 | func flashCurrentMatch() { 35 | guard let target = target, 36 | let emphasisManager = target.textView.emphasisManager, 37 | let currentFindMatchIndex else { 38 | return 39 | } 40 | 41 | let currentMatch = findMatches[currentFindMatchIndex] 42 | 43 | // Clear existing emphases 44 | emphasisManager.removeEmphases(for: EmphasisGroup.find) 45 | 46 | // Create emphasis with the nearest match as active 47 | let emphasis = ( 48 | Emphasis( 49 | range: currentMatch, 50 | style: .standard, 51 | flash: true, 52 | inactive: false, 53 | selectInDocument: true 54 | ) 55 | ) 56 | 57 | // Add the emphasis 58 | emphasisManager.addEmphases([emphasis], for: EmphasisGroup.find) 59 | } 60 | 61 | func clearMatchEmphases() { 62 | target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelViewModel+Find.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FindPanelViewModel { 11 | // MARK: - Find 12 | 13 | /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis 14 | /// manager's emphases. 15 | func find() { 16 | // Don't find if target isn't ready or the query is empty 17 | guard let target = target, !findText.isEmpty else { 18 | self.findMatches = [] 19 | return 20 | } 21 | 22 | // Set case sensitivity based on matchCase property 23 | var findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] 24 | 25 | // Add multiline options for regular expressions 26 | if findMethod == .regularExpression { 27 | findOptions.insert(.dotMatchesLineSeparators) 28 | findOptions.insert(.anchorsMatchLines) 29 | } 30 | 31 | let pattern: String 32 | 33 | switch findMethod { 34 | case .contains: 35 | // Simple substring match, escape special characters 36 | pattern = NSRegularExpression.escapedPattern(for: findText) 37 | 38 | case .matchesWord: 39 | // Match whole words only using word boundaries 40 | pattern = "\\b" + NSRegularExpression.escapedPattern(for: findText) + "\\b" 41 | 42 | case .startsWith: 43 | // Match at the start of a line or after a word boundary 44 | pattern = "(?:^|\\b)" + NSRegularExpression.escapedPattern(for: findText) 45 | 46 | case .endsWith: 47 | // Match at the end of a line or before a word boundary 48 | pattern = NSRegularExpression.escapedPattern(for: findText) + "(?:$|\\b)" 49 | 50 | case .regularExpression: 51 | // Use the pattern directly without additional escaping 52 | pattern = findText 53 | } 54 | 55 | guard let regex = try? NSRegularExpression(pattern: pattern, options: findOptions) else { 56 | self.findMatches = [] 57 | self.currentFindMatchIndex = nil 58 | return 59 | } 60 | 61 | let text = target.textView.string 62 | let range = target.textView.documentRange 63 | let matches = regex.matches(in: text, range: range).filter { !$0.range.isEmpty } 64 | 65 | self.findMatches = matches.map(\.range) 66 | 67 | // Find the nearest match to the current cursor position 68 | currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) 69 | 70 | // Only add emphasis layers if the find panel is focused 71 | if isFocused { 72 | addMatchEmphases(flashCurrent: false) 73 | } 74 | } 75 | 76 | // MARK: - Get Nearest Emphasis Index 77 | 78 | private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { 79 | // order the array as follows 80 | // Found: 1 -> 2 -> 3 -> 4 81 | // Cursor: | 82 | // Result: 3 -> 4 -> 1 -> 2 83 | guard let cursorPosition = target?.cursorPositions.first else { return nil } 84 | let start = cursorPosition.range.location 85 | 86 | var left = 0 87 | var right = matchRanges.count - 1 88 | var bestIndex = -1 89 | var bestDiff = Int.max // Stores the closest difference 90 | 91 | while left <= right { 92 | let mid = left + (right - left) / 2 93 | let midStart = matchRanges[mid].location 94 | let diff = abs(midStart - start) 95 | 96 | // If it's an exact match, return immediately 97 | if diff == 0 { 98 | return mid 99 | } 100 | 101 | // If this is the closest so far, update the best index 102 | if diff < bestDiff { 103 | bestDiff = diff 104 | bestIndex = mid 105 | } 106 | 107 | // Move left or right based on the cursor position 108 | if midStart < start { 109 | left = mid + 1 110 | } else { 111 | right = mid - 1 112 | } 113 | } 114 | 115 | return bestIndex >= 0 ? bestIndex : nil 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelViewModel+Move.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension FindPanelViewModel { 11 | func moveToNextMatch() { 12 | moveMatch(forwards: true) 13 | } 14 | 15 | func moveToPreviousMatch() { 16 | moveMatch(forwards: false) 17 | } 18 | 19 | private func moveMatch(forwards: Bool) { 20 | guard let target = target else { return } 21 | 22 | guard !findMatches.isEmpty else { 23 | showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) 24 | return 25 | } 26 | 27 | // From here on out we want to emphasize the result no matter what 28 | defer { 29 | if isTargetFirstResponder { 30 | flashCurrentMatch() 31 | } else { 32 | addMatchEmphases(flashCurrent: isTargetFirstResponder) 33 | } 34 | } 35 | 36 | guard let currentFindMatchIndex else { 37 | self.currentFindMatchIndex = 0 38 | return 39 | } 40 | 41 | let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 42 | guard !isAtLimit || wrapAround else { 43 | showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) 44 | return 45 | } 46 | 47 | self.currentFindMatchIndex = if forwards { 48 | (currentFindMatchIndex + 1) % findMatches.count 49 | } else { 50 | (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count 51 | } 52 | if isAtLimit { 53 | showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) 54 | } 55 | } 56 | 57 | private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { 58 | if error { 59 | NSSound.beep() 60 | } 61 | BezelNotification.show( 62 | symbolName: error ? 63 | forwards ? "arrow.down.to.line" : "arrow.up.to.line" 64 | : forwards 65 | ? "arrow.trianglehead.topright.capsulepath.clockwise" 66 | : "arrow.trianglehead.bottomleft.capsulepath.clockwise", 67 | over: targetView 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelViewModel+Replace.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/18/25. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | 11 | extension FindPanelViewModel { 12 | /// Replace one or all ``findMatches`` with the contents of ``replaceText``. 13 | /// - Parameter all: If true, replaces all matches instead of just the selected one. 14 | func replace() { 15 | guard let target = target, 16 | let currentFindMatchIndex, 17 | !findMatches.isEmpty else { 18 | return 19 | } 20 | 21 | replaceMatch(index: currentFindMatchIndex, textView: target.textView, matches: &findMatches) 22 | 23 | self.findMatches = findMatches.enumerated().filter({ $0.offset != currentFindMatchIndex }).map(\.element) 24 | 25 | // Update currentFindMatchIndex based on wrapAround setting 26 | if findMatches.isEmpty { 27 | self.currentFindMatchIndex = nil 28 | } else if wrapAround { 29 | self.currentFindMatchIndex = currentFindMatchIndex % findMatches.count 30 | } else { 31 | // If we're at the end and not wrapping, stay at the end 32 | self.currentFindMatchIndex = min(currentFindMatchIndex, findMatches.count - 1) 33 | } 34 | 35 | // Update the emphases 36 | addMatchEmphases(flashCurrent: true) 37 | } 38 | 39 | func replaceAll() { 40 | guard let target = target, 41 | !findMatches.isEmpty else { 42 | return 43 | } 44 | 45 | target.textView.undoManager?.beginUndoGrouping() 46 | target.textView.textStorage.beginEditing() 47 | 48 | var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) 49 | for (idx, _) in sortedMatches.enumerated().reversed() { 50 | replaceMatch(index: idx, textView: target.textView, matches: &sortedMatches) 51 | } 52 | 53 | target.textView.textStorage.endEditing() 54 | target.textView.undoManager?.endUndoGrouping() 55 | 56 | if let lastMatch = sortedMatches.last { 57 | target.setCursorPositions( 58 | [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], 59 | scrollToVisible: true 60 | ) 61 | } 62 | 63 | self.findMatches = [] 64 | self.currentFindMatchIndex = nil 65 | 66 | // Update the emphases 67 | addMatchEmphases(flashCurrent: true) 68 | } 69 | 70 | /// Replace a single match in the text view, updating all other find matches with any length changes. 71 | /// - Parameters: 72 | /// - index: The index of the match to replace in the `matches` array. 73 | /// - textView: The text view to replace characters in. 74 | /// - matches: The array of matches to use and update. 75 | private func replaceMatch(index: Int, textView: TextView, matches: inout [NSRange]) { 76 | let range = matches[index] 77 | // Set cursor positions to the match range 78 | textView.replaceCharacters(in: range, with: replaceText) 79 | 80 | // Adjust the length of the replacement 81 | let lengthDiff = replaceText.utf16.count - range.length 82 | 83 | // Update all match ranges after the current match 84 | for idx in matches.dropFirst(index + 1).indices { 85 | matches[idx].location -= lengthDiff 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FindPanelViewModel.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 3/12/25. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import CodeEditTextView 11 | 12 | class FindPanelViewModel: ObservableObject { 13 | weak var target: FindPanelTarget? 14 | var dismiss: (() -> Void)? 15 | 16 | @Published var findMatches: [NSRange] = [] 17 | @Published var currentFindMatchIndex: Int? 18 | @Published var isShowingFindPanel: Bool = false 19 | 20 | @Published var findText: String = "" 21 | @Published var replaceText: String = "" 22 | @Published var mode: FindPanelMode = .find { 23 | didSet { 24 | self.target?.findPanelModeDidChange(to: mode) 25 | } 26 | } 27 | @Published var findMethod: FindMethod = .contains { 28 | didSet { 29 | if !findText.isEmpty { 30 | find() 31 | } 32 | } 33 | } 34 | 35 | @Published var isFocused: Bool = false 36 | 37 | @Published var matchCase: Bool = false 38 | @Published var wrapAround: Bool = true 39 | 40 | /// The height of the find panel. 41 | var panelHeight: CGFloat { 42 | return mode == .replace ? 54 : 28 43 | } 44 | 45 | /// The number of current find matches. 46 | var matchCount: Int { 47 | findMatches.count 48 | } 49 | 50 | var matchesEmpty: Bool { 51 | matchCount == 0 52 | } 53 | 54 | var isTargetFirstResponder: Bool { 55 | target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView 56 | } 57 | 58 | init(target: FindPanelTarget) { 59 | self.target = target 60 | 61 | // Add notification observer for text changes 62 | if let textViewController = target as? TextViewController { 63 | NotificationCenter.default.addObserver( 64 | self, 65 | selector: #selector(textDidChange), 66 | name: TextView.textDidChangeNotification, 67 | object: textViewController.textView 68 | ) 69 | } 70 | } 71 | 72 | // MARK: - Text Listeners 73 | 74 | /// Find target's text content changed, we need to re-search the contents and emphasize results. 75 | @objc private func textDidChange() { 76 | // Only update if we have find text 77 | if !findText.isEmpty { 78 | find() 79 | } 80 | } 81 | 82 | /// The contents of the find search field changed, trigger related events. 83 | func findTextDidChange() { 84 | // Check if this update was triggered by a return key without shift 85 | if let currentEvent = NSApp.currentEvent, 86 | currentEvent.type == .keyDown, 87 | currentEvent.keyCode == 36, // Return key 88 | !currentEvent.modifierFlags.contains(.shift) { 89 | return // Skip find for regular return key 90 | } 91 | 92 | // If the textview is first responder, exit fast 93 | if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { 94 | // If the text view has focus, just clear visual emphases but keep our find matches 95 | target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) 96 | return 97 | } 98 | 99 | // Clear existing emphases before performing new find 100 | target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) 101 | find() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighlightProviding.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/18/23. 6 | // 7 | 8 | import Foundation 9 | import CodeEditTextView 10 | import CodeEditLanguages 11 | import AppKit 12 | 13 | /// A single-case error that should be thrown when an operation should be retried. 14 | public enum HighlightProvidingError: Error { 15 | case operationCancelled 16 | } 17 | 18 | /// The protocol a class must conform to to be used for highlighting. 19 | public protocol HighlightProviding: AnyObject { 20 | /// Called once to set up the highlight provider with a data source and language. 21 | /// - Parameters: 22 | /// - textView: The text view to use as a text source. 23 | /// - codeLanguage: The language that should be used by the highlighter. 24 | @MainActor 25 | func setUp(textView: TextView, codeLanguage: CodeLanguage) 26 | 27 | /// Notifies the highlighter that an edit is going to happen in the given range. 28 | /// - Parameters: 29 | /// - textView: The text view to use. 30 | /// - range: The range of the incoming edit. 31 | @MainActor 32 | func willApplyEdit(textView: TextView, range: NSRange) 33 | 34 | /// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted. 35 | /// The returned `IndexSet` should include all indexes that need to be highlighted, including any inserted text. 36 | /// - Parameters: 37 | /// - textView: The text view to use. 38 | /// - range: The range of the edit. 39 | /// - delta: The length of the edit, can be negative for deletions. 40 | /// - Returns: An `IndexSet` containing all Indices to invalidate. 41 | @MainActor 42 | func applyEdit( 43 | textView: TextView, 44 | range: NSRange, 45 | delta: Int, 46 | completion: @escaping @MainActor (Result) -> Void 47 | ) 48 | 49 | /// Queries the highlight provider for any ranges to apply highlights to. The highlight provider should return an 50 | /// array containing all ranges to highlight, and the capture type for the range. Any ranges or indexes 51 | /// excluded from the returned array will be treated as plain text and highlighted as such. 52 | /// - Parameters: 53 | /// - textView: The text view to use. 54 | /// - range: The range to query. 55 | /// - Returns: All highlight ranges for the queried ranges. 56 | @MainActor 57 | func queryHighlightsFor( 58 | textView: TextView, 59 | range: NSRange, 60 | completion: @escaping @MainActor (Result<[HighlightRange], Error>) -> Void 61 | ) 62 | } 63 | 64 | extension HighlightProviding { 65 | public func willApplyEdit(textView: TextView, range: NSRange) { } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Highlighting/HighlightRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighlightRange.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/14/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// This struct represents a range to highlight, as well as the capture name for syntax coloring. 11 | public struct HighlightRange: Hashable, Sendable { 12 | public let range: NSRange 13 | public let capture: CaptureName? 14 | public let modifiers: CaptureModifierSet 15 | 16 | public init(range: NSRange, capture: CaptureName?, modifiers: CaptureModifierSet = []) { 17 | self.range = range 18 | self.capture = capture 19 | self.modifiers = modifiers 20 | } 21 | } 22 | 23 | extension HighlightRange: CustomDebugStringConvertible { 24 | public var debugDescription: String { 25 | if capture == nil && modifiers.isEmpty { 26 | "\(range) (empty)" 27 | } else { 28 | "\(range) (\(capture?.stringValue ?? "No Capture")) \(modifiers.values.map({ $0.stringValue }))" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Highlighting/VisibleRangeProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisibleRangeProvider.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/13/24. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | @MainActor 12 | protocol VisibleRangeProviderDelegate: AnyObject { 13 | func visibleSetDidUpdate(_ newIndices: IndexSet) 14 | } 15 | 16 | /// Provides information to ``HighlightProviderState``s about what text is visible in the editor. Keeps it's contents 17 | /// in sync with a text view and notifies listeners about changes so highlights can be applied to newly visible indices. 18 | @MainActor 19 | class VisibleRangeProvider { 20 | private weak var textView: TextView? 21 | private weak var minimapView: MinimapView? 22 | weak var delegate: VisibleRangeProviderDelegate? 23 | 24 | var documentRange: NSRange { 25 | textView?.documentRange ?? .notFound 26 | } 27 | 28 | /// The set of visible indexes in the text view 29 | lazy var visibleSet: IndexSet = { 30 | return IndexSet(integersIn: textView?.visibleTextRange ?? NSRange()) 31 | }() 32 | 33 | init(textView: TextView, minimapView: MinimapView?) { 34 | self.textView = textView 35 | self.minimapView = minimapView 36 | 37 | if let scrollView = textView.enclosingScrollView { 38 | NotificationCenter.default.addObserver( 39 | self, 40 | selector: #selector(visibleTextChanged), 41 | name: NSView.frameDidChangeNotification, 42 | object: scrollView 43 | ) 44 | 45 | NotificationCenter.default.addObserver( 46 | self, 47 | selector: #selector(visibleTextChanged), 48 | name: NSView.boundsDidChangeNotification, 49 | object: scrollView.contentView 50 | ) 51 | } 52 | 53 | NotificationCenter.default.addObserver( 54 | self, 55 | selector: #selector(visibleTextChanged), 56 | name: NSView.frameDidChangeNotification, 57 | object: textView 58 | ) 59 | } 60 | 61 | /// Updates the view to highlight newly visible text when the textview is scrolled or bounds change. 62 | @objc func visibleTextChanged() { 63 | guard let textViewVisibleRange = textView?.visibleTextRange else { 64 | return 65 | } 66 | var visibleSet = IndexSet(integersIn: textViewVisibleRange) 67 | if !(minimapView?.isHidden ?? true), let minimapVisibleRange = minimapView?.visibleTextRange { 68 | visibleSet.formUnion(IndexSet(integersIn: minimapVisibleRange)) 69 | } 70 | self.visibleSet = visibleSet 71 | delegate?.visibleSetDidUpdate(visibleSet) 72 | } 73 | 74 | deinit { 75 | NotificationCenter.default.removeObserver(self) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapContentView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/16/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | /// Displays the real contents of the minimap. The layout manager and selection manager place views and draw into this 12 | /// view. 13 | /// 14 | /// Height and position are managed by ``MinimapView``. 15 | public class MinimapContentView: FlippedNSView { 16 | weak var textView: TextView? 17 | weak var layoutManager: TextLayoutManager? 18 | weak var selectionManager: TextSelectionManager? 19 | 20 | override public func draw(_ dirtyRect: NSRect) { 21 | super.draw(dirtyRect) 22 | if textView?.isSelectable ?? false { 23 | selectionManager?.drawSelections(in: dirtyRect) 24 | } 25 | } 26 | 27 | override public func layout() { 28 | super.layout() 29 | layoutManager?.layoutLines() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapLineRenderer.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/10/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { 12 | weak var textView: TextView? 13 | 14 | init(textView: TextView) { 15 | self.textView = textView 16 | } 17 | 18 | func prepareForDisplay( // swiftlint:disable:this function_parameter_count 19 | textLine: TextLine, 20 | displayData: TextLine.DisplayData, 21 | range: NSRange, 22 | stringRef: NSTextStorage, 23 | markedRanges: MarkedRanges?, 24 | attachments: [AnyTextAttachment] 25 | ) { 26 | let maxWidth: CGFloat = if let textView, textView.wrapLines { 27 | textView.layoutManager.maxLineLayoutWidth 28 | } else { 29 | .infinity 30 | } 31 | 32 | textLine.prepareForDisplay( 33 | displayData: TextLine.DisplayData(maxWidth: maxWidth, lineHeightMultiplier: 1.0, estimatedLineHeight: 3.0), 34 | range: range, 35 | stringRef: stringRef, 36 | markedRanges: markedRanges, 37 | attachments: [] 38 | ) 39 | 40 | // Make all fragments 2px tall 41 | textLine.lineFragments.forEach { fragmentPosition in 42 | let remainingHeight = fragmentPosition.height - 3.0 43 | if remainingHeight != 0 { 44 | textLine.lineFragments.update( 45 | atOffset: fragmentPosition.range.location, 46 | delta: 0, 47 | deltaHeight: -remainingHeight 48 | ) 49 | } 50 | fragmentPosition.data.height = 2.0 51 | fragmentPosition.data.scaledHeight = 3.0 52 | } 53 | } 54 | 55 | func estimatedLineHeight() -> CGFloat? { 56 | 3.0 57 | } 58 | 59 | func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { 60 | MinimapLineFragmentView(textStorage: textView?.textStorage) 61 | } 62 | 63 | func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { 64 | // Offset is relative to the whole line, the CTLine is too. 65 | guard let content = lineFragment.contents.first else { return 0.0 } 66 | switch content.data { 67 | case .text(let ctLine): 68 | return 8 + (CGFloat(offset - CTLineGetStringRange(ctLine).location) * 1.5) 69 | case .attachment: 70 | return 0.0 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapView+DocumentVisibleView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/11/25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension MinimapView { 11 | /// Updates the ``documentVisibleView`` and ``scrollView`` to match the editor's scroll offset. 12 | /// 13 | /// - Note: In this context, the 'container' is the visible rect in the minimap. 14 | /// - Note: This is *tricky*, there's two cases for both views. If modifying, make sure to test both when the 15 | /// minimap is shorter than the container height and when the minimap should scroll. 16 | /// 17 | /// The ``documentVisibleView`` uses a position that's entirely relative to the percent of the available scroll 18 | /// height scrolled. If the minimap is smaller than the container, it uses the same percent scrolled, but as a 19 | /// percent of the minimap height. 20 | /// 21 | /// The height of the ``documentVisibleView`` is calculated using a ratio of the editor's height to the 22 | /// minimap's height, then applying that to the container's height. 23 | /// 24 | /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that 25 | /// percentage. The ``scrollView`` is only modified if the minimap is longer than the container view. 26 | func updateDocumentVisibleViewPosition() { 27 | guard let textView = textView, let editorScrollView = textView.enclosingScrollView else { 28 | return 29 | } 30 | 31 | let availableHeight = min(minimapHeight, containerHeight) 32 | let editorScrollViewVisibleRect = ( 33 | editorScrollView.documentVisibleRect.height - editorScrollView.contentInsets.vertical 34 | ) 35 | let scrollPercentage = editorScrollView.percentScrolled 36 | guard scrollPercentage.isFinite else { return } 37 | 38 | let multiplier = if minimapHeight < containerHeight { 39 | editorScrollViewVisibleRect / textView.frame.height 40 | } else { 41 | editorToMinimapHeightRatio 42 | } 43 | 44 | // Update Visible Pane, should scroll down slowly as the user scrolls the document, following a similar pace 45 | // as the vertical `NSScroller`. 46 | // Visible pane's height = visible height * multiplier 47 | // Visible pane's position = (container height - visible pane height) * scrollPercentage 48 | let visibleRectHeight = availableHeight * multiplier 49 | guard visibleRectHeight < 1e100 else { return } 50 | 51 | let availableContainerHeight = (availableHeight - visibleRectHeight) 52 | let visibleRectYPos = availableContainerHeight * scrollPercentage 53 | 54 | documentVisibleView.frame.origin.y = scrollView.contentInsets.top + visibleRectYPos 55 | documentVisibleView.frame.size.height = visibleRectHeight 56 | 57 | // Minimap scroll offset slowly scrolls down with the visible pane. 58 | if minimapHeight > containerHeight { 59 | setScrollViewPosition(scrollPercentage: scrollPercentage) 60 | } 61 | } 62 | 63 | private func setScrollViewPosition(scrollPercentage: CGFloat) { 64 | let totalHeight = contentView.frame.height + scrollView.contentInsets.vertical 65 | let visibleHeight = scrollView.documentVisibleRect.height 66 | let yPos = (totalHeight - visibleHeight) * scrollPercentage 67 | scrollView.contentView.scroll( 68 | to: NSPoint( 69 | x: scrollView.contentView.frame.origin.x, 70 | y: yPos - scrollView.contentInsets.top 71 | ) 72 | ) 73 | scrollView.reflectScrolledClipView(scrollView.contentView) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapView+DragVisibleView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/16/25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension MinimapView { 11 | /// Responds to a drag gesture on the document visible view. Dragging the view scrolls the editor a relative amount. 12 | @objc func documentVisibleViewDragged(_ sender: NSPanGestureRecognizer) { 13 | guard let editorScrollView = textView?.enclosingScrollView else { 14 | return 15 | } 16 | 17 | // Convert the drag distance in the minimap to the drag distance in the editor. 18 | let translation = sender.translation(in: documentVisibleView) 19 | let ratio = if minimapHeight > containerHeight { 20 | containerHeight / (textView?.frame.height ?? 0.0) 21 | } else { 22 | editorToMinimapHeightRatio 23 | } 24 | let editorTranslation = translation.y / ratio 25 | sender.setTranslation(.zero, in: documentVisibleView) 26 | 27 | // Clamp the scroll amount to the content, so we don't scroll crazy far past the end of the document. 28 | var newScrollViewY = editorScrollView.contentView.bounds.origin.y - editorTranslation 29 | // Minimum Y value is the top of the scroll view 30 | newScrollViewY = max(-editorScrollView.contentInsets.top, newScrollViewY) 31 | newScrollViewY = min( // Max y value needs to take into account the editor overscroll 32 | editorScrollView.documentMaxOriginY - editorScrollView.contentInsets.top, // Relative to the content's top 33 | newScrollViewY 34 | ) 35 | 36 | editorScrollView.contentView.scroll( 37 | to: NSPoint( 38 | x: editorScrollView.contentView.bounds.origin.x, 39 | y: newScrollViewY 40 | ) 41 | ) 42 | editorScrollView.reflectScrolledClipView(editorScrollView.contentView) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapView+TextLayoutManagerDelegate.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/11/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | extension MinimapView: TextLayoutManagerDelegate { 12 | public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { 13 | updateContentViewHeight() 14 | } 15 | 16 | public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { } 17 | 18 | public func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { 19 | textView?.layoutManagerTypingAttributes() ?? [:] 20 | } 21 | 22 | public func textViewportSize() -> CGSize { 23 | var size = scrollView.contentSize 24 | size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom 25 | size.width = textView?.layoutManager.maxLineLayoutWidth ?? size.width 26 | return size 27 | } 28 | 29 | public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { 30 | var point = scrollView.documentVisibleRect.origin 31 | point.y += yAdjustment 32 | scrollView.documentView?.scroll(point) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Minimap/MinimapView+TextSelectionManagerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapView+TextSelectionManagerDelegate.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/16/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | extension MinimapView: TextSelectionManagerDelegate { 12 | public var visibleTextRange: NSRange? { 13 | let minY = max(visibleRect.minY, 0) 14 | let maxY = min(visibleRect.maxY, layoutManager?.estimatedHeight() ?? 3.0) 15 | guard let minYLine = layoutManager?.textLineForPosition(minY), 16 | let maxYLine = layoutManager?.textLineForPosition(maxY) else { 17 | return nil 18 | } 19 | return NSRange(start: minYLine.range.location, end: maxYLine.range.max) 20 | } 21 | 22 | public func setNeedsDisplay() { 23 | contentView.needsDisplay = true 24 | } 25 | 26 | public func estimatedLineHeight() -> CGFloat { 27 | layoutManager?.estimateLineHeight() ?? 3.0 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStore+Coalesce.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStore+Internals.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/25/24 6 | // 7 | 8 | import _RopeModule 9 | 10 | extension RangeStore { 11 | /// Coalesce items before and after the given range. 12 | /// 13 | /// Compares the next run with the run at the given range. If they're the same, removes the next run and grows the 14 | /// pointed-at run. 15 | /// Performs the same operation with the preceding run, with the difference that the pointed-at run is removed 16 | /// rather than the queried one. 17 | /// 18 | /// - Parameter range: The range of the item to coalesce around. 19 | mutating func coalesceNearby(range: Range) { 20 | var index = findIndex(at: range.lastIndex).index 21 | if index < _guts.endIndex && _guts.index(after: index) != _guts.endIndex { 22 | coalesceRunAfter(index: &index) 23 | } 24 | 25 | index = findIndex(at: range.lowerBound).index 26 | if index > _guts.startIndex && index < _guts.endIndex && _guts.count > 1 { 27 | index = _guts.index(before: index) 28 | coalesceRunAfter(index: &index) 29 | } 30 | } 31 | 32 | /// Check if the run and the run after it are equal, and if so remove the next one and concatenate the two. 33 | private mutating func coalesceRunAfter(index: inout Index) { 34 | let thisRun = _guts[index] 35 | let nextRun = _guts[_guts.index(after: index)] 36 | 37 | if thisRun.compareValue(nextRun) { 38 | _guts.update(at: &index, by: { $0.length += nextRun.length }) 39 | 40 | var nextIndex = index 41 | _guts.formIndex(after: &nextIndex) 42 | _guts.remove(at: nextIndex) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStore+FindIndex.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/6/25. 6 | // 7 | 8 | extension RangeStore { 9 | /// Finds a Rope index, given a string offset. 10 | /// - Parameter offset: The offset to query for. 11 | /// - Returns: The index of the containing element in the rope. 12 | func findIndex(at offset: Int) -> (index: Index, remaining: Int) { 13 | _guts.find(at: offset, in: OffsetMetric(), preferEnd: false) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStore+OffsetMetric.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStore+OffsetMetric.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/25/24 6 | // 7 | 8 | import _RopeModule 9 | 10 | extension RangeStore { 11 | struct OffsetMetric: RopeMetric { 12 | typealias Element = StoredRun 13 | 14 | func size(of summary: RangeStore.StoredRun.Summary) -> Int { 15 | summary.length 16 | } 17 | 18 | func index(at offset: Int, in element: RangeStore.StoredRun) -> Int { 19 | return offset 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStore+StoredRun.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStore+StoredRun.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 10/25/24 6 | 7 | import _RopeModule 8 | 9 | extension RangeStore { 10 | struct StoredRun { 11 | var length: Int 12 | let value: Element? 13 | 14 | static func empty(length: Int) -> Self { 15 | StoredRun(length: length, value: nil) 16 | } 17 | 18 | /// Compare two styled ranges by their stored styles. 19 | /// - Parameter other: The range to compare to. 20 | /// - Returns: The result of the comparison. 21 | func compareValue(_ other: Self) -> Bool { 22 | return if let lhs = value, let rhs = other.value { 23 | lhs == rhs 24 | } else if let lhs = value { 25 | lhs.isEmpty 26 | } else if let rhs = other.value { 27 | rhs.isEmpty 28 | } else { 29 | true 30 | } 31 | } 32 | } 33 | } 34 | 35 | extension RangeStore.StoredRun: RopeElement { 36 | typealias Index = Int 37 | 38 | var summary: Summary { Summary(length: length) } 39 | 40 | @inlinable 41 | var isEmpty: Bool { length == 0 } 42 | 43 | @inlinable 44 | var isUndersized: Bool { false } // Never undersized, pseudo-container 45 | 46 | func invariantCheck() {} 47 | 48 | mutating func rebalance(nextNeighbor right: inout Self) -> Bool { 49 | // Never undersized 50 | fatalError("Unimplemented") 51 | } 52 | 53 | mutating func rebalance(prevNeighbor left: inout Self) -> Bool { 54 | // Never undersized 55 | fatalError("Unimplemented") 56 | } 57 | 58 | mutating func split(at index: Self.Index) -> Self { 59 | assert(index >= 0 && index <= length) 60 | let tail = Self(length: length - index, value: value) 61 | length = index 62 | return tail 63 | } 64 | } 65 | 66 | extension RangeStore.StoredRun { 67 | struct Summary { 68 | var length: Int 69 | } 70 | } 71 | 72 | extension RangeStore.StoredRun.Summary: RopeSummary { 73 | // FIXME: This is entirely arbitrary. Benchmark this. 74 | @inline(__always) 75 | static var maxNodeSize: Int { 10 } 76 | 77 | @inline(__always) 78 | static var zero: RangeStore.StoredRun.Summary { Self(length: 0) } 79 | 80 | @inline(__always) 81 | var isZero: Bool { length == 0 } 82 | 83 | mutating func add(_ other: RangeStore.StoredRun.Summary) { 84 | length += other.length 85 | } 86 | 87 | mutating func subtract(_ other: RangeStore.StoredRun.Summary) { 88 | length -= other.length 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStoreElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStoreElement.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/28/25. 6 | // 7 | 8 | protocol RangeStoreElement: Equatable, Hashable { 9 | var isEmpty: Bool { get } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/RangeStore/RangeStoreRun.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeStoreRun.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 11/4/24. 6 | // 7 | 8 | /// Consumer-facing value type for the stored values in this container. 9 | struct RangeStoreRun: Equatable, Hashable { 10 | var length: Int 11 | var value: Element? 12 | 13 | static func empty(length: Int) -> Self { 14 | RangeStoreRun(length: length, value: nil) 15 | } 16 | 17 | var isEmpty: Bool { 18 | value?.isEmpty ?? true 19 | } 20 | 21 | mutating func subtractLength(_ other: borrowing RangeStoreRun) { 22 | self.length -= other.length 23 | } 24 | } 25 | 26 | extension RangeStoreRun: CustomDebugStringConvertible { 27 | var debugDescription: String { 28 | if let value = value as? CustomDebugStringConvertible { 29 | "\(length) (\(value.debugDescription))" 30 | } else { 31 | "\(length) (empty)" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReformattingGuideView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Austin Condiff on 4/28/25. 6 | // 7 | 8 | import AppKit 9 | import CodeEditTextView 10 | 11 | class ReformattingGuideView: NSView { 12 | private var column: Int 13 | private var _isVisible: Bool 14 | private var theme: EditorTheme 15 | 16 | var isVisible: Bool { 17 | get { _isVisible } 18 | set { 19 | _isVisible = newValue 20 | isHidden = !newValue 21 | needsDisplay = true 22 | } 23 | } 24 | 25 | init(column: Int = 80, isVisible: Bool = false, theme: EditorTheme) { 26 | self.column = column 27 | self._isVisible = isVisible 28 | self.theme = theme 29 | super.init(frame: .zero) 30 | wantsLayer = true 31 | isHidden = !isVisible 32 | } 33 | 34 | required init?(coder: NSCoder) { 35 | fatalError("init(coder:) has not been implemented") 36 | } 37 | 38 | override func hitTest(_ point: NSPoint) -> NSView? { 39 | return nil 40 | } 41 | 42 | // Draw the reformatting guide line and shaded area 43 | override func draw(_ dirtyRect: NSRect) { 44 | super.draw(dirtyRect) 45 | guard isVisible else { 46 | return 47 | } 48 | 49 | // Determine if we should use light or dark colors based on the theme's background color 50 | let isLightMode = theme.background.brightnessComponent > 0.5 51 | 52 | // Set the line color based on the theme 53 | let lineColor = isLightMode ? 54 | NSColor.black.withAlphaComponent(0.075) : 55 | NSColor.white.withAlphaComponent(0.175) 56 | 57 | // Set the shaded area color (slightly more transparent) 58 | let shadedColor = isLightMode ? 59 | NSColor.black.withAlphaComponent(0.025) : 60 | NSColor.white.withAlphaComponent(0.025) 61 | 62 | // Draw the vertical line (accounting for inverted Y coordinate system) 63 | lineColor.setStroke() 64 | let linePath = NSBezierPath() 65 | linePath.move(to: NSPoint(x: frame.minX, y: frame.maxY)) // Start at top 66 | linePath.line(to: NSPoint(x: frame.minX, y: frame.minY)) // Draw down to bottom 67 | linePath.lineWidth = 1.0 68 | linePath.stroke() 69 | 70 | // Draw the shaded area to the right of the line 71 | shadedColor.setFill() 72 | let shadedRect = NSRect( 73 | x: frame.minX, 74 | y: frame.minY, 75 | width: frame.width, 76 | height: frame.height 77 | ) 78 | shadedRect.fill() 79 | } 80 | 81 | func updatePosition(in textView: TextView) { 82 | guard isVisible else { 83 | return 84 | } 85 | 86 | // Calculate the x position based on the font's character width and column number 87 | let charWidth = textView.font.boundingRectForFont.width 88 | let xPosition = CGFloat(column) * charWidth / 2 // Divide by 2 to account for coordinate system 89 | 90 | // Get the scroll view's content size 91 | guard let scrollView = textView.enclosingScrollView else { return } 92 | let contentSize = scrollView.documentVisibleRect.size 93 | 94 | // Ensure we don't create an invalid frame 95 | let maxWidth = max(0, contentSize.width - xPosition) 96 | 97 | // Update the frame to be a vertical line at the specified column with a shaded area to the right 98 | let newFrame = NSRect( 99 | x: xPosition, 100 | y: 0, // Start above the visible area 101 | width: maxWidth + 1000, 102 | height: contentSize.height // Use extended height 103 | ).pixelAligned 104 | 105 | frame = newFrame 106 | needsDisplay = true 107 | } 108 | 109 | func setVisible(_ visible: Bool) { 110 | isVisible = visible 111 | } 112 | 113 | func setColumn(_ newColumn: Int) { 114 | column = newColumn 115 | needsDisplay = true 116 | } 117 | 118 | func setTheme(_ newTheme: EditorTheme) { 119 | theme = newTheme 120 | needsDisplay = true 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/EffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EffectView.swift 3 | // CodeEditModules/CodeEditUI 4 | // 5 | // Created by Rehatbir Singh on 15/03/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A SwiftUI Wrapper for `NSVisualEffectView` 11 | /// 12 | /// ## Usage 13 | /// ```swift 14 | /// EffectView(material: .headerView, blendingMode: .withinWindow) 15 | /// ``` 16 | struct EffectView: NSViewRepresentable { 17 | private let material: NSVisualEffectView.Material 18 | private let blendingMode: NSVisualEffectView.BlendingMode 19 | private let emphasized: Bool 20 | 21 | /// Initializes the 22 | /// [`NSVisualEffectView`](https://developer.apple.com/documentation/appkit/nsvisualeffectview) 23 | /// with a 24 | /// [`Material`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/material) and 25 | /// [`BlendingMode`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/blendingmode) 26 | /// 27 | /// By setting the 28 | /// [`emphasized`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/1644721-isemphasized) 29 | /// flag, the emphasized state of the material will be used if available. 30 | /// 31 | /// - Parameters: 32 | /// - material: The material to use. Defaults to `.headerView`. 33 | /// - blendingMode: The blending mode to use. Defaults to `.withinWindow`. 34 | /// - emphasized:A Boolean value indicating whether to emphasize the look of the material. Defaults to `false`. 35 | init( 36 | _ material: NSVisualEffectView.Material = .headerView, 37 | blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, 38 | emphasized: Bool = false 39 | ) { 40 | self.material = material 41 | self.blendingMode = blendingMode 42 | self.emphasized = emphasized 43 | } 44 | 45 | func makeNSView(context: Context) -> NSVisualEffectView { 46 | let view = NSVisualEffectView() 47 | view.material = material 48 | view.blendingMode = blendingMode 49 | view.isEmphasized = emphasized 50 | view.state = .followsWindowActiveState 51 | return view 52 | } 53 | 54 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 55 | nsView.material = material 56 | nsView.blendingMode = blendingMode 57 | } 58 | 59 | /// Returns the system selection style as an ``EffectView`` if the `condition` is met. 60 | /// Otherwise it returns `Color.clear` 61 | /// 62 | /// - Parameter condition: The condition of when to apply the background. Defaults to `true`. 63 | /// - Returns: A View 64 | @ViewBuilder 65 | static func selectionBackground(_ condition: Bool = true) -> some View { 66 | if condition { 67 | EffectView(.selection, blendingMode: .withinWindow, emphasized: true) 68 | } else { 69 | Color.clear 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlippedNSView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/11/25. 6 | // 7 | 8 | import AppKit 9 | 10 | open class FlippedNSView: NSView { 11 | open override var isFlipped: Bool { true } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForwardingScrollView.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/15/25. 6 | // 7 | 8 | import Cocoa 9 | 10 | /// A custom ``NSScrollView`` subclass that forwards scroll wheel events to another scroll view. 11 | /// This class does not process any other scrolling events. However, it still lays out it's contents like a 12 | /// regular scroll view. 13 | /// 14 | /// Set ``receiver`` to target events. 15 | open class ForwardingScrollView: NSScrollView { 16 | /// The target scroll view to send scroll events to. 17 | open weak var receiver: NSScrollView? 18 | 19 | open override func scrollWheel(with event: NSEvent) { 20 | receiver?.scrollWheel(with: event) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/IconButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconButtonStyle.swift 3 | // CodeEdit 4 | // 5 | // Created by Austin Condiff on 11/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct IconButtonStyle: ButtonStyle { 11 | var isActive: Bool? 12 | var font: Font? 13 | var size: CGSize? 14 | 15 | init(isActive: Bool? = nil, font: Font? = nil, size: CGFloat? = nil) { 16 | self.isActive = isActive 17 | self.font = font 18 | self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) 19 | } 20 | 21 | init(isActive: Bool? = nil, font: Font? = nil, size: CGSize? = nil) { 22 | self.isActive = isActive 23 | self.font = font 24 | self.size = size 25 | } 26 | 27 | init(isActive: Bool? = nil, font: Font? = nil) { 28 | self.isActive = isActive 29 | self.font = font 30 | self.size = nil 31 | } 32 | 33 | func makeBody(configuration: ButtonStyle.Configuration) -> some View { 34 | IconButton( 35 | configuration: configuration, 36 | isActive: isActive, 37 | font: font, 38 | size: size 39 | ) 40 | } 41 | 42 | struct IconButton: View { 43 | let configuration: ButtonStyle.Configuration 44 | var isActive: Bool 45 | var font: Font 46 | var size: CGSize? 47 | @Environment(\.controlActiveState) 48 | private var controlActiveState 49 | @Environment(\.isEnabled) 50 | private var isEnabled: Bool 51 | @Environment(\.colorScheme) 52 | private var colorScheme 53 | 54 | init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGFloat?) { 55 | self.configuration = configuration 56 | self.isActive = isActive ?? false 57 | self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) 58 | self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) 59 | } 60 | 61 | init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGSize?) { 62 | self.configuration = configuration 63 | self.isActive = isActive ?? false 64 | self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) 65 | self.size = size ?? nil 66 | } 67 | 68 | init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?) { 69 | self.configuration = configuration 70 | self.isActive = isActive ?? false 71 | self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) 72 | self.size = nil 73 | } 74 | 75 | var body: some View { 76 | configuration.label 77 | .font(font) 78 | .foregroundColor( 79 | isActive 80 | ? Color(.controlAccentColor) 81 | : Color(.secondaryLabelColor) 82 | ) 83 | .frame(width: size?.width, height: size?.height, alignment: .center) 84 | .contentShape(Rectangle()) 85 | .brightness( 86 | configuration.isPressed 87 | ? colorScheme == .dark 88 | ? 0.5 89 | : isActive ? -0.25 : -0.75 90 | : 0 91 | ) 92 | .opacity(controlActiveState == .inactive ? 0.5 : 1) 93 | .symbolVariant(isActive ? .fill : .none) 94 | } 95 | } 96 | } 97 | 98 | extension ButtonStyle where Self == IconButtonStyle { 99 | static func icon( 100 | isActive: Bool? = false, 101 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), 102 | size: CGFloat? = 24 103 | ) -> IconButtonStyle { 104 | return IconButtonStyle(isActive: isActive, font: font, size: size) 105 | } 106 | static func icon( 107 | isActive: Bool? = false, 108 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), 109 | size: CGSize? = CGSize(width: 24, height: 24) 110 | ) -> IconButtonStyle { 111 | return IconButtonStyle(isActive: isActive, font: font, size: size) 112 | } 113 | static func icon( 114 | isActive: Bool? = false, 115 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) 116 | ) -> IconButtonStyle { 117 | return IconButtonStyle(isActive: isActive, font: font) 118 | } 119 | static var icon: IconButtonStyle { .init() } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/IconToggleStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconToggleStyle.swift 3 | // CodeEdit 4 | // 5 | // Created by Austin Condiff on 11/9/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct IconToggleStyle: ToggleStyle { 11 | var font: Font? 12 | var size: CGSize? 13 | 14 | @State var isPressing = false 15 | 16 | init(font: Font? = nil, size: CGFloat? = nil) { 17 | self.font = font 18 | self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) 19 | } 20 | 21 | init(font: Font? = nil, size: CGSize? = nil) { 22 | self.font = font 23 | self.size = size 24 | } 25 | 26 | init(font: Font? = nil) { 27 | self.font = font 28 | self.size = nil 29 | } 30 | 31 | func makeBody(configuration: ToggleStyle.Configuration) -> some View { 32 | Button( 33 | action: { configuration.isOn.toggle() }, 34 | label: { configuration.label } 35 | ) 36 | .buttonStyle(.icon(isActive: configuration.isOn, font: font, size: size)) 37 | } 38 | } 39 | 40 | extension ToggleStyle where Self == IconToggleStyle { 41 | static func icon( 42 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), 43 | size: CGFloat? = 24 44 | ) -> IconToggleStyle { 45 | return IconToggleStyle(font: font, size: size) 46 | } 47 | static func icon( 48 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), 49 | size: CGSize? = CGSize(width: 24, height: 24) 50 | ) -> IconToggleStyle { 51 | return IconToggleStyle(font: font, size: size) 52 | } 53 | static func icon( 54 | font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) 55 | ) -> IconToggleStyle { 56 | return IconToggleStyle(font: font) 57 | } 58 | static var icon: IconToggleStyle { .init() } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/SupportingViews/PanelStyles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PanelStyles.swift 3 | // CodeEdit 4 | // 5 | // Created by Austin Condiff on 3/12/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | private struct InsideControlGroupKey: EnvironmentKey { 11 | static let defaultValue: Bool = false 12 | } 13 | 14 | extension EnvironmentValues { 15 | var isInsideControlGroup: Bool { 16 | get { self[InsideControlGroupKey.self] } 17 | set { self[InsideControlGroupKey.self] = newValue } 18 | } 19 | } 20 | 21 | struct PanelControlGroupStyle: ControlGroupStyle { 22 | @Environment(\.controlActiveState) private var controlActiveState 23 | 24 | func makeBody(configuration: Configuration) -> some View { 25 | HStack(spacing: 0) { 26 | configuration.content 27 | .buttonStyle(PanelButtonStyle()) 28 | .environment(\.isInsideControlGroup, true) 29 | .padding(.vertical, 1) 30 | } 31 | .overlay( 32 | RoundedRectangle(cornerRadius: 4) 33 | .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) 34 | ) 35 | .cornerRadius(4) 36 | .clipped() 37 | } 38 | } 39 | 40 | struct PanelButtonStyle: ButtonStyle { 41 | @Environment(\.colorScheme) var colorScheme 42 | @Environment(\.controlActiveState) private var controlActiveState 43 | @Environment(\.isEnabled) private var isEnabled 44 | @Environment(\.isInsideControlGroup) private var isInsideControlGroup 45 | 46 | func makeBody(configuration: Configuration) -> some View { 47 | configuration.label 48 | .font(.system(size: 12, weight: .regular)) 49 | .foregroundColor(Color(.controlTextColor)) 50 | .padding(.horizontal, 6) 51 | .frame(height: isInsideControlGroup ? 16 : 18) 52 | .background( 53 | configuration.isPressed 54 | ? colorScheme == .light 55 | ? Color.black.opacity(0.06) 56 | : Color.white.opacity(0.24) 57 | : Color.clear 58 | ) 59 | .overlay( 60 | Group { 61 | if !isInsideControlGroup { 62 | RoundedRectangle(cornerRadius: 4) 63 | .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) 64 | } 65 | } 66 | ) 67 | .cornerRadius(isInsideControlGroup ? 0 : 4) 68 | .clipped() 69 | .contentShape(Rectangle()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/TextViewCoordinator/CombineCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombineCoordinator.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 5/19/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import CodeEditTextView 11 | 12 | /// A ``TextViewCoordinator`` class that publishes text changes and selection changes using Combine publishers. 13 | /// 14 | /// This class provides two publisher streams: ``textUpdatePublisher`` and ``selectionUpdatePublisher``. 15 | /// Both streams will receive any updates for text edits or selection changes and a `.finished` completion when the 16 | /// source editor is destroyed. 17 | public class CombineCoordinator: TextViewCoordinator { 18 | /// Publishes edit notifications as the text is changed in the editor. 19 | public var textUpdatePublisher: AnyPublisher { 20 | updateSubject.eraseToAnyPublisher() 21 | } 22 | 23 | /// Publishes cursor changes as the user types or selects text. 24 | public var selectionUpdatePublisher: AnyPublisher<[CursorPosition], Never> { 25 | selectionSubject.eraseToAnyPublisher() 26 | } 27 | 28 | private let updateSubject: PassthroughSubject = .init() 29 | private let selectionSubject: CurrentValueSubject<[CursorPosition], Never> = .init([]) 30 | 31 | /// Initializes the coordinator. 32 | public init() { } 33 | 34 | public func prepareCoordinator(controller: TextViewController) { } 35 | 36 | public func textViewDidChangeText(controller: TextViewController) { 37 | updateSubject.send() 38 | } 39 | 40 | public func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { 41 | selectionSubject.send(newPositions) 42 | } 43 | 44 | public func destroy() { 45 | updateSubject.send(completion: .finished) 46 | selectionSubject.send(completion: .finished) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextViewCoordinator.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 11/14/23. 6 | // 7 | 8 | import AppKit 9 | 10 | /// A protocol that can be used to receive extra state change messages from ``CodeEditSourceEditor``. 11 | /// 12 | /// These are used as a way to push messages up from underlying components into SwiftUI land without requiring passing 13 | /// callbacks for each message to the ``CodeEditSourceEditor`` initializer. 14 | /// 15 | /// They're very useful for updating UI that is directly related to the state of the editor, such as the current 16 | /// cursor position. For an example, see the ``CombineCoordinator`` class, which implements combine publishers for the 17 | /// messages this protocol provides. 18 | /// 19 | /// Conforming objects can also be used to get more detailed text editing notifications by conforming to the 20 | /// `TextViewDelegate` (from CodeEditTextView) protocol. In that case they'll receive most text change notifications. 21 | public protocol TextViewCoordinator: AnyObject { 22 | /// Called when an instance of ``TextViewController`` is available. Use this method to install any delegates, 23 | /// perform any modifications on the text view or controller, or capture the text view for later use in your app. 24 | /// 25 | /// - Parameter controller: The text controller. This is safe to keep a weak reference to, as long as it is 26 | /// dereferenced when ``TextViewCoordinator/destroy()-9nzfl`` is called. 27 | func prepareCoordinator(controller: TextViewController) 28 | 29 | /// Called when the text view's text changed. 30 | /// - Parameter controller: The text controller. 31 | func textViewDidChangeText(controller: TextViewController) 32 | 33 | /// Called after the text view updated it's cursor positions. 34 | /// - Parameter newPositions: The new positions of the cursors. 35 | func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) 36 | 37 | /// Called when the text controller is being destroyed. Use to free any necessary resources. 38 | func destroy() 39 | } 40 | 41 | /// Default implementations 42 | public extension TextViewCoordinator { 43 | func textViewDidChangeText(controller: TextViewController) { } 44 | func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { } 45 | func destroy() { } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Theme/ThemeAttributesProviding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeAttributesProviding.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 1/18/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Classes conforming to this protocol can provide attributes for text given a capture type. 11 | public protocol ThemeAttributesProviding: AnyObject { 12 | func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] 13 | } 14 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Atomic.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/2/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A simple atomic value using `NSLock`. 11 | final package class Atomic { 12 | private let lock: NSLock = .init() 13 | private var wrappedValue: T 14 | 15 | init(_ wrappedValue: T) { 16 | self.wrappedValue = wrappedValue 17 | } 18 | 19 | func mutate(_ handler: (inout T) -> Void) { 20 | lock.withLock { 21 | handler(&wrappedValue) 22 | } 23 | } 24 | 25 | func value() -> T { 26 | lock.withLock { wrappedValue } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/TreeSitter/TreeSitterClient+Edit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TreeSitterClient+Edit.swift 3 | // 4 | // 5 | // Created by Khan Winter on 3/10/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftTreeSitter 10 | import CodeEditLanguages 11 | 12 | extension TreeSitterClient { 13 | /// Applies the given edit to the current state and calls the editState's completion handler. 14 | /// 15 | /// Concurrency note: This method checks for task cancellation between layer edits, after editing all layers, and 16 | /// before setting the client's state. 17 | /// 18 | /// - Parameter edit: The edit to apply to the internal tree sitter state. 19 | /// - Returns: The set of ranges invalidated by the edit operation. 20 | func applyEdit(edit: InputEdit) -> IndexSet { 21 | guard let state = state?.copy(), let readBlock, let readCallback else { return IndexSet() } 22 | let pendingEdits = pendingEdits.value() // Grab pending edits. 23 | let edits = pendingEdits + [edit] 24 | 25 | var invalidatedRanges = IndexSet() 26 | var touchedLayers = Set() 27 | 28 | // Loop through all layers, apply edits & find changed byte ranges. 29 | for (idx, layer) in state.layers.enumerated().reversed() { 30 | if Task.isCancelled { return IndexSet() } 31 | 32 | if layer.id != state.primaryLayer.id { 33 | applyEditTo(layer: layer, edits: edits) 34 | 35 | if layer.ranges.isEmpty { 36 | state.removeLanguageLayer(at: idx) 37 | continue 38 | } 39 | 40 | touchedLayers.insert(layer) 41 | } 42 | 43 | layer.parser.includedRanges = layer.ranges.map { $0.tsRange } 44 | let ranges = layer.findChangedByteRanges( 45 | edits: edits, 46 | timeout: Constants.parserTimeout, 47 | readBlock: readBlock 48 | ) 49 | invalidatedRanges.insert(ranges: ranges) 50 | } 51 | 52 | if Task.isCancelled { return IndexSet() } 53 | 54 | // Update the state object for any new injections that may have been caused by this edit. 55 | invalidatedRanges.formUnion(state.updateInjectedLayers( 56 | readCallback: readCallback, 57 | readBlock: readBlock, 58 | touchedLayers: touchedLayers 59 | )) 60 | 61 | if Task.isCancelled { return IndexSet() } 62 | 63 | self.state = state // Apply the copied state 64 | self.pendingEdits.mutate { edits in // Clear the queue 65 | edits = [] 66 | } 67 | 68 | return invalidatedRanges 69 | } 70 | 71 | private func applyEditTo(layer: LanguageLayer, edits: [InputEdit]) { 72 | // Reversed for safe removal while looping 73 | for rangeIdx in (0.. 25 | } 26 | 27 | /// Finds nodes for each language layer at the given location. 28 | /// - Parameter location: The location to get a node for. 29 | /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree at the given location. 30 | /// - Throws: A ``TreeSitterClient.Error`` error. 31 | public func nodesAt(location: Int) throws -> [NodeResult] { 32 | let range = NSRange(location: location, length: 1) 33 | return try nodesAt(range: range) 34 | } 35 | 36 | /// Finds nodes in each language layer for the given range. 37 | /// - Parameter range: The range to get a node for. 38 | /// - Returns: All pairs of `Language, Node` where Node is the nearest node in the tree in the given range. 39 | /// - Throws: A ``TreeSitterClient.Error`` error. 40 | public func nodesAt(range: NSRange) throws -> [NodeResult] { 41 | try executor.execSync({ 42 | var nodes: [NodeResult] = [] 43 | for layer in self.state?.layers ?? [] { 44 | if let language = layer.tsLanguage, 45 | let node = layer.tree?.rootNode?.descendant(in: range.tsRange.bytes) { 46 | nodes.append(NodeResult(id: layer.id, language: language, node: node)) 47 | } 48 | } 49 | return nodes 50 | }) 51 | .throwOrReturn() 52 | } 53 | 54 | /// Perform a query on the tree sitter layer tree. 55 | /// - Parameters: 56 | /// - query: The query to perform. 57 | /// - matchingLanguages: A set of languages to limit the query to. Leave empty to not filter out any layers. 58 | /// - Returns: Any matching nodes from the query. 59 | public func query(_ query: Query, matchingLanguages: Set = []) throws -> [QueryResult] { 60 | try executor.execSync({ 61 | guard let readCallback = self.readCallback else { return [] } 62 | var result: [QueryResult] = [] 63 | for layer in self.state?.layers ?? [] { 64 | guard matchingLanguages.isEmpty || matchingLanguages.contains(layer.id) else { continue } 65 | guard let tree = layer.tree else { continue } 66 | let cursor = query.execute(in: tree) 67 | let resolvingCursor = cursor.resolve(with: Predicate.Context(textProvider: readCallback)) 68 | result.append(QueryResult(id: layer.id, cursor: resolvingCursor)) 69 | } 70 | return result 71 | }) 72 | .throwOrReturn() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Utils/CursorPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CursorPosition.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 11/13/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// # Cursor Position 11 | /// 12 | /// Represents the position of a cursor in a document. 13 | /// Provides information about the range of the selection relative to the document, and the line-column information. 14 | /// 15 | /// Can be initialized by users without knowledge of either column and line position or range in the document. 16 | /// When initialized by users, certain values may be set to `NSNotFound` or `-1` until they can be filled in by the text 17 | /// controller. 18 | /// 19 | public struct CursorPosition: Sendable, Codable, Equatable, Hashable { 20 | /// Initialize a cursor position. 21 | /// 22 | /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. 23 | /// The range value, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it appears 24 | /// in the``TextViewController/cursorPositions`` array. 25 | /// 26 | /// - Parameters: 27 | /// - line: The line of the cursor position, 1-indexed. 28 | /// - column: The column of the cursor position, 1-indexed. 29 | public init(line: Int, column: Int) { 30 | self.range = .notFound 31 | self.line = line 32 | self.column = column 33 | } 34 | 35 | /// Initialize a cursor position. 36 | /// 37 | /// When this initializer is used, both ``CursorPosition/line`` and ``CursorPosition/column`` will be initialized 38 | /// to `-1`. They will, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it 39 | /// appears in the ``TextViewController/cursorPositions`` array. 40 | /// 41 | /// - Parameter range: The range of the cursor position. 42 | public init(range: NSRange) { 43 | self.range = range 44 | self.line = -1 45 | self.column = -1 46 | } 47 | 48 | /// Private initializer. 49 | /// - Parameters: 50 | /// - range: The range of the position. 51 | /// - line: The line of the position. 52 | /// - column: The column of the position. 53 | package init(range: NSRange, line: Int, column: Int) { 54 | self.range = range 55 | self.line = line 56 | self.column = column 57 | } 58 | 59 | /// The range of the selection. 60 | public let range: NSRange 61 | /// The line the cursor is located at. 1-indexed. 62 | /// If ``CursorPosition/range`` is not empty, this is the line at the beginning of the selection. 63 | public let line: Int 64 | /// The column the cursor is located at. 1-indexed. 65 | /// If ``CursorPosition/range`` is not empty, this is the column at the beginning of the selection. 66 | public let column: Int 67 | } 68 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmphasisGroup.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 4/7/25. 6 | // 7 | 8 | enum EmphasisGroup { 9 | static let brackets = "codeedit.bracketPairs" 10 | static let find = "codeedit.find" 11 | } 12 | -------------------------------------------------------------------------------- /Sources/CodeEditSourceEditor/Utils/WeakCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakCoordinator.swift 3 | // CodeEditSourceEditor 4 | // 5 | // Created by Khan Winter on 9/13/24. 6 | // 7 | 8 | struct WeakCoordinator { 9 | weak var val: TextViewCoordinator? 10 | 11 | init(_ val: TextViewCoordinator) { 12 | self.val = val 13 | } 14 | } 15 | 16 | extension Array where Element == WeakCoordinator { 17 | mutating func clean() { 18 | self.removeAll(where: { $0.val == nil }) 19 | } 20 | 21 | mutating func values() -> [TextViewCoordinator] { 22 | self.clean() 23 | return self.compactMap({ $0.val }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/CodeEditSourceEditorTests/CaptureModifierSetTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CodeEditSourceEditor 3 | 4 | final class CaptureModifierSetTests: XCTestCase { 5 | func test_init() { 6 | // Init empty 7 | let set1 = CaptureModifierSet(rawValue: 0) 8 | XCTAssertEqual(set1, []) 9 | XCTAssertEqual(set1.values, []) 10 | 11 | // Init with multiple values 12 | let set2 = CaptureModifierSet(rawValue: 0b1101) 13 | XCTAssertEqual(set2, [.declaration, .readonly, .static]) 14 | XCTAssertEqual(set2.values, [.declaration, .readonly, .static]) 15 | } 16 | 17 | func test_insert() { 18 | var set = CaptureModifierSet(rawValue: 0) 19 | XCTAssertEqual(set, []) 20 | 21 | // Insert one item 22 | set.insert(.declaration) 23 | XCTAssertEqual(set, [.declaration]) 24 | 25 | // Inserting again does nothing 26 | set.insert(.declaration) 27 | XCTAssertEqual(set, [.declaration]) 28 | 29 | // Insert more items 30 | set.insert(.declaration) 31 | set.insert(.async) 32 | set.insert(.documentation) 33 | XCTAssertEqual(set, [.declaration, .async, .documentation]) 34 | 35 | // Order doesn't matter 36 | XCTAssertEqual(set, [.async, .declaration, .documentation]) 37 | } 38 | 39 | func test_values() { 40 | // Invalid rawValue returns non-garbage results 41 | var set = CaptureModifierSet([.declaration, .readonly, .static]) 42 | set.rawValue |= 1 << 48 // No real modifier with raw value 48, but we still have all the other values 43 | 44 | XCTAssertEqual(set.values, [.declaration, .readonly, .static]) 45 | 46 | set = CaptureModifierSet() 47 | set.insert(.declaration) 48 | set.insert(.async) 49 | set.insert(.documentation) 50 | XCTAssertEqual(set.values, [.declaration, .async, .documentation]) 51 | XCTAssertNotEqual(set.values, [.declaration, .documentation, .async]) // Order matters 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/CodeEditSourceEditorTests/CodeEditSourceEditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CodeEditSourceEditor 3 | 4 | // swiftlint:disable all 5 | final class CodeEditSourceEditorTests: XCTestCase { 6 | 7 | // MARK: NSFont Line Height 8 | 9 | func test_LineHeight() throws { 10 | let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) 11 | let result = font.lineHeight 12 | let expected = 15.0 13 | XCTAssertEqual(result, expected) 14 | } 15 | 16 | func test_LineHeight2() throws { 17 | let font = NSFont.monospacedSystemFont(ofSize: 0, weight: .regular) 18 | let result = font.lineHeight 19 | let expected = 16.0 20 | XCTAssertEqual(result, expected) 21 | } 22 | 23 | // MARK: String NSRange 24 | 25 | func test_StringSubscriptNSRange() throws { 26 | let testString = "Hello, World" 27 | let testRange = NSRange(location: 7, length: 5) 28 | 29 | let result = String(testString[testRange]!) 30 | let expected = "World" 31 | XCTAssertEqual(result, expected) 32 | } 33 | 34 | func test_StringSubscriptNSRange2() throws { 35 | let testString = "Hello,\nWorld" 36 | let testRange = NSRange(location: 7, length: 5) 37 | 38 | let result = String(testString[testRange]!) 39 | let expected = "World" 40 | XCTAssertEqual(result, expected) 41 | } 42 | } 43 | // swiftlint:enable all 44 | -------------------------------------------------------------------------------- /Tests/CodeEditSourceEditorTests/Highlighting/VisibleRangeProviderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CodeEditSourceEditor 3 | 4 | final class VisibleRangeProviderTests: XCTestCase { 5 | @MainActor 6 | func test_updateOnScroll() { 7 | let (scrollView, textView) = Mock.scrollingTextView() 8 | textView.string = Array(repeating: "\n", count: 400).joined() 9 | textView.layout() 10 | 11 | let rangeProvider = VisibleRangeProvider(textView: textView, minimapView: nil) 12 | let originalSet = rangeProvider.visibleSet 13 | 14 | scrollView.contentView.scroll(to: NSPoint(x: 0, y: 250)) 15 | 16 | scrollView.layoutSubtreeIfNeeded() 17 | textView.layout() 18 | 19 | XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) 20 | } 21 | 22 | @MainActor 23 | func test_updateOnResize() { 24 | let (scrollView, textView) = Mock.scrollingTextView() 25 | textView.string = Array(repeating: "\n", count: 400).joined() 26 | textView.layout() 27 | 28 | let rangeProvider = VisibleRangeProvider(textView: textView, minimapView: nil) 29 | let originalSet = rangeProvider.visibleSet 30 | 31 | scrollView.setFrameSize(NSSize(width: 250, height: 450)) 32 | 33 | scrollView.layoutSubtreeIfNeeded() 34 | textView.layout() 35 | 36 | XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) 37 | } 38 | 39 | // Skipping due to a bug in the textview that returns all indices for the visible rect 40 | // when not in a scroll view 41 | 42 | @MainActor 43 | func _test_updateOnResizeNoScrollView() { 44 | let textView = Mock.textView() 45 | textView.frame = NSRect(x: 0, y: 0, width: 100, height: 100) 46 | textView.string = Array(repeating: "\n", count: 400).joined() 47 | textView.layout() 48 | 49 | let rangeProvider = VisibleRangeProvider(textView: textView, minimapView: nil) 50 | let originalSet = rangeProvider.visibleSet 51 | 52 | textView.setFrameSize(NSSize(width: 350, height: 450)) 53 | 54 | textView.layout() 55 | 56 | XCTAssertNotEqual(originalSet, rangeProvider.visibleSet) 57 | } 58 | } 59 | --------------------------------------------------------------------------------