File: README.md
VimR — Neovim GUI for macOS
About
VimR is a Neovim GUI for macOS written in Swift.
The goal is to build an editor that uses Neovim inside with some of the convenient
GUI features similar to those present in other editors.
There are other Neovim GUIs for macOS,
see the list, so why VimR?
- Play around with Neovim,
- Play around with the main idea of Redux architecture, and
- (most importantly) have fun!
If you feel chatty, there is a chat room: https://matrix.to/#/#vimr:matrix.org
If you want to support VimR financially, use Github's Sponsor.
Download
Pre-built Universal signed and notarized binaries can be found under Releases.
Requirements
- macOS 13.0 or later
- Development: Xcode 26
Reusable Components
VimR is built with a modular architecture. The following Swift packages can be used independently:
- NvimView: SwiftPM module containing an NSView which bundles everything needed to embed Neovim in a Cocoa app, including the Neovim binary and runtime files.
- NvimApi: Synchronous and asynchronous API for Neovim.
- Commons: Common utilities and helpers used across the project.
- Tabs: Tab bar component.
- Workspace: Workspace management component.
- Ignore: Gitignore-style pattern matching using wildmatch.
Features
- Markdown preview
- Generic HTML preview (retains the scroll position when reloading)
- Fuzzy file finder a la Xcode's "Open Quickly..."
- Trackpad support: Pinching for zooming and two-finger scrolling.
- Ligatures: Turned off by default. Turn it on in the Preferences.
- Command line tool.
- (Simple) File browser
- Flexible workspace model a la JetBrain's IDEs
How to Build
Clone this repository. Install homebrew, then in the project root:
git submodule update --init
xcode-select --install # install the Xcode command line tools, if you haven't already
brew bundle # install dependencies, e.g., build tools for Neovim
clean=true notarize=false trust_plugins=true ./bin/build_vimr.shtrust_plugins=true: Skips the interactive package plugin validation (SwiftLint), allowing the build to proceed in a non-interactive shell.notarize=false: Skips the Apple notarization process and performs an ad-hoc signature instead.
The built application will be located at ./build/Build/Products/Release/VimR.app.
For convenience, you can use the helper script to build and overwrite the application in /Applications:
./bin/build_and_install_local_release.shDevelopment
See DEVELOP.md.
License
File: bin/README.md
How to use
cdinto${PROJECT_ROOT}/bin, this directory.- Install pyenv and pyenv-virtuelenv.
- Install Python 3.9.7 using pyenv.
- Create a virtualenv with the name
com.qvacua.VimR.bin. - Ensure that you're running the Python in the virtualenv bybash
pyenv which python /${HOME}/.pyenv/versions/com.qvacua.VimR.bin/bin/python - Install the requirementsbash
pip install -r requirements.txt
File: ci/README.md
- Install Jenkins (via brew)
- Install plugins
- Job DSL
- AnsiColor
- Set the
gitbinary in Manage Jenkins -> Global Tool Configuration - Set
PATHfor Jenkins (necessary for e.g.git-lfs) in Manage Jenkins -> Configure System -> Global properties -> *Environment variables" - Add a free style job
vimr_setup_jobswith one step to process a Job DSL file atci/create_build_job.groovy.- Approve script at Manager Jenkins -> In-process Script Approval.
To test the job creation using local git repository, use file:///Users/.../vimr-repo as repository and add "-Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true" to /opt/homebrew/opt/jenkins/bin/jenkins:
#!/bin/bash
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home}"
exec "${JAVA_HOME}/bin/java" "-Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true" "-jar" "/opt/homebrew/Cellar/jenkins/2.435/libexec/jenkins.war" "$@"File: docs/notes-on-cocoa-text-input.md
Some Notes on Cocoa's Text Input
To use Cocoa's text input system, e.g. the 2-Set Korean input, your view has to implement the NSTextInputClient protocol. Apple's documentation is very scarce, so we're writing down some of our findings.
Simple Case
For simple cases like ü, which can be entered by Opt-u + u, it's quite straightforward:
- Enter
Opt-u. hasMarkedText()is called to check whether we already have marked text.setMarkedText("¨", selectedRange NSRange(1, 0), replacementRange: NSRange(NSNotFound, 0))is called. In this case the first argument is anNSString,selectedRangetells us where to put the cursor relative to the string: in this case after¨. The rangereplacemenRangetells us whether the string should replace some of the existing text. In this case no replacement is required.- Enter
u. hasMarkedText()is called again.insertText("ü", replacementRange: NSRange(NSNotFound, 0))is called to finalize the input. It seems that for the replacement range(NSNotFound, 0)we should replace the previously marked text with the final string. So in this case we must first delete¨and insertü.
Korean (Hangul, 한글)
Let's move to a bit more complicated case: Korean. In this case more methods are involved:
selectedRange(): all other additional methods seem to rely on this method. Ideally we should returnNSRange(CursorPosition, 0)when nothing is selected orNSRange(SelectionBegin, SelectionLength)when there's a selection.attributedSubstringForProposedRange(_:actualRange:): for entering only Hangul, this method can be ignored.
Let's assume we want to enter 하태원: (hasMarkedText() is called here and there...)
selectedRange()is called multiple times when changing the input method from US to Korean. This is also the case when starting the app with Korean input selected.- Enter
ㅎ. setMarkedText("ㅎ", selectedRange: NSRange(1, 0) replacementRange:NSRange(NotFound, 0))is called.- Enter
ㅏ. attributedSubstringForProposedRange(_:actualRange:)andselectedRange()are called multiple times: again, for only Hangul, ignorable.setMarkedText("하", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called: deleteㅎand insert하; not yet finalized.- Enter
ㅌ attributedSubstringForProposedRange(_:actualRange:)andselectedRange()are called multiple times: ignore.setMarkedText("핱", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called: delete하and insert핱; not yet finalized.- Enter
ㅐ attributedSubstringForProposedRange(_:actualRange:)andselectedRange()are called multiple times: ignore.setMarkedText("하", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called: delete핱and insert하; not yet finalized.insertText("하", replacementRange: NSRange(NotFound, 0))is called to finalize the input of하.attributedSubstringForProposedRange(_:actualRange:)andselectedRange()are called multiple times: ignore.setMarkedText("태", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called: Since the replacement range isNotFound, append the marked text태to the freshly finalized하.- ...
Hanja (한자)
Let's consider the even more complicated case: Hanja in Korean. In this case the selectedRange() and attributedSubstringForProposedRange(_:actualRange:) play a vital role and also
firstRectForCharacterRange(_:actualRange): this method is used to determine where to show the Hanja popup. The character range is determined byselectedRange().
Let's assume we want to enter 河: (again hasMarkedText() is called here and there...)
- Enter
ㅎ. setMarkedText("ㅎ", selectedRange: NSRange(1, 0) replacementRange:NSRange(NotFound, 0))is called.- Enter
ㅏ. attributedSubstringForProposedRange(_:actualRange:),selectedRange()andhasMarkedText()are called multiple times: again, for only Hangul, ignorable.setMarkedText("하", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called: deleteㅎand insert하; not yet finalized.- Enter
Opt-Return. setMarkedText("하", selectedRange: NSRange(1, 0), replacementRange: NSRange(NotFound, 0))is called again.selectedRange()is called: here we should return a range which can be consistently used byattributedSubstringForProposedRange(_:actualRange)andfirstRectForCharacterRange(_:actualRange).insertText("하", replacementRange: NSRange(NotFound, 0))is called even we are not done yet... So our view thinks we finalized the input of하.attributedSubstringForProposedRange(_:actualRange)is called multiple times to get the Hangul syllable to replace with Hanja. The proposed range can be very different in each call.- Only if the range from
selectedRange()could be somehow consistently used inattributedSubstringForProposedRange(_:actualRange), then the Hanja popup is displayed. Otherwise we get the selectorinsertNewlineIgnoringFieldEditorindoCommandBySelector(). setMarkedText("下" , selectedRange: NSRange(1, 0), replacementRange: NSRange(1, 1))is called: the replacement range is notNotFoundwhich means that we first have to delete the text in the given range, in this case the finalized하and then append the marked text.- Selecting different Hanja calls the usual
setMarkedText(_:selectedRange:actualRange)andReturnfinalizes the input of河.
Chinese Pinyin
suppose we want to enter 中国
- we should enter the pinyin
zhongguo, then<Space>to confirm it. - each char input triggers: setMarkedText, markedRange, firstRect, attributedSubstringForProposedRange
- finally setMarkedText("zhong guo", selectedRange: NSRange(10, 0), replacementRange: NSRange(NotFound, 0)) iscalled:
- then after
<Space>enter, insertText("中国", replacementRange: NSRange(NotFound, 0)) is called - many selectedRange and attributedSubstring(forProposedRange:actualRange:) calls.
this seems right simple. but when in markedtext state(before confirming it),
- we can use number to select other candidates
- we can use
=,-, ``,<DOWN>,<Left>,<Right>to choose candidate, and vim shouldn't handle it. - we can use
<Left>,<Right>to move in marked text, and insert char in middle of markedText. even complicate, the move is not by char, but by word.
each marked text or marked cursor changes, setMarkedText will called, with selectedRange point to the marked cursor position(may be middle, not the text end)
so these key shouldn't be handle by vim directly when in marked text state.
and finally we confirmed all markedtext, then a insertText will be called
Other Writing System
Not a clue, since I only know Latin alphabet and Korean (+Hanja)...