├── .editorconfig
├── .gitignore
├── .idea
├── .gitignore
├── .name
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
├── gradle.xml
├── kotlinc.xml
└── vcs.xml
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
├── main
├── kotlin
│ └── com
│ │ └── ugarosa
│ │ └── neovim
│ │ ├── adapter
│ │ ├── coordinator
│ │ │ └── BufferCoordinator.kt
│ │ ├── idea
│ │ │ ├── action
│ │ │ │ ├── ActionAdapter.kt
│ │ │ │ └── NvimEscapeAction.kt
│ │ │ ├── editor
│ │ │ │ ├── CaretPositionSyncAdapter.kt
│ │ │ │ ├── CaretShapeSyncAdapter.kt
│ │ │ │ ├── DocumentSyncAdapter.kt
│ │ │ │ ├── IdeaCaretListener.kt
│ │ │ │ ├── IdeaDocumentListener.kt
│ │ │ │ └── SelectionSyncAdapter.kt
│ │ │ ├── input
│ │ │ │ ├── dispatcher
│ │ │ │ │ └── NvimKeyEventDispatcher.kt
│ │ │ │ ├── notation
│ │ │ │ │ ├── NvimKeyNotation.kt
│ │ │ │ │ └── SupportedKey.kt
│ │ │ │ └── router
│ │ │ │ │ └── NvimKeyRouter.kt
│ │ │ ├── lifecycle
│ │ │ │ ├── AppInitializer.kt
│ │ │ │ ├── IdeaBufferCoordinatorRegistry.kt
│ │ │ │ ├── NvimAppLifecycleListener.kt
│ │ │ │ ├── NvimProjectActivity.kt
│ │ │ │ └── ProjectLifecycleRegistry.kt
│ │ │ ├── ui
│ │ │ │ ├── BaseEditorViewer.kt
│ │ │ │ ├── cmdline
│ │ │ │ │ ├── CmdlineEvent.kt
│ │ │ │ │ ├── CmdlineView.kt
│ │ │ │ │ └── NvimCmdlineManager.kt
│ │ │ │ ├── message
│ │ │ │ │ ├── MessageEvent.kt
│ │ │ │ │ ├── MessageHistoryView.kt
│ │ │ │ │ ├── MessageKind.kt
│ │ │ │ │ ├── MessageLiveView.kt
│ │ │ │ │ ├── NvimMessageManager.kt
│ │ │ │ │ ├── NvimMessageToolWindowFactory.kt
│ │ │ │ │ └── OverlayIcon.kt
│ │ │ │ └── statusline
│ │ │ │ │ ├── NvimModeWidget.kt
│ │ │ │ │ └── NvimModeWidgetFactory.kt
│ │ │ └── undo
│ │ │ │ ├── DocumentUndoListener.kt
│ │ │ │ └── NvimUndoManager.kt
│ │ └── nvim
│ │ │ ├── incoming
│ │ │ ├── ActionAdapter.kt
│ │ │ ├── BufLinesAdapter.kt
│ │ │ ├── CursorAdapter.kt
│ │ │ ├── IncomingEventsRegistry.kt
│ │ │ ├── ModeAdapter.kt
│ │ │ ├── OptionAdapter.kt
│ │ │ ├── VisualSelectionAdapter.kt
│ │ │ └── redraw
│ │ │ │ ├── CmdlineHandler.kt
│ │ │ │ ├── HighlightHandler.kt
│ │ │ │ ├── MessageHandler.kt
│ │ │ │ └── RedrawAdapter.kt
│ │ │ └── outgoing
│ │ │ ├── CursorCommandAdapter.kt
│ │ │ └── DocumentCommandAdapter.kt
│ │ ├── bus
│ │ ├── IdeaToNvimBus.kt
│ │ ├── IdeaToNvimEvent.kt
│ │ ├── NvimToIdeaBus.kt
│ │ └── NvimToIdeaEvent.kt
│ │ ├── common
│ │ ├── FocusUtil.kt
│ │ ├── FontSize.kt
│ │ └── GridSize.kt
│ │ ├── config
│ │ ├── NvimOption.kt
│ │ ├── idea
│ │ │ ├── NvimKeymapConfigurable.kt
│ │ │ ├── NvimKeymapSettings.kt
│ │ │ └── UserKeyMapping.kt
│ │ └── nvim
│ │ │ ├── NvimGlobalOptionsManager.kt
│ │ │ ├── NvimLocalOptionsManager.kt
│ │ │ ├── NvimOptionManager.kt
│ │ │ └── option
│ │ │ ├── GlobalOnlyOptions.kt
│ │ │ ├── LocalOnlyOptions.kt
│ │ │ ├── RawOptionParser.kt
│ │ │ └── SharedOptions.kt
│ │ ├── domain
│ │ ├── buffer
│ │ │ └── RepeatableChange.kt
│ │ ├── highlight
│ │ │ ├── HighlightAttribute.kt
│ │ │ └── NvimHighlightManager.kt
│ │ ├── id
│ │ │ ├── BufferId.kt
│ │ │ ├── TabpageId.kt
│ │ │ └── WindowId.kt
│ │ ├── mode
│ │ │ ├── NvimMode.kt
│ │ │ └── NvimModeManager.kt
│ │ └── position
│ │ │ ├── NvimPosition.kt
│ │ │ ├── NvimRegion.kt
│ │ │ └── Utf8OffsetConverter.kt
│ │ ├── logger
│ │ ├── MyLogger.kt
│ │ └── MyLoggerChannel.kt
│ │ └── rpc
│ │ ├── client
│ │ ├── NvimClient.kt
│ │ └── api
│ │ │ ├── Buffer.kt
│ │ │ ├── Editor.kt
│ │ │ ├── Hook.kt
│ │ │ ├── Option.kt
│ │ │ ├── UI.kt
│ │ │ └── Variable.kt
│ │ ├── connection
│ │ └── NvimConnectionManager.kt
│ │ ├── event
│ │ └── NvimEventDispatcher.kt
│ │ ├── process
│ │ └── NvimProcessManager.kt
│ │ └── transport
│ │ ├── NvimObject.kt
│ │ ├── NvimTransport.kt
│ │ ├── RpcMessage.kt
│ │ └── ValueConverter.kt
└── resources
│ ├── META-INF
│ ├── plugin.xml
│ └── pluginIcon.svg
│ ├── icons
│ ├── green-circle.svg
│ ├── neovim-message@20x20.svg
│ └── neovim-message@20x20_dark.svg
│ ├── messages
│ └── NeovimBundle.properties
│ └── runtime
│ ├── lua
│ └── intellij
│ │ ├── buffer.lua
│ │ ├── hook.lua
│ │ ├── option.lua
│ │ └── window.lua
│ └── plugin
│ └── intellij.lua
└── test
└── kotlin
└── com
└── ugarosa
└── neovim
└── domain
└── buffer
└── RepeatableChange.kt
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_standard_property-naming = disabled
3 | ktlint_standard_filename = disabled
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | build/
3 | !gradle/wrapper/gradle-wrapper.jar
4 | !**/src/main/**/build/
5 | !**/src/test/**/build/
6 | .kotlin/
7 |
8 | ### IntelliJ IDEA ###
9 | .idea/modules.xml
10 | .idea/jarRepositories.xml
11 | .idea/compiler.xml
12 | .idea/libraries/
13 | .idea/misc.xml
14 | .idea/runConfigurations
15 | *.iws
16 | *.iml
17 | *.ipr
18 | out/
19 | !**/src/main/**/out/
20 | !**/src/test/**/out/
21 | .intellijPlatform/
22 |
23 | ### Eclipse ###
24 | .apt_generated
25 | .classpath
26 | .factorypath
27 | .project
28 | .settings
29 | .springBeans
30 | .sts4-cache
31 | bin/
32 | !**/src/main/**/bin/
33 | !**/src/test/**/bin/
34 |
35 | ### NetBeans ###
36 | /nbproject/private/
37 | /nbbuild/
38 | /dist/
39 | /nbdist/
40 | /.nb-gradle/
41 |
42 | ### VS Code ###
43 | .vscode/
44 |
45 | ### Mac OS ###
46 | .DS_Store
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Environment-dependent path to Maven home directory
7 | /mavenHomeManager.xml
8 | # Datasource local storage ignored files
9 | /dataSources/
10 | /dataSources.local.xml
11 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | idea-neovim
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
15 |
16 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask
2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
3 |
4 | plugins {
5 | id("java")
6 | alias(libs.plugins.kotlin)
7 | alias(libs.plugins.intellij.platform)
8 | alias(libs.plugins.ktlint)
9 | }
10 |
11 | group = "com.ugarosa"
12 | version = "1.0-SNAPSHOT"
13 |
14 | repositories {
15 | mavenCentral()
16 | intellijPlatform {
17 | defaultRepositories()
18 | }
19 | }
20 |
21 | // Configure Gradle IntelliJ Plugin
22 | // Read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin.html
23 | dependencies {
24 | implementation(libs.msgpack)
25 |
26 | intellijPlatform {
27 | create("IC", "2025.1")
28 | testFramework(org.jetbrains.intellij.platform.gradle.TestFrameworkType.Platform)
29 |
30 | // Add necessary plugin dependencies for compilation here, example:
31 | bundledPlugin("com.intellij.java")
32 | }
33 |
34 | testImplementation(libs.bundles.test)
35 | }
36 |
37 | intellijPlatform {
38 | pluginConfiguration {
39 | ideaVersion {
40 | sinceBuild = "251"
41 | }
42 |
43 | changeNotes =
44 | """
45 | Initial version
46 | """.trimIndent()
47 | }
48 | }
49 |
50 | tasks {
51 | // Set the JVM compatibility versions
52 | withType {
53 | sourceCompatibility = "21"
54 | targetCompatibility = "21"
55 | }
56 | withType {
57 | compilerOptions {
58 | jvmTarget.set(JvmTarget.JVM_21)
59 | }
60 | }
61 | }
62 |
63 | tasks.named("runIde") {
64 | systemProperties["idea.log.trace.categories"] = "#com.ugarosa.neovim"
65 | systemProperties["intellij.platform.log.sync"] = true
66 | }
67 |
68 | tasks.prepareSandbox {
69 | from("src/main/resources/runtime") {
70 | into("${rootProject.name}/runtime")
71 | }
72 | }
73 |
74 | tasks.test {
75 | useJUnitPlatform()
76 | }
77 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib
2 | kotlin.stdlib.default.dependency = false
3 |
4 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html
5 | org.gradle.configuration-cache = true
6 |
7 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html
8 | org.gradle.caching = true
9 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | kotlin = "2.1.20"
3 | intellij-platform = "2.5.0"
4 | ktlint = "12.2.0"
5 | msgpack = "0.9.9"
6 | junit = "5.12.2"
7 | kotest = "5.9.1"
8 | mockk = "1.14.2"
9 |
10 | [plugins]
11 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
12 | intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-platform" }
13 | ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
14 |
15 | [libraries]
16 | msgpack = { module = "org.msgpack:msgpack-core", version.ref = "msgpack" }
17 | # tests
18 | junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
19 | junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit" }
20 | kotest = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
21 | mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
22 |
23 | [bundles]
24 | test = ["junit-jupiter", "junit-vintage", "kotest", "mockk"]
25 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/uga-rosa/idea-neovim/229f470164cd15440c14068f7810f89e5e032790/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Use "xargs" to parse quoted args.
209 | #
210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
211 | #
212 | # In Bash we could simply go:
213 | #
214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
215 | # set -- "${ARGS[@]}" "$@"
216 | #
217 | # but POSIX shell has neither arrays nor command substitution, so instead we
218 | # post-process each arg (as a line of input to sed) to backslash-escape any
219 | # character that might be a shell metacharacter, then use eval to reverse
220 | # that process (while maintaining the separation between arguments), and wrap
221 | # the whole thing up as a single "set" statement.
222 | #
223 | # This will of course break if any of these variables contains a newline or
224 | # an unmatched quote.
225 | #
226 |
227 | eval "set -- $(
228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
229 | xargs -n1 |
230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
231 | tr '\n' ' '
232 | )" '"$@"'
233 |
234 | exec "$JAVACMD" "$@"
235 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "idea-neovim"
2 |
3 | dependencyResolutionManagement {
4 | versionCatalogs {
5 | create("libs")
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/action/ActionAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.action
2 |
3 | import com.intellij.openapi.actionSystem.ActionManager
4 | import com.intellij.openapi.application.EDT
5 | import com.ugarosa.neovim.common.focusEditor
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.withContext
8 |
9 | suspend fun executeAction(actionId: String) {
10 | val anAction = ActionManager.getInstance().getAction(actionId) ?: return
11 | val editor = focusEditor()
12 |
13 | withContext(Dispatchers.EDT) {
14 | ActionManager.getInstance().tryToExecute(
15 | anAction,
16 | null,
17 | editor?.component,
18 | "IdeaNeovim",
19 | true,
20 | ).waitFor(5_000)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/action/NvimEscapeAction.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.action
2 |
3 | import com.intellij.openapi.actionSystem.AnAction
4 | import com.intellij.openapi.actionSystem.AnActionEvent
5 | import com.intellij.openapi.components.service
6 | import com.ugarosa.neovim.bus.EscapeInsert
7 | import com.ugarosa.neovim.bus.IdeaToNvimBus
8 | import com.ugarosa.neovim.common.unsafeFocusEditor
9 | import org.intellij.lang.annotations.Language
10 |
11 | class NvimEscapeAction : AnAction() {
12 | override fun actionPerformed(p0: AnActionEvent) {
13 | val editor = unsafeFocusEditor() ?: return
14 | val event = EscapeInsert(editor)
15 | service().tryEmit(event)
16 | }
17 |
18 | companion object {
19 | @Language("devkit-action-id")
20 | const val ACTION_ID = "NvimEscapeAction"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/editor/CaretShapeSyncAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.editor
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.components.service
5 | import com.intellij.openapi.editor.ex.EditorEx
6 | import com.ugarosa.neovim.config.nvim.NvimOptionManager
7 | import com.ugarosa.neovim.domain.id.BufferId
8 | import com.ugarosa.neovim.domain.mode.NvimMode
9 | import kotlinx.coroutines.Dispatchers
10 | import kotlinx.coroutines.withContext
11 |
12 | class CaretShapeSyncAdapter(
13 | private val editor: EditorEx,
14 | ) {
15 | private val optionsManager = service()
16 |
17 | suspend fun apply(
18 | bufferId: BufferId,
19 | mode: NvimMode,
20 | ) {
21 | val option = optionsManager.getLocal(bufferId)
22 | withContext(Dispatchers.EDT) {
23 | if (mode.isCommand()) {
24 | changeCaretVisible(false)
25 | } else {
26 | changeCaretVisible(true)
27 | }
28 | editor.settings.isBlockCursor = mode.isBlock(option.selection)
29 | }
30 | }
31 |
32 | private fun changeCaretVisible(isVisible: Boolean) {
33 | editor.setCaretVisible(isVisible)
34 | editor.setCaretEnabled(isVisible)
35 | editor.contentComponent.repaint()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/editor/DocumentSyncAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.editor
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.command.WriteCommandAction
5 | import com.intellij.openapi.editor.ex.EditorEx
6 | import com.ugarosa.neovim.bus.NvimBufLines
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.withContext
9 |
10 | class DocumentSyncAdapter(
11 | private val editor: EditorEx,
12 | private val documentListener: IdeaDocumentListener,
13 | ) {
14 | private val doc = editor.document
15 |
16 | suspend fun apply(events: List) {
17 | withContext(Dispatchers.EDT) {
18 | WriteCommandAction.writeCommandAction(editor.project)
19 | .run {
20 | documentListener.disable()
21 | events.forEach { applyEvent(it) }
22 | documentListener.enable()
23 | }
24 | }
25 | }
26 |
27 | private fun applyEvent(e: NvimBufLines) {
28 | val totalLines = doc.lineCount
29 |
30 | // Compute start/end offsets and flag for trailing newline
31 | val (startOffset, endOffset, addTrailingNewline) =
32 | when {
33 | // 1) Append at end of document
34 | e.firstLine >= totalLines ->
35 | Triple(doc.textLength, doc.textLength, false)
36 |
37 | // 2) Replacement range includes end of document
38 | e.lastLine == -1 || e.lastLine >= totalLines -> {
39 | val start = (doc.getLineStartOffset(e.firstLine) - 1).coerceAtLeast(0)
40 | Triple(start, doc.textLength, false)
41 | }
42 |
43 | // 3) Range within document
44 | else -> {
45 | val start = doc.getLineStartOffset(e.firstLine)
46 | val end = doc.getLineStartOffset(e.lastLine)
47 | Triple(start, end, true)
48 | }
49 | }
50 |
51 | val replacementText =
52 | if (e.replacementLines.isEmpty()) {
53 | ""
54 | } else {
55 | buildString {
56 | if (!addTrailingNewline) append("\n")
57 | append(e.replacementLines.joinToString("\n"))
58 | if (addTrailingNewline) append("\n")
59 | }
60 | }
61 |
62 | doc.replaceString(startOffset, endOffset, replacementText)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/editor/IdeaCaretListener.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.editor
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.service
5 | import com.intellij.openapi.editor.event.CaretEvent
6 | import com.intellij.openapi.editor.event.CaretListener
7 | import com.intellij.openapi.editor.ex.EditorEx
8 | import com.ugarosa.neovim.bus.IdeaCaretMoved
9 | import com.ugarosa.neovim.bus.IdeaToNvimBus
10 | import com.ugarosa.neovim.domain.id.BufferId
11 | import com.ugarosa.neovim.domain.position.NvimPosition
12 |
13 | class IdeaCaretListener(
14 | private val bufferId: BufferId,
15 | private val editor: EditorEx,
16 | ) : CaretListener, Disposable {
17 | private val bus = service()
18 | private var isEnabled = false
19 |
20 | fun enable() {
21 | if (isEnabled) return
22 | isEnabled = true
23 | editor.caretModel.addCaretListener(this, this)
24 | }
25 |
26 | fun disable() {
27 | if (!isEnabled) return
28 | isEnabled = false
29 | editor.caretModel.removeCaretListener(this)
30 | }
31 |
32 | override fun caretPositionChanged(event: CaretEvent) {
33 | val editor = event.editor
34 | val offset = editor.caretModel.offset
35 | val pos = NvimPosition.fromOffset(offset, editor.document)
36 | bus.tryEmit(IdeaCaretMoved(bufferId, pos, offset))
37 | }
38 |
39 | override fun dispose() {
40 | disable()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/editor/IdeaDocumentListener.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.editor
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.service
5 | import com.intellij.openapi.editor.event.DocumentEvent
6 | import com.intellij.openapi.editor.event.DocumentListener
7 | import com.intellij.openapi.editor.ex.EditorEx
8 | import com.ugarosa.neovim.bus.IdeaDocumentChange
9 | import com.ugarosa.neovim.bus.IdeaToNvimBus
10 | import com.ugarosa.neovim.domain.id.BufferId
11 |
12 | class IdeaDocumentListener(
13 | private val bufferId: BufferId,
14 | private val editor: EditorEx,
15 | ) : DocumentListener, Disposable {
16 | private val bus = service()
17 | private var isEnabled = false
18 |
19 | fun enable() {
20 | if (isEnabled) return
21 | isEnabled = true
22 | editor.document.addDocumentListener(this, this)
23 | }
24 |
25 | fun disable() {
26 | if (!isEnabled) return
27 | isEnabled = false
28 | editor.document.removeDocumentListener(this)
29 | }
30 |
31 | override fun beforeDocumentChange(event: DocumentEvent) {
32 | val change =
33 | IdeaDocumentChange(
34 | bufferId = bufferId,
35 | offset = event.offset,
36 | oldLen = event.oldLength,
37 | newText = event.newFragment.toString().replace("\r\n", "\n"),
38 | caret = editor.caretModel.offset,
39 | )
40 | bus.tryEmit(change)
41 | }
42 |
43 | override fun dispose() {
44 | disable()
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/editor/SelectionSyncAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.editor
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.editor.colors.EditorColors
5 | import com.intellij.openapi.editor.colors.EditorColorsManager
6 | import com.intellij.openapi.editor.ex.EditorEx
7 | import com.intellij.openapi.editor.markup.HighlighterLayer
8 | import com.intellij.openapi.editor.markup.HighlighterTargetArea
9 | import com.intellij.openapi.editor.markup.RangeHighlighter
10 | import com.intellij.openapi.editor.markup.TextAttributes
11 | import com.ugarosa.neovim.bus.VisualSelectionChanged
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.withContext
14 |
15 | class SelectionSyncAdapter(
16 | private val editor: EditorEx,
17 | ) {
18 | private val attributes =
19 | TextAttributes().apply {
20 | val globalScheme = EditorColorsManager.getInstance().globalScheme
21 | val selectionColor = globalScheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR)
22 | backgroundColor = selectionColor
23 | }
24 | private val highlighters = mutableListOf()
25 |
26 | suspend fun apply(event: VisualSelectionChanged) {
27 | val offsets =
28 | event.regions.map { region ->
29 | val startOffset = region.startOffset(editor.document)
30 | val endOffset = region.endOffset(editor.document)
31 | startOffset to endOffset
32 | }
33 |
34 | withContext(Dispatchers.EDT) {
35 | reset()
36 |
37 | offsets.forEach { (startOffset, endOffset) ->
38 | val highlighter =
39 | editor.markupModel.addRangeHighlighter(
40 | startOffset,
41 | endOffset,
42 | HighlighterLayer.SELECTION,
43 | attributes,
44 | HighlighterTargetArea.EXACT_RANGE,
45 | )
46 | highlighters.add(highlighter)
47 | }
48 | }
49 | }
50 |
51 | fun reset() {
52 | highlighters.forEach { editor.markupModel.removeHighlighter(it) }
53 | highlighters.clear()
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/input/dispatcher/NvimKeyEventDispatcher.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.input.dispatcher
2 |
3 | import com.intellij.ide.IdeEventQueue
4 | import com.ugarosa.neovim.adapter.idea.input.notation.NeovimKeyNotation
5 | import com.ugarosa.neovim.adapter.idea.input.notation.printableVKs
6 | import com.ugarosa.neovim.adapter.idea.input.router.NvimKeyRouter
7 | import com.ugarosa.neovim.common.unsafeFocusEditor
8 | import com.ugarosa.neovim.logger.myLogger
9 | import java.awt.AWTEvent
10 | import java.awt.event.KeyEvent
11 |
12 | class NvimKeyEventDispatcher(
13 | private val keyRouter: NvimKeyRouter,
14 | ) : IdeEventQueue.EventDispatcher {
15 | private val logger = myLogger()
16 |
17 | override fun dispatch(e: AWTEvent): Boolean {
18 | if (e is KeyEvent) {
19 | return hijackKeyEvent(e)
20 | }
21 |
22 | return false
23 | }
24 |
25 | // Store the last modifiers for KEY_TYPED events
26 | private var lastMods = 0
27 |
28 | // Hijack all key events
29 | private fun hijackKeyEvent(e: KeyEvent): Boolean {
30 | logger.trace("Received key event: $e")
31 |
32 | // Ignore events not from the editor
33 | val editor = unsafeFocusEditor() ?: return false
34 |
35 | when (e.id) {
36 | KeyEvent.KEY_PRESSED -> {
37 | val mods = e.modifiersEx
38 | lastMods = mods
39 |
40 | // Pure characters or SHIFT + character go to KEY_TYPED
41 | val onlyShift = mods == KeyEvent.SHIFT_DOWN_MASK
42 | if (e.keyCode in printableVKs && (mods == 0 || onlyShift)) {
43 | return false
44 | }
45 |
46 | NeovimKeyNotation.fromKeyPressedEvent(e)?.also {
47 | logger.debug("Pressed key: $it")
48 | return keyRouter.enqueueKey(it, editor)
49 | }
50 | // Fallback to default behavior if not supported key
51 | return false
52 | }
53 |
54 | KeyEvent.KEY_TYPED -> {
55 | val c = e.keyChar
56 | val mods = lastMods
57 |
58 | // CTRL/ALT/META + character are not handled by KEY_TYPED
59 | if (mods and (KeyEvent.CTRL_DOWN_MASK or KeyEvent.ALT_DOWN_MASK or KeyEvent.META_DOWN_MASK) != 0) {
60 | logger.trace("Ignore KEY_TYPED event with modifiers: $e")
61 | return true
62 | }
63 |
64 | // Special case for space
65 | if (c == ' ') {
66 | val notation = NeovimKeyNotation.fromModsAndKey(mods, "Space")
67 | logger.debug("Typed Space key: $notation")
68 | return keyRouter.enqueueKey(notation, editor)
69 | }
70 |
71 | NeovimKeyNotation.fromKeyTypedEvent(e)?.also {
72 | logger.debug("Typed key: $it")
73 | return keyRouter.enqueueKey(it, editor)
74 | }
75 | return true
76 | }
77 |
78 | else -> {
79 | return false
80 | }
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/input/notation/NvimKeyNotation.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.input.notation
2 |
3 | import com.intellij.util.xmlb.annotations.Attribute
4 | import com.intellij.util.xmlb.annotations.XCollection
5 | import com.ugarosa.neovim.logger.myLogger
6 | import java.awt.event.KeyEvent
7 |
8 | enum class NeovimKeyModifier(val neovimPrefix: String) {
9 | CTRL("C"),
10 | SHIFT("S"),
11 | ALT("A"),
12 | META("M"),
13 | ;
14 |
15 | companion object {
16 | private val map = entries.associateBy { it.neovimPrefix }
17 |
18 | fun fromString(prefix: String): NeovimKeyModifier? = map[prefix]
19 | }
20 | }
21 |
22 | data class NeovimKeyNotation(
23 | @XCollection(
24 | propertyElementName = "modifiers",
25 | elementName = "modifier",
26 | )
27 | val modifiers: List,
28 | @Attribute
29 | val key: String,
30 | ) {
31 | @Suppress("unused")
32 | constructor() : this(emptyList(), "")
33 |
34 | companion object {
35 | private val logger = myLogger()
36 | private val keyCodeToNeovim: Map =
37 | supportedKeys.associate { it.awtKeyCode to it.neovimName }
38 |
39 | fun fromKeyPressedEvent(event: KeyEvent): NeovimKeyNotation? {
40 | check(event.id == KeyEvent.KEY_PRESSED) { "Not a KEY_PRESSED event: $event" }
41 |
42 | val modifiers =
43 | mutableListOf().apply {
44 | if (event.isControlDown) add(NeovimKeyModifier.CTRL)
45 | if (event.isShiftDown) add(NeovimKeyModifier.SHIFT)
46 | if (event.isAltDown) add(NeovimKeyModifier.ALT)
47 | if (event.isMetaDown) add(NeovimKeyModifier.META)
48 | }
49 | val key =
50 | keyCodeToNeovim[event.keyCode]
51 | ?: run {
52 | logger.trace("Not a supported key event: $event")
53 | return null
54 | }
55 | return NeovimKeyNotation(modifiers, key)
56 | }
57 |
58 | fun fromKeyTypedEvent(event: KeyEvent): NeovimKeyNotation? {
59 | check(event.id == KeyEvent.KEY_TYPED) { "Not a KEY_TYPED event: $event" }
60 |
61 | val c = event.keyChar
62 | if (c == KeyEvent.CHAR_UNDEFINED || c.isISOControl()) {
63 | logger.trace("Not a printable character: $event")
64 | return null
65 | }
66 | val key =
67 | when (val s = c.toString()) {
68 | "<" -> ""
69 | else -> s
70 | }
71 | return NeovimKeyNotation(emptyList(), key)
72 | }
73 |
74 | fun fromModsAndKey(
75 | mods: Int,
76 | key: String,
77 | ): NeovimKeyNotation {
78 | val modifiers =
79 | buildList {
80 | if (mods and KeyEvent.CTRL_DOWN_MASK != 0) add(NeovimKeyModifier.CTRL)
81 | if (mods and KeyEvent.SHIFT_DOWN_MASK != 0) add(NeovimKeyModifier.SHIFT)
82 | if (mods and KeyEvent.ALT_DOWN_MASK != 0) add(NeovimKeyModifier.ALT)
83 | if (mods and KeyEvent.META_DOWN_MASK != 0) add(NeovimKeyModifier.META)
84 | }
85 | return NeovimKeyNotation(modifiers, key)
86 | }
87 |
88 | // This regex will match:
89 | // 1. (Yyy)
90 | // 2.
91 | // 3. Any single character
92 | private val regex = Regex("""<[^>]+>\([^)]+\)|<[^>]+>|.""")
93 |
94 | fun parseNotations(notations: String): List {
95 | return regex.findAll(notations)
96 | .mapNotNull { mr -> parseSingleNotation(mr.value) }
97 | .toList()
98 | }
99 |
100 | private fun parseSingleNotation(notation: String): NeovimKeyNotation? {
101 | val text = notation.trim()
102 | // may have modifiers like
103 | if (text.startsWith("<") && text.endsWith(">")) {
104 | // Special handing for
105 | if (text.equals("", ignoreCase = true)) {
106 | return NeovimKeyNotation(emptyList(), "Nop")
107 | }
108 |
109 | val inner = text.substring(1, text.length - 1)
110 | val parts = inner.split("-")
111 |
112 | val rawKey = parts.last()
113 | val key =
114 | supportedKeys.firstOrNull { it.neovimName.equals(rawKey, ignoreCase = true) }
115 | ?.neovimName
116 | ?: run {
117 | logger.warn("Unknown key: $rawKey")
118 | return null
119 | }
120 |
121 | val mods =
122 | parts.dropLast(1).map { token ->
123 | NeovimKeyModifier.fromString(token)
124 | ?: run {
125 | logger.warn("Unknown modifier: $token")
126 | return null
127 | }
128 | }
129 | return NeovimKeyNotation(mods, key)
130 | } else {
131 | // (Yyy) or any single character
132 | return NeovimKeyNotation(emptyList(), text)
133 | }
134 | }
135 | }
136 |
137 | fun toPrintableChar(): Char? {
138 | if (modifiers.isNotEmpty()) {
139 | logger.debug("Cannot convert to printable char: $this")
140 | return null
141 | }
142 | if (key.length != 1) {
143 | logger.debug("Cannot convert to printable char: $this")
144 | return null
145 | }
146 | return key[0]
147 | }
148 |
149 | /**
150 | * Render as Neovim notation string.
151 | * Examples: "", "g", "", "(FooBar)"
152 | */
153 | override fun toString(): String {
154 | return when {
155 | modifiers.isNotEmpty() -> "<${modifiers.joinToString("-") { it.neovimPrefix }}-$key>"
156 |
157 | key.length == 1 -> key
158 |
159 | key.startsWith("<") -> key
160 |
161 | else -> "<$key>"
162 | }
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/input/notation/SupportedKey.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.input.notation
2 |
3 | import com.ugarosa.neovim.logger.MyLogger
4 | import java.awt.event.KeyEvent
5 |
6 | private val logger = MyLogger.getInstance("com.ugarosa.neovim.keymap.notation.SupportedKey")
7 |
8 | /**
9 | * Supported key mapping and printability in a single source-of-truth list.
10 | */
11 | data class SupportedKey(
12 | val awtName: String,
13 | val neovimName: String,
14 | // Whether this key is considered a printable character (i.e. should be passed through to KEY_TYPED)
15 | val printable: Boolean,
16 | ) {
17 | val awtKeyCode: Int =
18 | try {
19 | KeyEvent::class.java.getField("VK_$awtName").getInt(null)
20 | } catch (e: Exception) {
21 | logger.warn("Failed to get AWT key code for $awtName", e)
22 | -1
23 | }
24 | }
25 |
26 | // Single source of truth for all supported keys:
27 | val supportedKeys: List =
28 | buildList {
29 | // Letters A–Z
30 | addAll(('A'..'Z').map { SupportedKey(it.toString(), it.toString(), printable = true) })
31 | // Digits 0–9
32 | addAll(('0'..'9').map { SupportedKey(it.toString(), it.toString(), printable = true) })
33 | // Function keys F1–F12 (non-printable)
34 | addAll((1..12).map { SupportedKey("F$it", "F$it", printable = false) })
35 | // Numpad keys (digits & operators)
36 | addAll((0..9).map { SupportedKey("NUMPAD$it", "k$it", printable = true) })
37 | addAll(
38 | listOf(
39 | SupportedKey("DECIMAL", "kPoint", printable = true),
40 | SupportedKey("ADD", "kPlus", printable = true),
41 | SupportedKey("SUBTRACT", "kMinus", printable = true),
42 | SupportedKey("MULTIPLY", "kMultiply", printable = true),
43 | SupportedKey("DIVIDE", "kDivide", printable = true),
44 | SupportedKey("SEPARATOR", "kComma", printable = true),
45 | ),
46 | )
47 | // Common punctuation and symbols
48 | addAll(
49 | listOf(
50 | SupportedKey("MINUS", "-", printable = true),
51 | SupportedKey("EQUALS", "=", printable = true),
52 | SupportedKey("BACK_SLASH", "\\", printable = true),
53 | SupportedKey("BACK_QUOTE", "`", printable = true),
54 | SupportedKey("OPEN_BRACKET", "[", printable = true),
55 | SupportedKey("CLOSE_BRACKET", "]", printable = true),
56 | SupportedKey("SEMICOLON", ";", printable = true),
57 | SupportedKey("QUOTE", "'", printable = true),
58 | SupportedKey("COMMA", ",", printable = true),
59 | SupportedKey("PERIOD", ".", printable = true),
60 | SupportedKey("SLASH", "/", printable = true),
61 | SupportedKey("SPACE", "Space", printable = true),
62 | ),
63 | )
64 | // Shift‐modified symbols
65 | addAll(
66 | listOf(
67 | SupportedKey("EXCLAMATION_MARK", "!", printable = true),
68 | SupportedKey("AT", "@", printable = true),
69 | SupportedKey("NUMBER_SIGN", "#", printable = true),
70 | SupportedKey("DOLLAR", "$", printable = true),
71 | SupportedKey("CIRCUMFLEX", "^", printable = true),
72 | SupportedKey("AMPERSAND", "&", printable = true),
73 | SupportedKey("ASTERISK", "*", printable = true),
74 | SupportedKey("LEFT_PARENTHESIS", "(", printable = true),
75 | SupportedKey("RIGHT_PARENTHESIS", ")", printable = true),
76 | SupportedKey("UNDERSCORE", "_", printable = true),
77 | SupportedKey("PLUS", "+", printable = true),
78 | SupportedKey("BRACELEFT", "{", printable = true),
79 | SupportedKey("BRACERIGHT", "}", printable = true),
80 | SupportedKey("COLON", ":", printable = true),
81 | SupportedKey("QUOTEDBL", "\"", printable = true),
82 | SupportedKey("LESS", "", printable = true),
83 | SupportedKey("GREATER", ">", printable = true),
84 | ),
85 | )
86 | // Other common keys (non-printable)
87 | addAll(
88 | listOf(
89 | SupportedKey("ENTER", "CR", printable = false),
90 | SupportedKey("BACK_SPACE", "BS", printable = false),
91 | SupportedKey("TAB", "Tab", printable = false),
92 | SupportedKey("ESCAPE", "Esc", printable = false),
93 | SupportedKey("DELETE", "Del", printable = false),
94 | SupportedKey("INSERT", "Ins", printable = false),
95 | SupportedKey("HOME", "Home", printable = false),
96 | SupportedKey("END", "End", printable = false),
97 | SupportedKey("PAGE_UP", "PageUp", printable = false),
98 | SupportedKey("PAGE_DOWN", "PageDown", printable = false),
99 | SupportedKey("UP", "Up", printable = false),
100 | SupportedKey("DOWN", "Down", printable = false),
101 | SupportedKey("LEFT", "Left", printable = false),
102 | SupportedKey("RIGHT", "Right", printable = false),
103 | ),
104 | )
105 | }
106 |
107 | // Derive the set of printable key codes when needed:
108 | val printableVKs: Set = supportedKeys.filter { it.printable }.map { it.awtKeyCode }.toSet()
109 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/input/router/NvimKeyRouter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.input.router
2 |
3 | import com.intellij.ide.IdeEventQueue
4 | import com.intellij.openapi.Disposable
5 | import com.intellij.openapi.application.runWriteAction
6 | import com.intellij.openapi.components.Service
7 | import com.intellij.openapi.components.service
8 | import com.intellij.openapi.editor.actionSystem.TypedAction
9 | import com.intellij.openapi.editor.ex.EditorEx
10 | import com.ugarosa.neovim.adapter.idea.action.executeAction
11 | import com.ugarosa.neovim.adapter.idea.input.dispatcher.NvimKeyEventDispatcher
12 | import com.ugarosa.neovim.adapter.idea.input.notation.NeovimKeyNotation
13 | import com.ugarosa.neovim.config.idea.KeyMappingAction
14 | import com.ugarosa.neovim.config.idea.NvimKeymapSettings
15 | import com.ugarosa.neovim.domain.mode.NvimMode
16 | import com.ugarosa.neovim.domain.mode.getMode
17 | import com.ugarosa.neovim.logger.myLogger
18 | import com.ugarosa.neovim.rpc.client.NvimClient
19 | import com.ugarosa.neovim.rpc.client.api.input
20 | import kotlinx.coroutines.CoroutineScope
21 | import kotlinx.coroutines.ObsoleteCoroutinesApi
22 | import kotlinx.coroutines.channels.Channel
23 | import kotlinx.coroutines.channels.actor
24 | import java.util.concurrent.ConcurrentLinkedDeque
25 |
26 | @Service(Service.Level.APP)
27 | class NvimKeyRouter(
28 | scope: CoroutineScope,
29 | ) : Disposable {
30 | private val logger = myLogger()
31 |
32 | private val dispatcher = NvimKeyEventDispatcher(this)
33 | private val client = service()
34 | private val settings = service()
35 |
36 | private val buffer = ConcurrentLinkedDeque()
37 |
38 | @OptIn(ObsoleteCoroutinesApi::class)
39 | private val actionQueue =
40 | scope.actor Unit>(capacity = Channel.UNLIMITED) {
41 | for (lambda in channel) {
42 | lambda()
43 | }
44 | }
45 |
46 | fun start() {
47 | IdeEventQueue.getInstance().addDispatcher(dispatcher, this)
48 | }
49 |
50 | private fun stop() {
51 | IdeEventQueue.getInstance().removeDispatcher(dispatcher)
52 | buffer.clear()
53 | }
54 |
55 | fun enqueueKey(
56 | key: NeovimKeyNotation,
57 | editor: EditorEx,
58 | ): Boolean {
59 | buffer.add(key)
60 | return processBuffer(editor)
61 | }
62 |
63 | private fun processBuffer(editor: EditorEx): Boolean {
64 | val mode = getMode()
65 | val snapshot = buffer.toList()
66 |
67 | logger.trace("Processing buffer: $snapshot in mode: $mode")
68 |
69 | val prefixMatches =
70 | settings.getUserKeyMappings().filter { (mapMode, lhs) ->
71 | mapMode.toModeKinds().contains(mode.kind) &&
72 | lhs.size >= snapshot.size &&
73 | lhs.take(snapshot.size) == snapshot
74 | }
75 | val exactlyMatch = prefixMatches.firstOrNull { it.lhs.size == snapshot.size }
76 |
77 | when {
78 | // No match found.
79 | prefixMatches.isEmpty() -> {
80 | buffer.clear()
81 | return fallback(snapshot, editor, mode)
82 | }
83 |
84 | // Exact match found.
85 | exactlyMatch != null && prefixMatches.size == 1 -> {
86 | buffer.clear()
87 | logger.trace("Executing exact match: $exactlyMatch in mode: $mode")
88 | executeRhs(exactlyMatch.rhs)
89 | return true
90 | }
91 |
92 | // Some prefix match found. Pending next input.
93 | else -> {
94 | logger.trace("Pending next input: $snapshot in mode: $mode")
95 | return true
96 | }
97 | }
98 | }
99 |
100 | private fun fallback(
101 | keys: List,
102 | editor: EditorEx,
103 | mode: NvimMode,
104 | ): Boolean {
105 | if (mode.isInsert()) {
106 | // Don't consume the key if the mode is insert-mode
107 | logger.trace("Fallback to IDEA: $keys")
108 | val printableChars =
109 | keys.dropLast(1)
110 | .mapNotNull { it.toPrintableChar() }
111 | if (printableChars.isNotEmpty()) {
112 | actionQueue.trySend {
113 | runWriteAction {
114 | printableChars.forEach { char ->
115 | TypedAction.getInstance().handler.execute(editor, char, editor.dataContext)
116 | }
117 | }
118 | }
119 | }
120 | return false
121 | } else {
122 | logger.trace("Fallback to Neovim: $keys")
123 | actionQueue.trySend {
124 | client.input(keys.joinToString(""))
125 | }
126 | return true
127 | }
128 | }
129 |
130 | private fun executeRhs(actions: List) {
131 | actions.forEach { action ->
132 | when (action) {
133 | is KeyMappingAction.SendToNeovim -> {
134 | logger.trace("Sending key to Neovim: ${action.key}")
135 | actionQueue.trySend {
136 | client.input(action.key.toString())
137 | }
138 | }
139 |
140 | is KeyMappingAction.ExecuteIdeaAction -> {
141 | logger.trace("Executing action: ${action.actionId}")
142 | actionQueue.trySend {
143 | executeAction(action.actionId)
144 | }
145 | }
146 | }
147 | }
148 | }
149 |
150 | override fun dispose() {
151 | stop()
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/lifecycle/AppInitializer.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.lifecycle
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.intellij.openapi.components.service
5 | import com.ugarosa.neovim.adapter.idea.input.router.NvimKeyRouter
6 | import com.ugarosa.neovim.config.nvim.NvimOptionManager
7 | import com.ugarosa.neovim.logger.myLogger
8 | import com.ugarosa.neovim.rpc.client.NvimClient
9 | import com.ugarosa.neovim.rpc.client.api.installHook
10 | import com.ugarosa.neovim.rpc.client.api.uiAttach
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.launch
13 |
14 | @Service(Service.Level.APP)
15 | class AppInitializer(
16 | scope: CoroutineScope,
17 | ) {
18 | private val logger = myLogger()
19 | private val client = service()
20 | private val optionManager = service()
21 | private val keyRouter = service()
22 |
23 | init {
24 | scope.launch {
25 | client.uiAttach()
26 | logger.debug("Attached UI")
27 |
28 | client.installHook()
29 | logger.debug("Installed autocmd hooks")
30 |
31 | optionManager.initializeGlobal()
32 | logger.debug("Initialized global options")
33 |
34 | keyRouter.start()
35 | logger.debug("Start Neovim key router")
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/lifecycle/IdeaBufferCoordinatorRegistry.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.lifecycle
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.editor.EditorFactory
7 | import com.intellij.openapi.editor.event.EditorFactoryEvent
8 | import com.intellij.openapi.editor.event.EditorFactoryListener
9 | import com.intellij.openapi.editor.ex.EditorEx
10 | import com.ugarosa.neovim.adapter.coordinator.BufferCoordinator
11 | import com.ugarosa.neovim.adapter.idea.ui.isCustomEditor
12 | import com.ugarosa.neovim.rpc.client.NvimClient
13 | import com.ugarosa.neovim.rpc.client.api.createBuffer
14 | import kotlinx.coroutines.CoroutineScope
15 | import kotlinx.coroutines.launch
16 | import java.util.concurrent.ConcurrentHashMap
17 |
18 | @Service(Service.Level.APP)
19 | class IdeaBufferCoordinatorRegistry(
20 | private val scope: CoroutineScope,
21 | ) : Disposable {
22 | private val client = service()
23 | private val bufferMap = ConcurrentHashMap()
24 |
25 | init {
26 | EditorFactory.getInstance().addEditorFactoryListener(
27 | object : EditorFactoryListener {
28 | override fun editorCreated(event: EditorFactoryEvent) {
29 | val editor = event.editor as? EditorEx ?: return
30 | if (isCustomEditor(editor)) return
31 | scope.launch { register(editor) }
32 | }
33 |
34 | override fun editorReleased(event: EditorFactoryEvent) {
35 | val editor = event.editor as? EditorEx ?: return
36 | bufferMap.remove(editor)?.dispose()
37 | }
38 | },
39 | this,
40 | )
41 | }
42 |
43 | private suspend fun register(editor: EditorEx) {
44 | val bufferId = client.createBuffer()
45 | val buffer = BufferCoordinator.getInstance(scope, bufferId, editor)
46 | bufferMap[editor] = buffer
47 | }
48 |
49 | override fun dispose() {
50 | bufferMap.values.forEach { it.dispose() }
51 | bufferMap.clear()
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/lifecycle/NvimAppLifecycleListener.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.lifecycle
2 |
3 | import com.intellij.ide.AppLifecycleListener
4 | import com.intellij.openapi.components.service
5 | import com.ugarosa.neovim.adapter.idea.undo.NvimUndoManager
6 | import com.ugarosa.neovim.adapter.nvim.incoming.IncomingEventsRegistry
7 |
8 | class NvimAppLifecycleListener : AppLifecycleListener {
9 | override fun appFrameCreated(commandLineArgs: List) {
10 | service()
11 | service()
12 | service()
13 | service()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/lifecycle/NvimProjectActivity.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.lifecycle
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.project.Project
5 | import com.intellij.openapi.startup.ProjectActivity
6 |
7 | class NvimProjectActivity : ProjectActivity {
8 | override suspend fun execute(project: Project) {
9 | project.service()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/lifecycle/ProjectLifecycleRegistry.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.lifecycle
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.editor.EditorFactory
7 | import com.intellij.openapi.editor.ex.EditorEx
8 | import com.intellij.openapi.fileEditor.FileDocumentManager
9 | import com.intellij.openapi.fileEditor.FileEditorManager
10 | import com.intellij.openapi.fileEditor.FileEditorManagerEvent
11 | import com.intellij.openapi.fileEditor.FileEditorManagerListener
12 | import com.intellij.openapi.fileEditor.TextEditor
13 | import com.intellij.openapi.project.Project
14 | import com.intellij.openapi.vfs.VirtualFile
15 | import com.intellij.openapi.vfs.VirtualFileManager
16 | import com.intellij.openapi.vfs.newvfs.BulkFileListener
17 | import com.intellij.openapi.vfs.newvfs.events.VFileEvent
18 | import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent
19 | import com.ugarosa.neovim.bus.ChangeModifiable
20 | import com.ugarosa.neovim.bus.EditorSelected
21 | import com.ugarosa.neovim.bus.IdeaToNvimBus
22 | import com.ugarosa.neovim.logger.myLogger
23 |
24 | @Service(Service.Level.PROJECT)
25 | class ProjectLifecycleRegistry(
26 | private val project: Project,
27 | ) : Disposable {
28 | private val logger = myLogger()
29 | private val bus = service()
30 |
31 | init {
32 | emitInitialEditorSelection()
33 | subscribeToEditorSelectionChanges()
34 | subscribeToFileWritablePropertyChanges()
35 | }
36 |
37 | private fun emitInitialEditorSelection() {
38 | val manager = FileEditorManager.getInstance(project)
39 | val editor = manager.selectedTextEditor as? EditorEx ?: return
40 | logger.info("Initial editor selected: $editor")
41 | bus.tryEmit(EditorSelected(editor))
42 | }
43 |
44 | private fun subscribeToEditorSelectionChanges() {
45 | project.messageBus.connect(this).subscribe(
46 | FileEditorManagerListener.FILE_EDITOR_MANAGER,
47 | object : FileEditorManagerListener {
48 | override fun selectionChanged(event: FileEditorManagerEvent) {
49 | val editor = (event.newEditor as? TextEditor)?.editor as? EditorEx ?: return
50 | logger.info("Editor selected: $editor")
51 | bus.tryEmit(EditorSelected(editor))
52 | }
53 | },
54 | )
55 | }
56 |
57 | private fun subscribeToFileWritablePropertyChanges() {
58 | project.messageBus.connect(this).subscribe(
59 | VirtualFileManager.VFS_CHANGES,
60 | object : BulkFileListener {
61 | override fun after(events: List) {
62 | events
63 | .asSequence()
64 | .filterIsInstance()
65 | .filter { it.propertyName == VirtualFile.PROP_WRITABLE }
66 | .mapNotNull { FileDocumentManager.getInstance().getDocument(it.file) }
67 | .flatMap { EditorFactory.getInstance().getEditors(it).toList() }
68 | .filterIsInstance()
69 | .forEach { editor ->
70 | logger.info("File writable property changed: $editor")
71 | bus.tryEmit(ChangeModifiable(editor))
72 | }
73 | }
74 | },
75 | )
76 | }
77 |
78 | override fun dispose() {}
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/BaseEditorViewer.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.editor.EditorCustomElementRenderer
5 | import com.intellij.openapi.editor.EditorFactory
6 | import com.intellij.openapi.editor.Inlay
7 | import com.intellij.openapi.editor.colors.EditorColors
8 | import com.intellij.openapi.editor.colors.EditorColorsManager
9 | import com.intellij.openapi.editor.ex.EditorEx
10 | import com.intellij.openapi.editor.markup.TextAttributes
11 | import com.intellij.openapi.project.Project
12 | import com.intellij.openapi.util.Key
13 | import com.intellij.ui.JBColor
14 | import com.ugarosa.neovim.domain.highlight.NvimHighlightManager
15 | import java.awt.Graphics
16 | import java.awt.Rectangle
17 |
18 | private val CUSTOM_EDITOR_KEY = Key.create("CUSTOM_EDITOR_KEY")
19 |
20 | fun isCustomEditor(editor: EditorEx): Boolean {
21 | return editor.document.getUserData(CUSTOM_EDITOR_KEY) == true
22 | }
23 |
24 | abstract class BaseEditorViewer(
25 | project: Project,
26 | ) {
27 | protected val highlightManager = service()
28 |
29 | protected val document =
30 | EditorFactory.getInstance()
31 | .createDocument("").apply {
32 | putUserData(CUSTOM_EDITOR_KEY, true)
33 | }
34 | protected val editor: EditorEx =
35 | EditorFactory.getInstance().createEditor(document, project)
36 | .let { it as EditorEx }
37 | .apply {
38 | settings.apply {
39 | // Simple viewer
40 | isLineNumbersShown = false
41 | isIndentGuidesShown = false
42 | isFoldingOutlineShown = false
43 | isRightMarginShown = false
44 | isCaretRowShown = false
45 | isLineMarkerAreaShown = false
46 | isAdditionalPageAtBottom = false
47 | // Use soft wraps
48 | isUseSoftWraps = true
49 | }
50 | backgroundColor = highlightManager.defaultBackground
51 | }
52 | val component = editor.component
53 |
54 | fun getHeight(): Int = document.lineCount * editor.lineHeight
55 |
56 | protected fun drawFakeCaret() {
57 | val inlayModel = editor.inlayModel
58 |
59 | inlayModel.getInlineElementsInRange(0, document.textLength)
60 | .filter { it.renderer is CaretRenderer }
61 | .forEach { it.dispose() }
62 |
63 | val offset = editor.caretModel.offset
64 |
65 | inlayModel.addInlineElement(
66 | offset,
67 | true,
68 | CaretRenderer(),
69 | )
70 | }
71 |
72 | private class CaretRenderer : EditorCustomElementRenderer {
73 | override fun calcWidthInPixels(inlay: Inlay<*>): Int = 1
74 |
75 | override fun paint(
76 | inlay: Inlay<*>,
77 | g: Graphics,
78 | targetRect: Rectangle,
79 | textAttributes: TextAttributes,
80 | ) {
81 | val schema = EditorColorsManager.getInstance().globalScheme
82 | val color = schema.getColor(EditorColors.CARET_COLOR) ?: JBColor.WHITE
83 | g.color = color
84 | g.fillRect(targetRect.x, targetRect.y, 1, targetRect.height)
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/cmdline/CmdlineEvent.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.cmdline
2 |
3 | // :h ui-cmdline
4 | sealed interface CmdlineEvent {
5 | data class Show(
6 | val content: List,
7 | val pos: Int,
8 | val firstChar: String,
9 | val prompt: String,
10 | val indent: Int,
11 | val level: Int,
12 | // used for prompt
13 | val hlId: Int,
14 | ) : CmdlineEvent
15 |
16 | data class Pos(val pos: Int, val level: Int) : CmdlineEvent
17 |
18 | data class SpecialChar(val c: String, val shift: Boolean, val level: Int) : CmdlineEvent
19 |
20 | data class Hide(
21 | val level: Int,
22 | val abort: Boolean,
23 | ) : CmdlineEvent
24 |
25 | data class BlockShow(
26 | val lines: List>,
27 | ) : CmdlineEvent
28 |
29 | data class BlockAppend(
30 | val line: List,
31 | ) : CmdlineEvent
32 |
33 | data object BlockHide : CmdlineEvent
34 |
35 | data object Flush : CmdlineEvent
36 | }
37 |
38 | data class CmdChunk(
39 | val attrId: Int,
40 | val text: String,
41 | )
42 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/cmdline/CmdlineView.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.cmdline
2 |
3 | import com.intellij.openapi.application.runUndoTransparentWriteAction
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.editor.Document
6 | import com.intellij.openapi.editor.markup.HighlighterLayer
7 | import com.intellij.openapi.editor.markup.HighlighterTargetArea
8 | import com.intellij.openapi.editor.markup.TextAttributes
9 | import com.intellij.openapi.project.Project
10 | import com.intellij.ui.JBColor
11 | import com.ugarosa.neovim.adapter.idea.ui.BaseEditorViewer
12 | import com.ugarosa.neovim.logger.myLogger
13 |
14 | @Service(Service.Level.PROJECT)
15 | class CmdlineView(
16 | project: Project,
17 | ) : BaseEditorViewer(project) {
18 | private val logger = myLogger()
19 |
20 | private val emptyShow = CmdlineEvent.Show(emptyList(), 0, "", "", 0, 0, 0)
21 | private var show: CmdlineEvent.Show = emptyShow
22 | private var specialChar: String = ""
23 | private var blockLines: MutableList> = mutableListOf()
24 | private var isDirty = false
25 |
26 | fun updateModel(
27 | show: CmdlineEvent.Show? = null,
28 | pos: Int? = null,
29 | specialChar: String? = null,
30 | blockShow: List>? = null,
31 | blockAppend: List? = null,
32 | ) {
33 | show?.let {
34 | this.show = it
35 | // Should be hidden at next cmdline_show
36 | this.specialChar = ""
37 | }
38 | pos?.let {
39 | this.show = this.show.copy(pos = it)
40 | }
41 | specialChar?.let {
42 | this.specialChar = it
43 | }
44 | blockShow?.let {
45 | blockLines = it.toMutableList()
46 | }
47 | blockAppend?.let {
48 | blockLines.add(it)
49 | }
50 | isDirty = true
51 | }
52 |
53 | fun clearSingle() {
54 | show = emptyShow
55 | specialChar = ""
56 | isDirty = true
57 | }
58 |
59 | fun clearBlock() {
60 | blockLines = mutableListOf()
61 | isDirty = true
62 | }
63 |
64 | fun isHidden(): Boolean = show.level == 0
65 |
66 | fun flush(): Boolean {
67 | if (!isDirty) return false
68 | isDirty = false
69 |
70 | logger.trace("Flushing CmdlinePane: show=$show, specialChar=$specialChar, blockLines=$blockLines")
71 |
72 | runUndoTransparentWriteAction {
73 | // Clear all text and highlighters
74 | document.setText("")
75 | editor.markupModel.removeAllHighlighters()
76 |
77 | var offset = 0
78 | blockLines.forEach { line ->
79 | offset = document.insertLine(offset, line, 0, "", true)
80 | }
81 |
82 | val lineStartOffset = offset
83 | offset = document.insertLine(offset, show.content, show.indent, specialChar, false)
84 |
85 | val cursorOffset = lineStartOffset + show.firstChar.length + show.prompt.length + show.indent + show.pos
86 | editor.caretModel.moveToOffset(cursorOffset)
87 |
88 | drawFakeCaret()
89 | }
90 | return true
91 | }
92 |
93 | private fun Document.insertLine(
94 | startOffset: Int,
95 | line: List,
96 | indent: Int,
97 | specialChar: String,
98 | newLine: Boolean,
99 | ): Int {
100 | var offset = startOffset
101 | val markup = editor.markupModel
102 |
103 | if (show.firstChar.isNotEmpty()) {
104 | insertString(offset, show.firstChar)
105 | offset += show.firstChar.length
106 | }
107 |
108 | if (show.prompt.isNotEmpty()) {
109 | val length = show.prompt.length
110 | val attrs = highlightManager.get(show.hlId).toTextAttributes()
111 | insertString(offset, show.prompt)
112 | markup.addRangeHighlighter(
113 | offset,
114 | offset + length,
115 | HighlighterLayer.SYNTAX,
116 | attrs,
117 | HighlighterTargetArea.EXACT_RANGE,
118 | )
119 | offset += length
120 | }
121 |
122 | if (indent > 0) {
123 | insertString(offset, " ".repeat(indent))
124 | offset += indent
125 | }
126 |
127 | line.forEach { chunk ->
128 | val length = chunk.text.length
129 | val attrs = highlightManager.get(chunk.attrId).toTextAttributes()
130 | insertString(offset, chunk.text)
131 | markup.addRangeHighlighter(
132 | offset,
133 | offset + length,
134 | HighlighterLayer.SYNTAX,
135 | attrs,
136 | HighlighterTargetArea.EXACT_RANGE,
137 | )
138 | offset += length
139 | }
140 |
141 | if (specialChar.isNotEmpty()) {
142 | val length = specialChar.length
143 | val attrs = TextAttributes().apply { foregroundColor = JBColor.GREEN }
144 | insertString(offset, specialChar)
145 | markup.addRangeHighlighter(
146 | offset,
147 | offset + length,
148 | HighlighterLayer.SYNTAX,
149 | attrs,
150 | HighlighterTargetArea.EXACT_RANGE,
151 | )
152 | offset += specialChar.length
153 | }
154 |
155 | if (newLine) {
156 | insertString(offset, "\n")
157 | offset += 1
158 | }
159 |
160 | return offset
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/cmdline/NvimCmdlineManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.cmdline
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.editor.Editor
7 | import com.intellij.openapi.project.Project
8 | import com.intellij.openapi.ui.popup.JBPopup
9 | import com.intellij.openapi.ui.popup.JBPopupFactory
10 | import com.intellij.openapi.ui.popup.JBPopupListener
11 | import com.intellij.openapi.ui.popup.LightweightWindowEvent
12 | import com.intellij.ui.awt.RelativePoint
13 | import com.ugarosa.neovim.common.focusEditor
14 | import com.ugarosa.neovim.domain.mode.getMode
15 | import com.ugarosa.neovim.logger.myLogger
16 | import com.ugarosa.neovim.rpc.client.NvimClient
17 | import com.ugarosa.neovim.rpc.client.api.input
18 | import kotlinx.coroutines.CoroutineScope
19 | import kotlinx.coroutines.Dispatchers
20 | import kotlinx.coroutines.launch
21 | import kotlinx.coroutines.withContext
22 | import java.awt.Dimension
23 | import java.awt.Point
24 |
25 | @Service(Service.Level.PROJECT)
26 | class NvimCmdlineManager(
27 | private val project: Project,
28 | private val scope: CoroutineScope,
29 | ) {
30 | private val logger = myLogger()
31 | private val client = service()
32 |
33 | private var popup: JBPopup? = null
34 |
35 | suspend fun handleEvent(event: CmdlineEvent) {
36 | val editor =
37 | focusEditor() ?: run {
38 | logger.warn("No focused editor found, cannot handle CmdlineEvent: $event")
39 | destroy()
40 | return
41 | }
42 |
43 | logger.trace("Handling CmdlineEvent: $event")
44 | withContext(Dispatchers.EDT) {
45 | val view = project.service()
46 | when (event) {
47 | is CmdlineEvent.Show -> view.updateModel(show = event)
48 | is CmdlineEvent.Pos -> view.updateModel(pos = event.pos)
49 | is CmdlineEvent.SpecialChar -> view.updateModel(specialChar = event.c)
50 | is CmdlineEvent.Hide -> view.clearSingle()
51 | is CmdlineEvent.BlockShow -> view.updateModel(blockShow = event.lines)
52 | is CmdlineEvent.BlockAppend -> view.updateModel(blockAppend = event.line)
53 | is CmdlineEvent.BlockHide -> view.clearBlock()
54 | is CmdlineEvent.Flush -> {
55 | // No change in model
56 | if (!view.flush()) return@withContext
57 |
58 | if (view.isHidden()) {
59 | logger.trace("Cmdline is hidden, not showing popup: $event")
60 | destroy()
61 | } else if (popup == null || popup!!.isDisposed) {
62 | logger.trace("Cmdline is shown, creating popup: $event")
63 | showPopup(editor, view)
64 | } else {
65 | logger.trace("Cmdline is shown, updating popup: $event")
66 | resize(editor, view)
67 | }
68 | }
69 | }
70 | }
71 | }
72 |
73 | private suspend fun destroy() =
74 | withContext(Dispatchers.EDT) {
75 | popup?.cancel()
76 | popup = null
77 | }
78 |
79 | private fun showPopup(
80 | editor: Editor,
81 | view: CmdlineView,
82 | ) {
83 | val (loc, size) = bottomLocationAndSize(editor, view)
84 | popup =
85 | JBPopupFactory.getInstance()
86 | .createComponentPopupBuilder(view.component, null)
87 | .setResizable(true)
88 | .setMovable(false)
89 | .setFocusable(false)
90 | .setRequestFocus(false)
91 | .setShowBorder(false)
92 | .setShowShadow(false)
93 | .setMinSize(size)
94 | .addListener(PopupCloseListener(scope, client))
95 | .createPopup()
96 | .apply {
97 | this.size = size
98 | show(RelativePoint(editor.component, loc))
99 | }
100 | }
101 |
102 | private class PopupCloseListener(
103 | private val scope: CoroutineScope,
104 | private val client: NvimClient,
105 | ) : JBPopupListener {
106 | override fun onClosed(event: LightweightWindowEvent) {
107 | scope.launch {
108 | if (getMode().isCommand()) {
109 | client.input("")
110 | }
111 | }
112 | }
113 | }
114 |
115 | private fun resize(
116 | editor: Editor,
117 | view: CmdlineView,
118 | ) {
119 | val (loc, size) = bottomLocationAndSize(editor, view)
120 | popup?.apply {
121 | this.size = size
122 | this.setLocation(RelativePoint(editor.component, loc).screenPoint)
123 | }
124 | }
125 |
126 | private fun bottomLocationAndSize(
127 | editor: Editor,
128 | view: CmdlineView,
129 | ): Pair {
130 | val width = editor.component.width
131 | val height = view.getHeight()
132 |
133 | val x = 0
134 | val y = editor.component.height - height
135 |
136 | return Point(x, y) to Dimension(width, height)
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/MessageEvent.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | /**
4 | * **ui-messages**
5 | *
6 | * Activated by the `ext_messages` `ui-option`.
7 | * Activates `ui-linegrid` and `ui-cmdline` implicitly.
8 | *
9 | * This UI extension delegates presentation of messages and dialogs. Messages
10 | * that would otherwise render in the message/cmdline screen space, are emitted
11 | * as UI events.
12 | *
13 | * Nvim will not allocate screen space for the cmdline or messages. 'cmdheight'
14 | * will be set to zero, but can be changed and used for the replacing cmdline or
15 | * message window. Cmdline state is emitted as `ui-cmdline` events, which the UI
16 | * must handle.
17 | */
18 | sealed interface MessageEvent {
19 | /**
20 | * Display a message to the user.
21 | *
22 | * `["msg_show", kind, content, replace_last, history]`
23 | */
24 | data class Show(
25 | val kind: MessageKind,
26 | val content: List,
27 | val replaceLast: Boolean,
28 | val history: Boolean,
29 | ) : MessageEvent
30 |
31 | /**
32 | * Clear all messages currently displayed by "msg_show". (Messages sent
33 | * by other "msg_" events below will not be affected).
34 | *
35 | * `["msg_clear"]`
36 | */
37 | data object Clear : MessageEvent
38 |
39 | // Ignore "msg_showmode", "msg_showcmd", "msg_ruler"
40 |
41 | /**
42 | * Sent when `:messages` command is invoked. History is sent as a list of
43 | * entries, where each entry is a `[kind, content]` tuple.
44 | *
45 | * `["msg_history_show", entries]`
46 | */
47 | data class ShowHistory(
48 | val entries: List,
49 | ) : MessageEvent
50 |
51 | /**
52 | * Clear the `:messages` history.
53 | *
54 | * `["msg_history_clear"]`
55 | */
56 | data object ClearHistory : MessageEvent
57 |
58 | data object Flush : MessageEvent
59 | }
60 |
61 | data class MsgChunk(
62 | val attrId: Int,
63 | val text: String,
64 | )
65 |
66 | data class MsgHistoryEntry(
67 | val kind: MessageKind,
68 | val content: List,
69 | )
70 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/MessageHistoryView.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | import com.intellij.openapi.application.runUndoTransparentWriteAction
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.editor.markup.HighlighterLayer
6 | import com.intellij.openapi.editor.markup.HighlighterTargetArea
7 | import com.intellij.openapi.project.Project
8 | import com.ugarosa.neovim.adapter.idea.ui.BaseEditorViewer
9 |
10 | private const val MAX_CHARS = 50_000
11 | private const val TRIM_CHARS = 5_000
12 |
13 | @Service(Service.Level.PROJECT)
14 | class MessageHistoryView(
15 | project: Project,
16 | ) : BaseEditorViewer(project) {
17 | companion object {
18 | const val TAB_TITLE = "History"
19 | }
20 |
21 | fun updateHistory(show: MessageEvent.Show) {
22 | if (!show.history) return
23 |
24 | runUndoTransparentWriteAction {
25 | val markup = editor.markupModel
26 |
27 | if (document.textLength > 0) {
28 | document.insertString(document.textLength, "\n")
29 | }
30 |
31 | show.content.forEach { chunk ->
32 | val start = document.textLength
33 | val text = chunk.text
34 | document.insertString(start, text)
35 | val end = start + text.length
36 |
37 | val attrs = highlightManager.get(chunk.attrId).toTextAttributes()
38 | markup.addRangeHighlighter(
39 | start,
40 | end,
41 | HighlighterLayer.SYNTAX,
42 | attrs,
43 | HighlighterTargetArea.EXACT_RANGE,
44 | )
45 | }
46 |
47 | if (document.textLength > MAX_CHARS) {
48 | document.deleteString(0, TRIM_CHARS)
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/MessageKind.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | enum class MessageKind(val value: String) {
4 | BufWrite("bufwrite"),
5 | Confirm("confirm"),
6 | ErrMsg("emsg"),
7 | Echo("echo"),
8 | EchoMsg("echomsg"),
9 | EchoErr("echoerr"),
10 | Completion("completion"),
11 | ListCmd("list_cmd"),
12 | LuaErr("lua_error"),
13 | LuaPrint("lua_print"),
14 | RpcErr("rpc_error"),
15 | ReturnPrompt("return_prompt"),
16 | QuickFix("quickfix"),
17 | SearchCmd("search_cmd"),
18 | SearchCount("search_count"),
19 | ShellErr("shell_err"),
20 | ShellOut("shell_out"),
21 | ShellRet("shell_ret"),
22 | Undo("undo"),
23 | Verbose("verbose"),
24 | WildList("wildlist"),
25 | WarnMsg("wmsg"),
26 | Unknown(""),
27 |
28 | // For "msg_history_show"
29 | History("history"),
30 | ;
31 |
32 | companion object {
33 | private val map = entries.associateBy { it.value }
34 |
35 | fun fromValue(value: String): MessageKind {
36 | return map[value] ?: Unknown
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/MessageLiveView.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | import com.intellij.openapi.application.runUndoTransparentWriteAction
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.editor.markup.HighlighterLayer
6 | import com.intellij.openapi.editor.markup.HighlighterTargetArea
7 | import com.intellij.openapi.project.Project
8 | import com.ugarosa.neovim.adapter.idea.ui.BaseEditorViewer
9 |
10 | @Service(Service.Level.PROJECT)
11 | class MessageLiveView(
12 | project: Project,
13 | ) : BaseEditorViewer(project) {
14 | companion object {
15 | const val TAB_TITLE = "Live"
16 | }
17 |
18 | private var kind = MessageKind.Unknown
19 | private val chunks = mutableListOf()
20 | private var isDirty = false
21 |
22 | fun updateModel(
23 | show: MessageEvent.Show? = null,
24 | history: MessageEvent.ShowHistory? = null,
25 | ) {
26 | show?.let {
27 | this.kind = it.kind
28 | if (it.replaceLast) {
29 | this.chunks.clear()
30 | }
31 | this.chunks.addAll(it.content)
32 | }
33 | history?.let { it ->
34 | this.kind = MessageKind.History
35 | this.chunks.clear()
36 | this.chunks.addAll(it.entries.flatMap { it.content })
37 | }
38 | isDirty = true
39 | }
40 |
41 | fun clear() {
42 | this.kind = MessageKind.Unknown
43 | this.chunks.clear()
44 | isDirty = false
45 | }
46 |
47 | /**
48 | * Apply pending updates to the editor component.
49 | * @return true if something was rendered.
50 | */
51 | fun flush(): Boolean {
52 | if (!isDirty) return false
53 | isDirty = false
54 |
55 | runUndoTransparentWriteAction {
56 | // Set the text. It replaces the whole document.
57 | val text = chunks.joinToString("\n") { it.text }
58 | document.setText(text)
59 |
60 | // Remove all highlighters
61 | val markup = editor.markupModel
62 | markup.removeAllHighlighters()
63 |
64 | // Add new highlighters
65 | var offset = 0
66 | chunks.forEach { chunk ->
67 | val length = chunk.text.length
68 | val attrs = highlightManager.get(chunk.attrId).toTextAttributes()
69 | markup.addRangeHighlighter(
70 | offset,
71 | offset + length,
72 | HighlighterLayer.SYNTAX,
73 | attrs,
74 | HighlighterTargetArea.EXACT_RANGE,
75 | )
76 | offset += length + 1 // +1 for the newline
77 | }
78 | }
79 |
80 | return true
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/NvimMessageManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | import com.intellij.openapi.application.ApplicationManager
4 | import com.intellij.openapi.application.EDT
5 | import com.intellij.openapi.application.invokeLater
6 | import com.intellij.openapi.components.Service
7 | import com.intellij.openapi.components.service
8 | import com.intellij.openapi.project.Project
9 | import com.intellij.openapi.wm.ToolWindow
10 | import com.intellij.openapi.wm.ToolWindowManager
11 | import com.intellij.openapi.wm.ex.ToolWindowManagerListener
12 | import com.ugarosa.neovim.common.focusProject
13 | import com.ugarosa.neovim.logger.myLogger
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.withContext
16 |
17 | @Service(Service.Level.APP)
18 | class NvimMessageManager {
19 | private val logger = myLogger()
20 |
21 | init {
22 | // Reset icon when tool window is shown
23 | ApplicationManager.getApplication().messageBus.connect().subscribe(
24 | ToolWindowManagerListener.TOPIC,
25 | object : ToolWindowManagerListener {
26 | override fun toolWindowShown(toolWindow: ToolWindow) {
27 | if (toolWindow.id != NeovimMessageToolWindowFactory.WINDOW_ID) return
28 | invokeLater {
29 | toolWindow.setIcon(NeovimMessageIcon.base)
30 | }
31 | }
32 | },
33 | )
34 | }
35 |
36 | suspend fun handleMessageEvent(event: MessageEvent) =
37 | withContext(Dispatchers.EDT) {
38 | val project = focusProject() ?: return@withContext
39 | val livePane = project.service()
40 | val historyPane = project.service()
41 |
42 | logger.trace("Handling message event: $event")
43 |
44 | when (event) {
45 | is MessageEvent.Show -> {
46 | livePane.updateModel(show = event)
47 | historyPane.updateHistory(event)
48 | }
49 |
50 | is MessageEvent.ShowHistory -> {
51 | livePane.updateModel(history = event)
52 | }
53 |
54 | is MessageEvent.Clear,
55 | is MessageEvent.ClearHistory,
56 | -> {
57 | livePane.clear()
58 | }
59 |
60 | is MessageEvent.Flush -> {
61 | toolWindow(project)?.let { tw ->
62 | if (livePane.flush()) {
63 | tw.setIcon(NeovimMessageIcon.withBadge)
64 | } else {
65 | tw.hide()
66 | }
67 | }
68 | }
69 | }
70 | }
71 |
72 | private fun toolWindow(project: Project): ToolWindow? =
73 | ToolWindowManager.getInstance(project)
74 | .getToolWindow(NeovimMessageToolWindowFactory.WINDOW_ID)
75 | }
76 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/NvimMessageToolWindowFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.project.Project
5 | import com.intellij.openapi.util.IconLoader
6 | import com.intellij.openapi.wm.ToolWindow
7 | import com.intellij.openapi.wm.ToolWindowFactory
8 | import com.intellij.ui.content.ContentFactory
9 |
10 | object NeovimMessageIcon {
11 | val base = IconLoader.getIcon("/icons/neovim-message@20x20.svg", javaClass)
12 | private val badge = IconLoader.getIcon("/icons/green-circle.svg", javaClass)
13 | val withBadge = OverlayIcon(base, badge)
14 | }
15 |
16 | class NeovimMessageToolWindowFactory : ToolWindowFactory {
17 | override fun createToolWindowContent(
18 | project: Project,
19 | toolWindow: ToolWindow,
20 | ) {
21 | toolWindow.setIcon(NeovimMessageIcon.base)
22 |
23 | val contentFactory = ContentFactory.getInstance()
24 |
25 | val liveView = project.service()
26 | val liveContent = contentFactory.createContent(liveView.component, MessageLiveView.TAB_TITLE, false)
27 | toolWindow.contentManager.addContent(liveContent)
28 |
29 | val historyView = project.service()
30 | val historyContent = contentFactory.createContent(historyView.component, MessageHistoryView.TAB_TITLE, false)
31 | toolWindow.contentManager.addContent(historyContent)
32 |
33 | toolWindow.contentManager.setSelectedContent(liveContent)
34 | }
35 |
36 | companion object {
37 | const val WINDOW_ID = "Neovim Messages"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/message/OverlayIcon.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.message
2 |
3 | import com.intellij.openapi.util.ScalableIcon
4 | import com.intellij.util.IconUtil
5 | import java.awt.Component
6 | import java.awt.Graphics
7 | import javax.swing.Icon
8 |
9 | /**
10 | * An Icon that draws [base] and then draws [badge] at one of the four corners,
11 | * with [padding] pixels inset.
12 | */
13 | class OverlayIcon(
14 | private val base: Icon,
15 | private val badge: Icon,
16 | private val corner: Corner = Corner.TOP_RIGHT,
17 | private val padding: Int = 0,
18 | ) : ScalableIcon {
19 | enum class Corner { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT }
20 |
21 | override fun getIconWidth(): Int = base.iconWidth
22 |
23 | override fun getIconHeight(): Int = base.iconHeight
24 |
25 | override fun paintIcon(
26 | c: Component?,
27 | g: Graphics,
28 | x: Int,
29 | y: Int,
30 | ) {
31 | // draw the base icon
32 | base.paintIcon(c, g, x, y)
33 |
34 | // decide where to draw the badge
35 | val bx =
36 | when (corner) {
37 | Corner.TOP_LEFT, Corner.BOTTOM_LEFT -> x + padding
38 | Corner.TOP_RIGHT, Corner.BOTTOM_RIGHT -> x + base.iconWidth - badge.iconWidth - padding
39 | }
40 | val by =
41 | when (corner) {
42 | Corner.TOP_LEFT, Corner.TOP_RIGHT -> y + padding
43 | Corner.BOTTOM_LEFT, Corner.BOTTOM_RIGHT -> y + base.iconHeight - badge.iconHeight - padding
44 | }
45 |
46 | // draw the badge
47 | badge.paintIcon(c, g, bx, by)
48 | }
49 |
50 | override fun getScale(): Float = (base as? ScalableIcon)?.scale ?: 1.0f
51 |
52 | override fun scale(scale: Float): Icon {
53 | val scaledBase = (base as? ScalableIcon)?.scale(scale) ?: IconUtil.scale(base, null, scale)
54 | val scaledBadge = (badge as? ScalableIcon)?.scale(scale) ?: IconUtil.scale(badge, null, scale)
55 | return OverlayIcon(scaledBase, scaledBadge, corner, padding)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/statusline/NvimModeWidget.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.statusline
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.wm.CustomStatusBarWidget
5 | import com.intellij.openapi.wm.StatusBar
6 | import com.intellij.ui.JBColor
7 | import com.intellij.util.ui.JBUI
8 | import com.ugarosa.neovim.domain.mode.NeovimModeManager
9 | import com.ugarosa.neovim.domain.mode.NvimMode
10 | import com.ugarosa.neovim.domain.mode.NvimModeKind
11 | import javax.swing.JLabel
12 |
13 | class NvimModeWidget : CustomStatusBarWidget {
14 | init {
15 | service().addHook { _, new ->
16 | updateMode(new)
17 | }
18 | }
19 |
20 | private var mode: NvimMode = NvimMode.default
21 | private var statusBar: StatusBar? = null
22 | private val label =
23 | JLabel().apply {
24 | border = JBUI.Borders.empty(0, 6)
25 | isOpaque = true
26 | text = mode.kind.name
27 | foreground = JBColor.foreground()
28 | background = modeToColor(mode)
29 | }
30 |
31 | override fun ID(): String = NEOVIM_MODE_ID
32 |
33 | override fun install(statusBar: StatusBar) {
34 | this.statusBar = statusBar
35 | }
36 |
37 | override fun dispose() {}
38 |
39 | override fun getComponent() = label
40 |
41 | fun updateMode(newMode: NvimMode) {
42 | if (mode != newMode) {
43 | mode = newMode
44 | label.text = newMode.kind.name
45 | label.background = modeToColor(newMode)
46 | statusBar?.updateWidget(ID())
47 | }
48 | }
49 |
50 | private fun modeToColor(mode: NvimMode): JBColor {
51 | return when (mode.kind) {
52 | NvimModeKind.NORMAL -> JBColor.GREEN
53 |
54 | NvimModeKind.VISUAL,
55 | NvimModeKind.SELECT,
56 | -> JBColor.BLUE
57 |
58 | NvimModeKind.INSERT -> JBColor.YELLOW
59 | NvimModeKind.REPLACE -> JBColor.ORANGE
60 | NvimModeKind.COMMAND -> JBColor.GREEN
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/ui/statusline/NvimModeWidgetFactory.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.ui.statusline
2 |
3 | import com.intellij.openapi.project.Project
4 | import com.intellij.openapi.wm.StatusBar
5 | import com.intellij.openapi.wm.StatusBarWidget
6 | import com.intellij.openapi.wm.StatusBarWidgetFactory
7 |
8 | const val NEOVIM_MODE_ID = "NeovimModeWidgetId"
9 |
10 | class NeovimModeWidgetFactory : StatusBarWidgetFactory {
11 | override fun getId(): String = NEOVIM_MODE_ID
12 |
13 | override fun getDisplayName(): String = "Neovim Mode Display"
14 |
15 | override fun isAvailable(project: Project): Boolean = true
16 |
17 | override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true
18 |
19 | override fun createWidget(project: Project): StatusBarWidget {
20 | return NvimModeWidget()
21 | }
22 |
23 | override fun disposeWidget(widget: StatusBarWidget) {
24 | widget.dispose()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/undo/DocumentUndoListener.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.undo
2 |
3 | import com.intellij.openapi.editor.event.DocumentEvent
4 | import com.intellij.openapi.editor.event.DocumentListener
5 |
6 | data class Patch(
7 | val offset: Int,
8 | val oldText: String,
9 | val newText: String,
10 | )
11 |
12 | class DocumentUndoListener : DocumentListener {
13 | private val patches = mutableListOf()
14 |
15 | override fun documentChanged(event: DocumentEvent) {
16 | patches +=
17 | Patch(
18 | offset = event.offset,
19 | oldText = event.oldFragment.toString(),
20 | newText = event.newFragment.toString(),
21 | )
22 | }
23 |
24 | fun clear() {
25 | patches.clear()
26 | }
27 |
28 | fun copyPatches(): List {
29 | return patches.toList()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/idea/undo/NvimUndoManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.idea.undo
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.application.runUndoTransparentWriteAction
5 | import com.intellij.openapi.command.impl.DocumentUndoProvider
6 | import com.intellij.openapi.command.undo.DocumentReference
7 | import com.intellij.openapi.command.undo.DocumentReferenceManager
8 | import com.intellij.openapi.command.undo.UndoManager
9 | import com.intellij.openapi.command.undo.UndoUtil
10 | import com.intellij.openapi.command.undo.UndoableAction
11 | import com.intellij.openapi.components.Service
12 | import com.intellij.openapi.components.service
13 | import com.intellij.openapi.editor.ex.EditorEx
14 | import com.intellij.openapi.project.Project
15 | import com.intellij.util.DocumentUtil
16 | import com.ugarosa.neovim.common.focusEditor
17 | import com.ugarosa.neovim.common.focusProject
18 | import com.ugarosa.neovim.domain.mode.NeovimModeManager
19 | import kotlinx.coroutines.Dispatchers
20 | import kotlinx.coroutines.withContext
21 |
22 | /**
23 | * This is used to group changes made during a certain period into a single undo block.
24 | * It temporarily disables all other undo recordings, and only registers a custom undo action when `finish()` is called.
25 | *
26 | * NOTE: Why not group them using `executeCommand` with the same `groupId`?
27 | * There are many actions that modify the document besides `TypedAction`. For example, `Backspace` is a different
28 | * action, as are inserting completion suggestions or Copilot suggestions. There might also be plugins I’m unaware of
29 | * that provide their own actions. It’s not realistic to override all of these actions.
30 | *
31 | * NOTE: Why not use CommandProcessorEx.startCommand?
32 | * If the user types within a started command, processes like Lookup won’t be triggered, so the user won’t benefit from
33 | * auto-completion.
34 | */
35 | @Service(Service.Level.APP)
36 | class NvimUndoManager {
37 | private val listener = DocumentUndoListener()
38 | private var beforeCaret = 0
39 |
40 | init {
41 | service().addHook { old, new ->
42 | val editor = focusEditor() ?: return@addHook
43 | if (new.isInsert()) {
44 | start(editor)
45 | } else if (old.isInsert()) {
46 | val project = focusProject() ?: return@addHook
47 | finish(project, editor)
48 | }
49 | }
50 | }
51 |
52 | private suspend fun start(editor: EditorEx) =
53 | withContext(Dispatchers.EDT) {
54 | UndoUtil.disableUndoFor(editor.document)
55 | listener.clear()
56 | editor.document.addDocumentListener(listener)
57 | beforeCaret = editor.caretModel.offset
58 | }
59 |
60 | private suspend fun finish(
61 | project: Project,
62 | editor: EditorEx,
63 | ) = withContext(Dispatchers.EDT) {
64 | try {
65 | val patches = listener.copyPatches()
66 | val beforeCaret = this@NvimUndoManager.beforeCaret
67 | val afterCaret = editor.caretModel.offset
68 | if (patches.isEmpty() && beforeCaret == afterCaret) {
69 | // No changes
70 | return@withContext
71 | }
72 | val reference = DocumentReferenceManager.getInstance().create(editor.document)
73 |
74 | val action =
75 | object : UndoableAction {
76 | override fun undo() {
77 | apply(editor, patches, beforeCaret, false)
78 | }
79 |
80 | override fun redo() {
81 | apply(editor, patches, afterCaret, true)
82 | }
83 |
84 | override fun getAffectedDocuments(): Array = arrayOf(reference)
85 |
86 | override fun isGlobal(): Boolean = false
87 | }
88 |
89 | runUndoTransparentWriteAction {
90 | UndoManager.getInstance(project).undoableActionPerformed(action)
91 | }
92 | } finally {
93 | UndoUtil.enableUndoFor(editor.document)
94 | editor.document.removeDocumentListener(listener)
95 | }
96 | }
97 |
98 | @Suppress("UnstableApiUsage")
99 | private fun apply(
100 | editor: EditorEx,
101 | patches: List,
102 | caretOffset: Int,
103 | forward: Boolean,
104 | ) {
105 | DocumentUndoProvider.startDocumentUndo(editor.document)
106 | try {
107 | DocumentUtil.writeInRunUndoTransparentAction {
108 | val seq = if (forward) patches else patches.reversed()
109 | seq.forEach { p ->
110 | // replaceString(startOffset, endOffset, replacement)
111 | //
112 | // Important: `endOffset` **must be calculated using the text that is
113 | // currently present in the document** at the moment of execution,
114 | // because IntelliJ measures ranges in the *live* document.
115 | //
116 | // ─ forward == true (redo)
117 | // The document still contains the *old* text, so the range to
118 | // replace is [offset, offset + old.length).
119 | //
120 | // ─ forward == false (undo)
121 | // The document currently contains the *new* text, so the range to
122 | // replace is [offset, offset + new.length).
123 | editor.document.replaceString(
124 | p.offset,
125 | p.offset + if (forward) p.oldText.length else p.newText.length,
126 | if (forward) p.newText else p.oldText,
127 | )
128 | }
129 | editor.caretModel.moveToOffset(caretOffset)
130 | }
131 | } finally {
132 | DocumentUndoProvider.finishDocumentUndo(editor.document)
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/ActionAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.adapter.idea.action.executeAction
4 | import com.ugarosa.neovim.rpc.client.NvimClient
5 |
6 | fun installActionAdapter(client: NvimClient) {
7 | client.register("IdeaNeovim:ExecIdeaAction") { params ->
8 | val actionId = params[0].asString()
9 | executeAction(actionId)
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/BufLinesAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.bus.NvimBufLines
4 | import com.ugarosa.neovim.bus.NvimToIdeaBus
5 | import com.ugarosa.neovim.rpc.client.NvimClient
6 |
7 | fun installBufLinesAdapter(
8 | client: NvimClient,
9 | bus: NvimToIdeaBus,
10 | ) {
11 | client.register("nvim_buf_lines_event") { params ->
12 | val bufferId = params[0].asBufferId()
13 | val changedTick = params[1].asLong()
14 | val firstLine = params[2].asInt()
15 | val lastLine = params[3].asInt()
16 | val replacementLines = params[4].asArray().map { it.asString() }
17 | val event = NvimBufLines(bufferId, changedTick, firstLine, lastLine, replacementLines)
18 | bus.tryEmit(event)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/CursorAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.bus.NvimCursorMoved
4 | import com.ugarosa.neovim.bus.NvimToIdeaBus
5 | import com.ugarosa.neovim.domain.position.NvimPosition
6 | import com.ugarosa.neovim.rpc.client.NvimClient
7 |
8 | fun installCursorAdapter(
9 | client: NvimClient,
10 | bus: NvimToIdeaBus,
11 | ) {
12 | client.register("IdeaNeovim:CursorMoved") { params ->
13 | val bufferId = params[0].asBufferId()
14 | val line = params[1].asInt()
15 | val col = params[2].asInt()
16 | val curswant = params[3].asInt()
17 | val pos = NvimPosition(line, col, curswant)
18 | val event = NvimCursorMoved(bufferId, pos)
19 | bus.tryEmit(event)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/IncomingEventsRegistry.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.intellij.openapi.components.service
5 | import com.ugarosa.neovim.adapter.nvim.incoming.redraw.installRedrawAdapter
6 | import com.ugarosa.neovim.bus.NvimToIdeaBus
7 | import com.ugarosa.neovim.config.nvim.NvimOptionManager
8 | import com.ugarosa.neovim.rpc.client.NvimClient
9 |
10 | @Service(Service.Level.APP)
11 | class IncomingEventsRegistry {
12 | private val client = service()
13 | private val bus = service()
14 | private val optionManager = service()
15 |
16 | init {
17 | installBufLinesAdapter(client, bus)
18 | installCursorAdapter(client, bus)
19 | installModeAdapter(client, bus)
20 | installVisualSelectionAdapter(client, bus)
21 | installOptionAdapter(client, optionManager)
22 | installActionAdapter(client)
23 | installRedrawAdapter(client)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/ModeAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.bus.ModeChanged
4 | import com.ugarosa.neovim.bus.NvimToIdeaBus
5 | import com.ugarosa.neovim.domain.mode.NvimMode
6 | import com.ugarosa.neovim.rpc.client.NvimClient
7 |
8 | fun installModeAdapter(
9 | client: NvimClient,
10 | bus: NvimToIdeaBus,
11 | ) {
12 | client.register("IdeaNeovim:ModeChanged") { params ->
13 | val bufferId = params[0].asBufferId()
14 | val mode = NvimMode.fromMode(params[1].asString()) ?: return@register
15 | val event = ModeChanged(bufferId, mode)
16 | bus.tryEmit(event)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/OptionAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.config.nvim.NvimOptionManager
4 | import com.ugarosa.neovim.rpc.client.NvimClient
5 |
6 | fun installOptionAdapter(
7 | client: NvimClient,
8 | optionManager: NvimOptionManager,
9 | ) {
10 | client.register("IdeaNeovim:OptionSet") { params ->
11 | val bufferId = params[0].asBufferId()
12 | val scope = params[1].asString()
13 | val key = params[2].asString()
14 | val value = params[3].asAny()
15 | when (scope) {
16 | "global" -> optionManager.putGlobal(key, value)
17 | "local" -> optionManager.putLocal(bufferId, key, value)
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/VisualSelectionAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming
2 |
3 | import com.ugarosa.neovim.bus.NvimToIdeaBus
4 | import com.ugarosa.neovim.bus.VisualSelectionChanged
5 | import com.ugarosa.neovim.domain.position.NvimRegion
6 | import com.ugarosa.neovim.rpc.client.NvimClient
7 |
8 | fun installVisualSelectionAdapter(
9 | client: NvimClient,
10 | bus: NvimToIdeaBus,
11 | ) {
12 | client.register("IdeaNeovim:VisualSelection") { params ->
13 | val bufferId = params[0].asBufferId()
14 | val regions =
15 | params[1].asArray().map {
16 | val list = it.asArray()
17 | val row = list[0].asInt()
18 | val startColumn = list[1].asInt()
19 | val endColumn = list[2].asInt()
20 | NvimRegion(row, startColumn, endColumn)
21 | }
22 | val event = VisualSelectionChanged(bufferId, regions)
23 | bus.tryEmit(event)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/redraw/CmdlineHandler.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming.redraw
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.components.service
5 | import com.ugarosa.neovim.adapter.idea.ui.cmdline.CmdChunk
6 | import com.ugarosa.neovim.adapter.idea.ui.cmdline.CmdlineEvent
7 | import com.ugarosa.neovim.adapter.idea.ui.cmdline.NvimCmdlineManager
8 | import com.ugarosa.neovim.common.focusProject
9 | import com.ugarosa.neovim.rpc.transport.NvimObject
10 | import kotlinx.coroutines.Dispatchers
11 | import kotlinx.coroutines.withContext
12 |
13 | suspend fun onCmdlineEvent(
14 | name: String,
15 | param: List,
16 | ) {
17 | maybeCmdlineEvent(name, param)?.let { event ->
18 | val cmdlineManager =
19 | withContext(Dispatchers.EDT) {
20 | focusProject()?.service()
21 | } ?: return
22 | cmdlineManager.handleEvent(event)
23 | }
24 | }
25 |
26 | private fun maybeCmdlineEvent(
27 | name: String,
28 | param: List,
29 | ): CmdlineEvent? {
30 | return when (name) {
31 | "cmdline_show" -> {
32 | val content = param[0].asArray().map { it.asShowContent() }
33 | CmdlineEvent.Show(
34 | content,
35 | param[1].asInt(),
36 | param[2].asString(),
37 | param[3].asString(),
38 | param[4].asInt(),
39 | param[5].asInt(),
40 | param[6].asInt(),
41 | )
42 | }
43 |
44 | "cmdline_pos" -> {
45 | CmdlineEvent.Pos(
46 | param[0].asInt(),
47 | param[1].asInt(),
48 | )
49 | }
50 |
51 | "cmdline_special_char" -> {
52 | CmdlineEvent.SpecialChar(
53 | param[0].asString(),
54 | param[1].asBool(),
55 | param[2].asInt(),
56 | )
57 | }
58 |
59 | "cmdline_hide" -> {
60 | CmdlineEvent.Hide(
61 | param[0].asInt(),
62 | param[1].asBool(),
63 | )
64 | }
65 |
66 | "cmdline_block_show" -> {
67 | val lines =
68 | param[0].asArray().map { line ->
69 | line.asArray().map { it.asShowContent() }
70 | }
71 | CmdlineEvent.BlockShow(lines)
72 | }
73 |
74 | "cmdline_block_append" -> {
75 | val line = param[0].asArray().map { it.asShowContent() }
76 | CmdlineEvent.BlockAppend(line)
77 | }
78 |
79 | "cmdline_block_hide" -> {
80 | CmdlineEvent.BlockHide
81 | }
82 |
83 | "flush" -> {
84 | CmdlineEvent.Flush
85 | }
86 |
87 | else -> null
88 | }
89 | }
90 |
91 | private fun NvimObject.asShowContent(): CmdChunk {
92 | val content = asArray()
93 | return CmdChunk(
94 | content[0].asInt(),
95 | content[1].asString(),
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/redraw/HighlightHandler.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming.redraw
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.domain.highlight.NvimHighlightManager
5 | import com.ugarosa.neovim.rpc.transport.NvimObject
6 |
7 | fun onHighlightEvent(
8 | name: String,
9 | param: List,
10 | ) {
11 | val highlightManager = service()
12 | when (name) {
13 | "default_colors_set" -> {
14 | // Ignore this event since we want to use IDE default colors
15 | }
16 |
17 | "hl_attr_define" -> {
18 | val id = param[0].asInt()
19 | val attr = param[1].asStringMap()
20 | highlightManager.defineAttr(id, attr)
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/redraw/MessageHandler.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming.redraw
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.adapter.idea.ui.message.MessageEvent
5 | import com.ugarosa.neovim.adapter.idea.ui.message.MessageKind
6 | import com.ugarosa.neovim.adapter.idea.ui.message.MsgChunk
7 | import com.ugarosa.neovim.adapter.idea.ui.message.MsgHistoryEntry
8 | import com.ugarosa.neovim.adapter.idea.ui.message.NvimMessageManager
9 | import com.ugarosa.neovim.rpc.transport.NvimObject
10 |
11 | suspend fun onMessageEvent(
12 | name: String,
13 | param: List,
14 | ) {
15 | maybeMessageEvent(name, param)?.let { event ->
16 | service().handleMessageEvent(event)
17 | }
18 | }
19 |
20 | private fun maybeMessageEvent(
21 | name: String,
22 | param: List,
23 | ): MessageEvent? {
24 | return when (name) {
25 | "msg_show" -> {
26 | val kind = MessageKind.fromValue(param[0].asString())
27 | val content =
28 | param[1].asArray().map {
29 | val chunk = it.asArray()
30 | MsgChunk(chunk[0].asInt(), chunk[1].asString())
31 | }
32 | val replaceLast = param[2].asBool()
33 | val history = param[3].asBool()
34 | MessageEvent.Show(kind, content, replaceLast, history)
35 | }
36 |
37 | "msg_clear" -> MessageEvent.Clear
38 |
39 | "msg_history_show" -> {
40 | val entries =
41 | param.map { entry ->
42 | val list = entry.asArray()[0].asArray()
43 | val kind = MessageKind.fromValue(list[0].asString())
44 | val content =
45 | list[1].asArray().map {
46 | val chunk = it.asArray()
47 | MsgChunk(chunk[0].asInt(), chunk[1].asString())
48 | }
49 | MsgHistoryEntry(kind, content)
50 | }
51 | MessageEvent.ShowHistory(entries)
52 | }
53 |
54 | "msg_history_clear" -> MessageEvent.ClearHistory
55 |
56 | "flush" -> MessageEvent.Flush
57 |
58 | else -> null
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/incoming/redraw/RedrawAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.incoming.redraw
2 |
3 | import com.ugarosa.neovim.rpc.client.NvimClient
4 |
5 | fun installRedrawAdapter(client: NvimClient) {
6 | client.register("redraw") { rawBatches ->
7 | // rawBatches: List<…> = [ ['grid_resize', [2,77,36]], ['msg_showmode',[[]]], … ]
8 | rawBatches.forEach { rawBatch ->
9 | // batch: List = ['grid_resize', [2,77,36]]
10 | val batch = rawBatch.asArray()
11 |
12 | // Some events have a single parameter, while others have several parameters
13 | // e.g. ["hl_attr_define", [param1], [param2], [param3], ...]
14 | val name = batch[0].asString()
15 | batch.drop(1).forEach { rawParam ->
16 | val param = rawParam.asArray()
17 | onHighlightEvent(name, param)
18 | onCmdlineEvent(name, param)
19 | onMessageEvent(name, param)
20 | }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/outgoing/CursorCommandAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.outgoing
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.domain.id.BufferId
5 | import com.ugarosa.neovim.domain.position.NvimPosition
6 | import com.ugarosa.neovim.rpc.client.NvimClient
7 | import com.ugarosa.neovim.rpc.client.api.setCursor
8 |
9 | class CursorCommandAdapter {
10 | private val client = service()
11 |
12 | suspend fun send(
13 | bufferId: BufferId,
14 | pos: NvimPosition,
15 | ) {
16 | client.setCursor(bufferId, pos)
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/adapter/nvim/outgoing/DocumentCommandAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.adapter.nvim.outgoing
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.domain.buffer.FixedChange
5 | import com.ugarosa.neovim.domain.buffer.RepeatableChange
6 | import com.ugarosa.neovim.domain.id.BufferId
7 | import com.ugarosa.neovim.rpc.client.NvimClient
8 | import com.ugarosa.neovim.rpc.client.api.CHANGED_TICK
9 | import com.ugarosa.neovim.rpc.client.api.bufVar
10 | import com.ugarosa.neovim.rpc.client.api.bufferAttach
11 | import com.ugarosa.neovim.rpc.client.api.bufferSetLines
12 | import com.ugarosa.neovim.rpc.client.api.bufferSetText
13 | import com.ugarosa.neovim.rpc.client.api.input
14 | import com.ugarosa.neovim.rpc.client.api.modifiable
15 | import com.ugarosa.neovim.rpc.client.api.noModifiable
16 | import com.ugarosa.neovim.rpc.client.api.sendRepeatableChange
17 | import com.ugarosa.neovim.rpc.client.api.setFiletype
18 |
19 | class DocumentCommandAdapter(
20 | private val bufferId: BufferId,
21 | ) {
22 | private val client = service()
23 | private val ignoreTicks = mutableSetOf()
24 |
25 | suspend fun replaceAll(lines: List) {
26 | client.bufferSetLines(bufferId, 0, -1, lines)
27 | }
28 |
29 | suspend fun setFiletype(path: String) {
30 | client.setFiletype(bufferId, path)
31 | }
32 |
33 | suspend fun attach() {
34 | client.bufferAttach(bufferId)
35 | }
36 |
37 | suspend fun setText(change: FixedChange) {
38 | val currentTick = client.bufVar(bufferId, CHANGED_TICK)
39 | ignoreTicks.add(currentTick + 1)
40 | client.bufferSetText(bufferId, change.start, change.end, change.replacement)
41 | }
42 |
43 | suspend fun sendRepeatableChange(change: RepeatableChange) {
44 | val currentTick = client.bufVar(bufferId, CHANGED_TICK)
45 | ignoreTicks.addAll(currentTick + 1..currentTick + change.ignoreTickIncrement)
46 | client.sendRepeatableChange(change)
47 | }
48 |
49 | suspend fun escape() {
50 | client.input("")
51 | }
52 |
53 | fun isIgnored(tick: Long): Boolean {
54 | return ignoreTicks.remove(tick)
55 | }
56 |
57 | suspend fun changeModifiable(isWritable: Boolean) {
58 | if (isWritable) {
59 | client.modifiable(bufferId)
60 | } else {
61 | client.noModifiable(bufferId)
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/bus/IdeaToNvimBus.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.bus
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.Service
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.cancel
7 | import kotlinx.coroutines.channels.Channel
8 | import kotlinx.coroutines.flow.MutableSharedFlow
9 | import kotlinx.coroutines.flow.SharedFlow
10 | import kotlinx.coroutines.flow.asSharedFlow
11 | import kotlinx.coroutines.isActive
12 | import kotlinx.coroutines.launch
13 |
14 | @Service(Service.Level.APP)
15 | class IdeaToNvimBus(
16 | private val scope: CoroutineScope,
17 | ) : Disposable {
18 | private val channel = Channel(Channel.UNLIMITED)
19 |
20 | fun tryEmit(event: IdeaToNvimEvent) {
21 | channel.trySend(event)
22 | }
23 |
24 | private val _documentChange = MutableSharedFlow()
25 | val documentChange: SharedFlow = _documentChange.asSharedFlow()
26 |
27 | private val _caretMoved = MutableSharedFlow()
28 | val caretMoved: SharedFlow = _caretMoved.asSharedFlow()
29 |
30 | private val _editorSelected = MutableSharedFlow()
31 | val editorSelected: SharedFlow = _editorSelected.asSharedFlow()
32 |
33 | private val _changeModifiable = MutableSharedFlow()
34 | val changeModifiable: SharedFlow = _changeModifiable.asSharedFlow()
35 |
36 | private val _escapeInsert = MutableSharedFlow()
37 | val escapeInsert: SharedFlow = _escapeInsert.asSharedFlow()
38 |
39 | init {
40 | scope.launch {
41 | while (isActive) {
42 | when (val event = channel.receive()) {
43 | is IdeaDocumentChange -> _documentChange.emit(event)
44 | is IdeaCaretMoved -> _caretMoved.emit(event)
45 | is EditorSelected -> _editorSelected.emit(event)
46 | is ChangeModifiable -> _changeModifiable.emit(event)
47 | is EscapeInsert -> _escapeInsert.emit(event)
48 | }
49 | }
50 | }
51 | }
52 |
53 | override fun dispose() {
54 | scope.cancel()
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/bus/IdeaToNvimEvent.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.bus
2 |
3 | import com.intellij.openapi.editor.ex.EditorEx
4 | import com.ugarosa.neovim.domain.id.BufferId
5 | import com.ugarosa.neovim.domain.position.NvimPosition
6 |
7 | sealed interface IdeaToNvimEvent
8 |
9 | data class IdeaDocumentChange(
10 | val bufferId: BufferId,
11 | val offset: Int,
12 | val oldLen: Int,
13 | val newText: String,
14 | val caret: Int,
15 | ) : IdeaToNvimEvent {
16 | val end: Int get() = offset + oldLen
17 | val caretInside get() = caret in offset..end
18 | val delta get() = newText.length - oldLen
19 | val lines: List get() = newText.replace("\r\n", "\n").split("\n")
20 | }
21 |
22 | data class IdeaCaretMoved(
23 | val bufferId: BufferId,
24 | val pos: NvimPosition,
25 | val offset: Int,
26 | ) : IdeaToNvimEvent
27 |
28 | data class EditorSelected(
29 | val editor: EditorEx,
30 | ) : IdeaToNvimEvent
31 |
32 | data class ChangeModifiable(
33 | val editor: EditorEx,
34 | ) : IdeaToNvimEvent
35 |
36 | data class EscapeInsert(
37 | val editor: EditorEx,
38 | ) : IdeaToNvimEvent
39 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/bus/NvimToIdeaBus.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.bus
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.Service
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.cancel
7 | import kotlinx.coroutines.channels.Channel
8 | import kotlinx.coroutines.flow.MutableSharedFlow
9 | import kotlinx.coroutines.flow.SharedFlow
10 | import kotlinx.coroutines.flow.asSharedFlow
11 | import kotlinx.coroutines.isActive
12 | import kotlinx.coroutines.launch
13 | import kotlinx.coroutines.withTimeoutOrNull
14 | import kotlin.time.Duration
15 | import kotlin.time.Duration.Companion.milliseconds
16 |
17 | @Service(Service.Level.APP)
18 | class NvimToIdeaBus(
19 | private val scope: CoroutineScope,
20 | ) : Disposable {
21 | private val batchWindow: Duration = 20.milliseconds
22 | private val channel = Channel(Channel.UNLIMITED)
23 |
24 | fun tryEmit(event: NvimToIdeaEvent) {
25 | channel.trySend(event)
26 | }
27 |
28 | private val _batchedBufLines = MutableSharedFlow>()
29 | val batchedBufLines: SharedFlow> = _batchedBufLines.asSharedFlow()
30 |
31 | private val _latestCursor = MutableSharedFlow()
32 | val latestCursor: SharedFlow = _latestCursor.asSharedFlow()
33 |
34 | private val _latestMode = MutableSharedFlow()
35 | val latestMode: SharedFlow = _latestMode.asSharedFlow()
36 |
37 | private val _latestVisualSelection = MutableSharedFlow()
38 | val latestVisualSelection: SharedFlow = _latestVisualSelection.asSharedFlow()
39 |
40 | init {
41 | scope.launch {
42 | val bufBucket = mutableListOf()
43 | var cursorShadow: NvimCursorMoved? = null
44 | var modeShadow: ModeChanged? = null
45 | var visualShadow: VisualSelectionChanged? = null
46 |
47 | suspend fun flush() {
48 | if (bufBucket.isNotEmpty()) {
49 | _batchedBufLines.emit(bufBucket.toList())
50 | bufBucket.clear()
51 | }
52 | cursorShadow?.let {
53 | _latestCursor.emit(it)
54 | cursorShadow = null
55 | }
56 | modeShadow?.let {
57 | _latestMode.emit(it)
58 | modeShadow = null
59 | }
60 | visualShadow?.let {
61 | _latestVisualSelection.emit(it)
62 | visualShadow = null
63 | }
64 | }
65 |
66 | fun received(event: NvimToIdeaEvent) {
67 | when (event) {
68 | is NvimBufLines -> bufBucket.add(event)
69 |
70 | is NvimCursorMoved -> cursorShadow = event
71 |
72 | is ModeChanged -> modeShadow = event
73 |
74 | is VisualSelectionChanged -> visualShadow = event
75 | }
76 | }
77 |
78 | while (isActive) {
79 | val first = channel.receive()
80 | received(first)
81 |
82 | while (true) {
83 | val next = withTimeoutOrNull(batchWindow) { channel.receive() }
84 | if (next == null) break
85 | received(next)
86 | }
87 | flush()
88 | }
89 | }
90 | }
91 |
92 | override fun dispose() {
93 | scope.cancel()
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/bus/NvimToIdeaEvent.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.bus
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.domain.mode.NvimMode
5 | import com.ugarosa.neovim.domain.position.NvimPosition
6 | import com.ugarosa.neovim.domain.position.NvimRegion
7 |
8 | sealed interface NvimToIdeaEvent
9 |
10 | data class NvimBufLines(
11 | val bufferId: BufferId,
12 | val changedTick: Long,
13 | val firstLine: Int,
14 | val lastLine: Int,
15 | val replacementLines: List,
16 | ) : NvimToIdeaEvent
17 |
18 | data class NvimCursorMoved(
19 | val bufferId: BufferId,
20 | val pos: NvimPosition,
21 | ) : NvimToIdeaEvent
22 |
23 | data class ModeChanged(
24 | val bufferId: BufferId,
25 | val mode: NvimMode,
26 | ) : NvimToIdeaEvent
27 |
28 | data class VisualSelectionChanged(
29 | val bufferId: BufferId,
30 | val regions: List,
31 | ) : NvimToIdeaEvent
32 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/common/FocusUtil.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.common
2 |
3 | import com.intellij.ide.DataManager
4 | import com.intellij.openapi.actionSystem.CommonDataKeys
5 | import com.intellij.openapi.actionSystem.DataContext
6 | import com.intellij.openapi.application.EDT
7 | import com.intellij.openapi.editor.ex.EditorEx
8 | import com.intellij.openapi.project.Project
9 | import com.intellij.openapi.wm.IdeFocusManager
10 | import com.ugarosa.neovim.logger.MyLogger
11 | import kotlinx.coroutines.Dispatchers
12 | import kotlinx.coroutines.withContext
13 |
14 | private val logger = MyLogger.getInstance("com.ugarosa.neovim.common.FocusUtils")
15 |
16 | // This function is not safe to call from a non-EDT thread.
17 | fun unsafeFocusEditor(): EditorEx? {
18 | val dataContext = getFocusContext() ?: return null
19 | val editor = CommonDataKeys.EDITOR.getData(dataContext)
20 | return editor as? EditorEx
21 | }
22 |
23 | suspend fun focusEditor(): EditorEx? = withContext(Dispatchers.EDT) { unsafeFocusEditor() }
24 |
25 | suspend fun focusProject(): Project? =
26 | withContext(Dispatchers.EDT) {
27 | val dataContext = getFocusContext() ?: return@withContext null
28 | CommonDataKeys.PROJECT.getData(dataContext)
29 | }
30 |
31 | private fun getFocusContext(): DataContext? {
32 | val focusOwner =
33 | IdeFocusManager.getGlobalInstance().focusOwner
34 | ?: return null
35 | return try {
36 | DataManager.getInstance().getDataContext(focusOwner)
37 | } catch (e: Exception) {
38 | logger.warn("Failed to get DataContext", e)
39 | null
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/common/FontSize.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.common
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.editor.colors.EditorFontType
5 | import com.intellij.openapi.editor.ex.EditorEx
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.withContext
8 |
9 | data class FontSize(
10 | val width: Int,
11 | val height: Int,
12 | ) {
13 | companion object {
14 | suspend fun fromEditorEx(editor: EditorEx): FontSize =
15 | withContext(Dispatchers.EDT) {
16 | val font = editor.colorsScheme.getFont(EditorFontType.PLAIN)
17 | val metrics = editor.contentComponent.getFontMetrics(font)
18 | val width = metrics.charWidth('W')
19 | val height = metrics.height
20 | FontSize(width, height)
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/common/GridSize.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.common
2 |
3 | import com.intellij.openapi.application.EDT
4 | import com.intellij.openapi.editor.ex.EditorEx
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.withContext
7 |
8 | data class GridSize(
9 | val width: Int,
10 | val height: Int,
11 | ) {
12 | companion object {
13 | suspend fun fromEditorEx(editor: EditorEx): GridSize =
14 | withContext(Dispatchers.EDT) {
15 | val fontSize = FontSize.fromEditorEx(editor)
16 | val charWidth = fontSize.width
17 | val lineHeight = editor.lineHeight
18 |
19 | val (pixelW, pixelH) = editor.contentComponent.visibleRect.let { it.width to it.height }
20 | val width = (pixelW / charWidth).coerceAtLeast(1)
21 | val height = (pixelH / lineHeight).coerceAtLeast(1)
22 |
23 | GridSize(width, height)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/NvimOption.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config
2 |
3 | import com.ugarosa.neovim.config.nvim.option.Filetype
4 | import com.ugarosa.neovim.config.nvim.option.Scrolloff
5 | import com.ugarosa.neovim.config.nvim.option.Selection
6 | import com.ugarosa.neovim.config.nvim.option.Sidescrolloff
7 |
8 | data class NvimOption(
9 | val filetype: Filetype,
10 | val selection: Selection,
11 | val scrolloff: Scrolloff,
12 | val sidescrolloff: Sidescrolloff,
13 | )
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/idea/NvimKeymapConfigurable.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.idea
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.options.Configurable
5 | import com.intellij.ui.ToolbarDecorator
6 | import com.intellij.ui.table.TableView
7 | import com.intellij.util.ui.ColumnInfo
8 | import com.intellij.util.ui.ListTableModel
9 | import com.ugarosa.neovim.adapter.idea.input.notation.NeovimKeyNotation
10 | import java.awt.BorderLayout
11 | import javax.swing.JComponent
12 | import javax.swing.JPanel
13 |
14 | class NvimKeymapConfigurable : Configurable {
15 | private val panel = JPanel(BorderLayout())
16 | private val tableModel: ListTableModel
17 | private val table: TableView
18 | private val settings = service()
19 |
20 | init {
21 | // Define columns: Mode, LHS, RHS
22 | val modesColumn =
23 | object : ColumnInfo("Mode") {
24 | override fun valueOf(row: Row) = row.mode
25 |
26 | override fun isCellEditable(row: Row) = true
27 |
28 | override fun setValue(
29 | row: Row,
30 | value: String,
31 | ) {
32 | row.mode = value
33 | }
34 | }
35 | val lhsColumn =
36 | object : ColumnInfo("LHS") {
37 | override fun valueOf(row: Row) = row.lhs
38 |
39 | override fun isCellEditable(row: Row) = true
40 |
41 | override fun setValue(
42 | row: Row,
43 | value: String,
44 | ) {
45 | row.lhs = value
46 | }
47 | }
48 | val rhsColumn =
49 | object : ColumnInfo("RHS") {
50 | override fun valueOf(row: Row) = row.rhs
51 |
52 | override fun isCellEditable(row: Row) = true
53 |
54 | override fun setValue(
55 | row: Row,
56 | value: String,
57 | ) {
58 | row.rhs = value
59 | }
60 | }
61 |
62 | tableModel = ListTableModel(modesColumn, lhsColumn, rhsColumn)
63 | table = TableView(tableModel).apply { rowHeight = 25 }
64 |
65 | // Add/remove buttons
66 | val decorator =
67 | ToolbarDecorator.createDecorator(table)
68 | .setAddAction {
69 | tableModel.addRow(Row("n", "", ""))
70 | }
71 | .setRemoveAction {
72 | table.selectedRows.sortedDescending().forEach { tableModel.removeRow(it) }
73 | }
74 | panel.add(decorator.createPanel(), BorderLayout.CENTER)
75 | }
76 |
77 | override fun getDisplayName() = "Neovim Keymap"
78 |
79 | override fun createComponent(): JComponent {
80 | reset()
81 | return panel
82 | }
83 |
84 | override fun isModified(): Boolean {
85 | return settings.getUserKeyMappings() != toUserKeyMappings()
86 | }
87 |
88 | override fun apply() {
89 | settings.setUserKeyMappings(toUserKeyMappings())
90 | }
91 |
92 | override fun reset() {
93 | val rows =
94 | settings.getUserKeyMappings().map { ukm ->
95 | Row(
96 | ukm.mode.value,
97 | ukm.lhs.joinToString("") { it.toString() },
98 | ukm.rhs.joinToString("") { it.toString() },
99 | )
100 | }
101 | tableModel.items = rows
102 | }
103 |
104 | override fun disposeUIResources() {}
105 |
106 | private fun toUserKeyMappings(): List =
107 | tableModel.items.map { row ->
108 | UserKeyMapping(
109 | mode = MapMode(row.mode),
110 | lhs = NeovimKeyNotation.parseNotations(row.lhs),
111 | rhs = KeyMappingAction.parseNotations(row.rhs),
112 | )
113 | }
114 |
115 | private data class Row(
116 | var mode: String,
117 | var lhs: String,
118 | var rhs: String,
119 | )
120 | }
121 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/idea/NvimKeymapSettings.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.idea
2 |
3 | import com.intellij.openapi.actionSystem.IdeActions
4 | import com.intellij.openapi.components.RoamingType
5 | import com.intellij.openapi.components.SerializablePersistentStateComponent
6 | import com.intellij.openapi.components.Service
7 | import com.intellij.openapi.components.State
8 | import com.intellij.openapi.components.Storage
9 | import com.intellij.util.xmlb.annotations.XCollection
10 | import com.ugarosa.neovim.adapter.idea.action.NvimEscapeAction
11 | import com.ugarosa.neovim.adapter.idea.input.notation.NeovimKeyNotation
12 |
13 | @Service(Service.Level.APP)
14 | @State(
15 | name = "NeovimKeymapSettings",
16 | storages = [
17 | Storage(value = "neovim_keymap.xml", roamingType = RoamingType.DEFAULT),
18 | ],
19 | )
20 | class NvimKeymapSettings :
21 | SerializablePersistentStateComponent(State()) {
22 | data class State(
23 | @JvmField @XCollection val mappings: List = defaultMappings(),
24 | )
25 |
26 | fun getUserKeyMappings(): List = state.mappings
27 |
28 | fun setUserKeyMappings(mappings: List) {
29 | updateState { it.copy(mappings = mappings) }
30 | }
31 |
32 | companion object {
33 | private fun defaultMappings(): List {
34 | return buildList {
35 | fun mapNvim(
36 | mode: String,
37 | lhsStr: String,
38 | rhsStr: String,
39 | ) {
40 | val lhs = NeovimKeyNotation.parseNotations(lhsStr)
41 | val rhs =
42 | NeovimKeyNotation.parseNotations(rhsStr)
43 | .map { KeyMappingAction.SendToNeovim(it) }
44 | add(UserKeyMapping(MapMode(mode), lhs, rhs))
45 | }
46 |
47 | fun mapIdea(
48 | mode: String,
49 | lhsStr: String,
50 | actionId: String,
51 | ) {
52 | val lhs = NeovimKeyNotation.parseNotations(lhsStr)
53 | val rhs = listOf(KeyMappingAction.ExecuteIdeaAction(actionId))
54 | add(UserKeyMapping(MapMode(mode), lhs, rhs))
55 | }
56 |
57 | // Insert mode Esc
58 | mapIdea("i", "", NvimEscapeAction.ACTION_ID)
59 | // Undo/Redo
60 | mapIdea("n", "u", IdeActions.ACTION_UNDO)
61 | mapIdea("n", "", IdeActions.ACTION_REDO)
62 | // Folding
63 | mapIdea("n", "zo", IdeActions.ACTION_EXPAND_REGION)
64 | mapIdea("n", "zO", IdeActions.ACTION_EXPAND_REGION_RECURSIVELY)
65 | mapIdea("n", "zc", IdeActions.ACTION_COLLAPSE_REGION)
66 | mapIdea("n", "zC", IdeActions.ACTION_COLLAPSE_REGION_RECURSIVELY)
67 | mapIdea("n", "za", IdeActions.ACTION_EXPAND_COLLAPSE_TOGGLE_REGION)
68 | mapNvim("n", "zA", "")
69 | mapNvim("n", "zv", "")
70 | mapNvim("n", "zx", "")
71 | mapNvim("n", "zX", "")
72 | mapNvim("n", "zm", "")
73 | mapIdea("n", "zM", IdeActions.ACTION_COLLAPSE_ALL_REGIONS)
74 | mapIdea("n", "zr", IdeActions.ACTION_EXPAND_ALL_TO_LEVEL_1)
75 | mapIdea("n", "zR", IdeActions.ACTION_EXPAND_ALL_REGIONS)
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/idea/UserKeyMapping.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.idea
2 |
3 | import com.intellij.util.xmlb.annotations.Attribute
4 | import com.intellij.util.xmlb.annotations.Tag
5 | import com.intellij.util.xmlb.annotations.XCollection
6 | import com.ugarosa.neovim.adapter.idea.input.notation.NeovimKeyNotation
7 | import com.ugarosa.neovim.domain.mode.NvimModeKind
8 |
9 | data class UserKeyMapping(
10 | @Tag
11 | val mode: MapMode,
12 | @XCollection(
13 | propertyElementName = "lhs",
14 | elementName = "key",
15 | elementTypes = [NeovimKeyNotation::class],
16 | )
17 | val lhs: List,
18 | @XCollection(
19 | propertyElementName = "rhs",
20 | elementName = "action",
21 | elementTypes = [KeyMappingAction.SendToNeovim::class, KeyMappingAction.ExecuteIdeaAction::class],
22 | )
23 | val rhs: List,
24 | ) {
25 | @Suppress("unused")
26 | constructor() : this(mode = MapMode(), lhs = emptyList(), rhs = emptyList())
27 | }
28 |
29 | data class MapMode(
30 | @Attribute val value: String,
31 | ) {
32 | constructor() : this("")
33 |
34 | fun toModeKinds(): List {
35 | if (value.isEmpty()) return listOf(NvimModeKind.NORMAL, NvimModeKind.VISUAL, NvimModeKind.SELECT)
36 | return value.toList().flatMap {
37 | when (it) {
38 | 'n' -> listOf(NvimModeKind.NORMAL)
39 | 'v' -> listOf(NvimModeKind.VISUAL, NvimModeKind.SELECT)
40 | 'x' -> listOf(NvimModeKind.VISUAL)
41 | 's' -> listOf(NvimModeKind.SELECT)
42 | '!' -> listOf(NvimModeKind.INSERT, NvimModeKind.COMMAND)
43 | 'i' -> listOf(NvimModeKind.INSERT)
44 | 'c' -> listOf(NvimModeKind.COMMAND)
45 | else -> emptyList()
46 | }
47 | }
48 | }
49 | }
50 |
51 | sealed class KeyMappingAction {
52 | data class SendToNeovim(
53 | @Tag val key: NeovimKeyNotation,
54 | ) : KeyMappingAction() {
55 | @Suppress("unused")
56 | constructor() : this(NeovimKeyNotation())
57 |
58 | override fun toString(): String {
59 | return key.toString()
60 | }
61 | }
62 |
63 | data class ExecuteIdeaAction(
64 | @Attribute val actionId: String,
65 | ) : KeyMappingAction() {
66 | @Suppress("unused")
67 | constructor() : this("")
68 |
69 | override fun toString(): String {
70 | return "($actionId)"
71 | }
72 | }
73 |
74 | companion object {
75 | // This regex will match: (actionId)
76 | private val regex = Regex("""\(([^)]+)\)""")
77 |
78 | fun parseNotations(notations: String): List {
79 | return NeovimKeyNotation.parseNotations(notations)
80 | .map { notation ->
81 | val mr = regex.find(notation.toString())
82 | if (mr != null) {
83 | val actionId = mr.groupValues[1]
84 | ExecuteIdeaAction(actionId)
85 | } else {
86 | SendToNeovim(notation)
87 | }
88 | }
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/NvimGlobalOptionsManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.config.nvim.option.Scrolloff
5 | import com.ugarosa.neovim.config.nvim.option.Selection
6 | import com.ugarosa.neovim.config.nvim.option.Sidescrolloff
7 | import com.ugarosa.neovim.logger.myLogger
8 | import com.ugarosa.neovim.rpc.client.NvimClient
9 | import com.ugarosa.neovim.rpc.client.api.getGlobalOption
10 | import kotlinx.coroutines.sync.Mutex
11 | import kotlinx.coroutines.sync.withLock
12 |
13 | interface NeovimGlobalOptions {
14 | val selection: Selection
15 | val scrolloff: Scrolloff
16 | val sidescrolloff: Sidescrolloff
17 | }
18 |
19 | private data class MutableNeovimGlobalOptions(
20 | override var selection: Selection = Selection.default,
21 | override var scrolloff: Scrolloff = Scrolloff.default,
22 | override var sidescrolloff: Sidescrolloff = Sidescrolloff.default,
23 | ) : NeovimGlobalOptions
24 |
25 | class NeovimGlobalOptionsManager() {
26 | private val logger = myLogger()
27 | private val client = service()
28 | private val mutex = Mutex()
29 | private val options = MutableNeovimGlobalOptions()
30 | private val setters: Map Unit> =
31 | mapOf(
32 | "selection" to { raw -> options.selection = Selection.fromRaw(raw) },
33 | "scrolloff" to { raw -> options.scrolloff = Scrolloff.fromRaw(raw) },
34 | "sidescrolloff" to { raw -> options.sidescrolloff = Sidescrolloff.fromRaw(raw) },
35 | )
36 |
37 | suspend fun initialize() {
38 | logger.trace("Initializing global options")
39 | val globalOptions = client.getGlobalOption()
40 | putAll(globalOptions)
41 | }
42 |
43 | suspend fun get(): NeovimGlobalOptions = mutex.withLock { options.copy() }
44 |
45 | suspend fun putAll(options: Map) {
46 | mutex.withLock {
47 | options.forEach { (name, raw) ->
48 | setters[name]?.invoke(raw)
49 | ?: logger.info("Unknown option name: $name")
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/NvimLocalOptionsManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim
2 |
3 | import com.intellij.openapi.components.service
4 | import com.ugarosa.neovim.config.nvim.option.Filetype
5 | import com.ugarosa.neovim.config.nvim.option.Scrolloff
6 | import com.ugarosa.neovim.config.nvim.option.Sidescrolloff
7 | import com.ugarosa.neovim.domain.id.BufferId
8 | import com.ugarosa.neovim.logger.myLogger
9 | import com.ugarosa.neovim.rpc.client.NvimClient
10 | import com.ugarosa.neovim.rpc.client.api.getLocalOption
11 | import kotlinx.coroutines.sync.Mutex
12 | import kotlinx.coroutines.sync.withLock
13 |
14 | interface NeovimLocalOptions {
15 | val filetype: Filetype
16 | val scrolloff: Scrolloff?
17 | val sidescrolloff: Sidescrolloff?
18 | }
19 |
20 | private data class MutableNeovimLocalOptions(
21 | override var filetype: Filetype = Filetype.default,
22 | override var scrolloff: Scrolloff? = null,
23 | override var sidescrolloff: Sidescrolloff? = null,
24 | ) : NeovimLocalOptions
25 |
26 | class NeovimLocalOptionsManager() {
27 | private val logger = myLogger()
28 | private val client = service()
29 | private val mutex = Mutex()
30 | private val options = MutableNeovimLocalOptions()
31 | private val setters: Map Unit> =
32 | mapOf(
33 | "filetype" to { raw -> options.filetype = Filetype.fromRaw(raw) },
34 | "scrolloff" to { raw -> options.scrolloff = Scrolloff.fromRaw(raw) },
35 | "sidescrolloff" to { raw -> options.sidescrolloff = Sidescrolloff.fromRaw(raw) },
36 | )
37 |
38 | suspend fun initialize(bufferId: BufferId) {
39 | logger.trace("Initializing local options for buffer: $bufferId")
40 | val localOptions = client.getLocalOption(bufferId)
41 | putAll(localOptions)
42 | }
43 |
44 | suspend fun get(): NeovimLocalOptions = mutex.withLock { options.copy() }
45 |
46 | suspend fun putAll(options: Map) {
47 | mutex.withLock {
48 | options.forEach { (name, raw) ->
49 | setters[name]?.invoke(raw)
50 | ?: logger.warn("Invalid option name: $name")
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/NvimOptionManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.ugarosa.neovim.config.NvimOption
5 | import com.ugarosa.neovim.config.nvim.option.Filetype
6 | import com.ugarosa.neovim.config.nvim.option.getOrElse
7 | import com.ugarosa.neovim.domain.id.BufferId
8 | import com.ugarosa.neovim.logger.myLogger
9 | import kotlinx.coroutines.CompletableDeferred
10 | import java.util.concurrent.ConcurrentHashMap
11 |
12 | @Service(Service.Level.APP)
13 | class NvimOptionManager {
14 | private val logger = myLogger()
15 |
16 | private val globalOptionsManager = NeovimGlobalOptionsManager()
17 | private val globalInit = CompletableDeferred()
18 |
19 | private val localOptionsManagers = ConcurrentHashMap()
20 | private val localInits = ConcurrentHashMap>()
21 |
22 | suspend fun initializeGlobal() {
23 | globalOptionsManager.initialize()
24 | globalInit.complete(Unit)
25 | }
26 |
27 | suspend fun initializeLocal(bufferId: BufferId) {
28 | val initDeferred = CompletableDeferred()
29 | localInits[bufferId] = initDeferred
30 | val localOptionsManager = NeovimLocalOptionsManager().apply { initialize(bufferId) }
31 | localOptionsManagers[bufferId] = localOptionsManager
32 | initDeferred.complete(Unit)
33 | }
34 |
35 | suspend fun getGlobal(): NeovimGlobalOptions {
36 | globalInit.await()
37 | return globalOptionsManager.get()
38 | }
39 |
40 | suspend fun getLocal(bufferId: BufferId): NvimOption {
41 | globalInit.await()
42 | localInits[bufferId]?.await() ?: error("Buffer $bufferId is not initialized")
43 |
44 | val globalOptions = globalOptionsManager.get()
45 | val localOptions = localOptionsManagers[bufferId]?.get()
46 |
47 | return NvimOption(
48 | filetype = localOptions?.filetype ?: Filetype.default,
49 | selection = globalOptions.selection,
50 | scrolloff = localOptions?.scrolloff.getOrElse(globalOptions.scrolloff),
51 | sidescrolloff = localOptions?.sidescrolloff.getOrElse(globalOptions.sidescrolloff),
52 | )
53 | }
54 |
55 | suspend fun putGlobal(
56 | key: String,
57 | value: Any,
58 | ) {
59 | globalInit.await()
60 | logger.trace("Set a global option: $key = $value")
61 | globalOptionsManager.putAll(mapOf(key to value))
62 | }
63 |
64 | suspend fun putLocal(
65 | bufferId: BufferId,
66 | key: String,
67 | value: Any,
68 | ) {
69 | localInits[bufferId]?.await() ?: error("Buffer $bufferId is not initialized")
70 | logger.trace("Set a local option: $key = $value")
71 | localOptionsManagers[bufferId]?.putAll(mapOf(key to value))
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/option/GlobalOnlyOptions.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim.option
2 |
3 | enum class Selection(val value: String) {
4 | INCLUSIVE("inclusive"),
5 | EXCLUSIVE("exclusive"),
6 | OLD("old"),
7 | ;
8 |
9 | companion object : RawOptionParser {
10 | override fun fromRaw(raw: Any): Selection {
11 | val stringValue = raw.toString()
12 | return entries.firstOrNull { it.value == stringValue }
13 | ?: throw IllegalArgumentException("Invalid value for Selection: $raw")
14 | }
15 |
16 | override val default = INCLUSIVE
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/option/LocalOnlyOptions.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim.option
2 |
3 | @JvmInline
4 | value class Filetype(val value: String) {
5 | companion object : RawOptionParser {
6 | override fun fromRaw(raw: Any): Filetype {
7 | val stringValue = raw.toString()
8 | return Filetype(stringValue)
9 | }
10 |
11 | override val default = Filetype("")
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/option/RawOptionParser.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim.option
2 |
3 | interface RawOptionParser {
4 | fun fromRaw(raw: Any): T
5 |
6 | val default: T
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/config/nvim/option/SharedOptions.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.config.nvim.option
2 |
3 | fun Scrolloff?.getOrElse(default: Scrolloff): Scrolloff =
4 | if (this == null || this.value < 0) {
5 | default
6 | } else {
7 | this
8 | }
9 |
10 | @JvmInline
11 | value class Scrolloff(val value: Int) {
12 | companion object : RawOptionParser {
13 | override fun fromRaw(raw: Any): Scrolloff {
14 | val intValue =
15 | (raw as? Number)?.toInt()
16 | ?: throw IllegalArgumentException("Invalid value for Scrolloff: $raw")
17 | return Scrolloff(intValue)
18 | }
19 |
20 | override val default = Scrolloff(0)
21 | }
22 | }
23 |
24 | fun Sidescrolloff?.getOrElse(default: Sidescrolloff): Sidescrolloff =
25 | if (this == null || this.value < 0) {
26 | default
27 | } else {
28 | this
29 | }
30 |
31 | @JvmInline
32 | value class Sidescrolloff(val value: Int) {
33 | companion object : RawOptionParser {
34 | override fun fromRaw(raw: Any): Sidescrolloff {
35 | val intValue =
36 | (raw as? Number)?.toInt()
37 | ?: throw IllegalArgumentException("Invalid value for Scrolloff: $raw")
38 | return Sidescrolloff(intValue)
39 | }
40 |
41 | override val default = Sidescrolloff(0)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/buffer/RepeatableChange.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.buffer
2 |
3 | import com.ugarosa.neovim.bus.IdeaDocumentChange
4 |
5 | data class RepeatableChange(
6 | var anchor: Int,
7 | var leftDel: Int,
8 | var rightDel: Int,
9 | val body: StringBuilder,
10 | ) {
11 | val start: Int get() = anchor - leftDel
12 | val end: Int get() = anchor + body.length + rightDel
13 | val delta: Int get() = body.length - (leftDel + rightDel)
14 | val ignoreTickIncrement = leftDel + rightDel + if (body.isEmpty()) 0 else 1
15 |
16 | fun overlap(c: IdeaDocumentChange): Boolean {
17 | return !(c.end < start || c.offset > end)
18 | }
19 |
20 | fun merge(c: IdeaDocumentChange) {
21 | var caretInBody = c.caret - start
22 |
23 | val needL = c.caret - c.offset
24 | val overL = needL - caretInBody
25 | if (overL > 0) {
26 | body.delete(0, caretInBody)
27 | leftDel += overL
28 | caretInBody = 0
29 | } else {
30 | body.delete(caretInBody - needL, caretInBody)
31 | caretInBody -= needL
32 | }
33 |
34 | val needR = c.end - c.caret
35 | val overR = needR - (body.length - caretInBody)
36 | if (overR > 0) {
37 | body.delete(caretInBody, body.length)
38 | rightDel += overR
39 | } else {
40 | body.delete(caretInBody, caretInBody + needR)
41 | }
42 |
43 | body.insert(caretInBody, c.newText)
44 | }
45 | }
46 |
47 | data class FixedChange(
48 | val start: Int,
49 | val end: Int,
50 | val replacement: List,
51 | )
52 |
53 | fun splitDocumentChanges(changes: List): Pair, RepeatableChange?> {
54 | var block: RepeatableChange? = null
55 | val queue = mutableListOf()
56 |
57 | for (c in changes) {
58 | when {
59 | block == null && c.caretInside -> {
60 | block =
61 | RepeatableChange(
62 | anchor = c.caret,
63 | leftDel = c.caret - c.offset,
64 | rightDel = c.end - c.caret,
65 | body = StringBuilder(c.newText),
66 | )
67 | }
68 |
69 | block != null && block.overlap(c) -> {
70 | block.merge(c)
71 | }
72 |
73 | block == null || c.end <= block.start -> {
74 | queue.add(
75 | FixedChange(
76 | start = c.offset,
77 | end = c.end,
78 | replacement = c.lines,
79 | ),
80 | )
81 | block?.apply {
82 | anchor += c.delta
83 | }
84 | }
85 |
86 | else -> {
87 | queue.add(
88 | FixedChange(
89 | start = c.offset - block.delta,
90 | end = c.end - block.delta,
91 | replacement = c.lines,
92 | ),
93 | )
94 | }
95 | }
96 | }
97 |
98 | return queue to block
99 | }
100 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/highlight/HighlightAttribute.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.highlight
2 |
3 | import com.intellij.openapi.editor.markup.EffectType
4 | import com.intellij.openapi.editor.markup.TextAttributes
5 | import com.intellij.ui.JBColor
6 | import java.awt.Color
7 | import java.awt.Font
8 |
9 | data class HighlightAttribute(
10 | val foreground: Color? = null,
11 | val background: Color? = null,
12 | val special: Color? = null,
13 | val reverse: Boolean = false,
14 | val italic: Boolean = false,
15 | val bold: Boolean = false,
16 | val strikethrough: Boolean = false,
17 | val underline: Boolean = false,
18 | val undercurl: Boolean = false,
19 | val underdouble: Boolean = false,
20 | val underdotted: Boolean = false,
21 | val underdashed: Boolean = false,
22 | ) {
23 | companion object {
24 | fun fromMap(map: Map): HighlightAttribute {
25 | return HighlightAttribute(
26 | foreground = (map["foreground"] as? Int)?.let { newColor(it) },
27 | background = (map["background"] as? Int)?.let { newColor(it) },
28 | special = (map["special"] as? Int)?.let { newColor(it) },
29 | reverse = map["reverse"] as? Boolean ?: false,
30 | italic = map["italic"] as? Boolean ?: false,
31 | bold = map["bold"] as? Boolean ?: false,
32 | strikethrough = map["strikethrough"] as? Boolean ?: false,
33 | underline = map["underline"] as? Boolean ?: false,
34 | undercurl = map["undercurl"] as? Boolean ?: false,
35 | underdouble = map["underdouble"] as? Boolean ?: false,
36 | underdotted = map["underdotted"] as? Boolean ?: false,
37 | underdashed = map["underdashed"] as? Boolean ?: false,
38 | )
39 | }
40 |
41 | private fun newColor(value: Int): JBColor {
42 | return JBColor(Color(value), Color(value))
43 | }
44 | }
45 |
46 | fun toTextAttributes(): TextAttributes {
47 | val attrs = TextAttributes()
48 |
49 | foreground?.let { attrs.foregroundColor = it }
50 | background?.let { attrs.backgroundColor = it }
51 |
52 | // You can merge multiple styles using bitwise OR
53 | var fontType = Font.PLAIN
54 | if (bold) fontType = fontType or Font.BOLD
55 | if (italic) fontType = fontType or Font.ITALIC
56 | attrs.fontType = fontType
57 |
58 | special?.let { attrs.effectColor = it }
59 |
60 | attrs.effectType =
61 | when {
62 | strikethrough -> EffectType.STRIKEOUT
63 | underline -> EffectType.LINE_UNDERSCORE
64 | undercurl -> EffectType.WAVE_UNDERSCORE
65 | underdouble -> EffectType.LINE_UNDERSCORE
66 | underdotted -> EffectType.BOLD_DOTTED_LINE
67 | underdashed -> EffectType.BOLD_LINE_UNDERSCORE
68 | else -> null
69 | }
70 |
71 | if (reverse) {
72 | val fg = attrs.foregroundColor
73 | attrs.foregroundColor = attrs.backgroundColor
74 | attrs.backgroundColor = fg
75 | }
76 |
77 | return attrs
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/highlight/NvimHighlightManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.highlight
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.intellij.ui.JBColor
5 |
6 | @Service(Service.Level.APP)
7 | class NvimHighlightManager {
8 | val defaultForeground = JBColor.foreground()
9 | val defaultBackground = JBColor.background()
10 |
11 | private val define = mutableMapOf()
12 |
13 | init {
14 | define[0] =
15 | HighlightAttribute(
16 | foreground = defaultForeground,
17 | background = defaultBackground,
18 | )
19 | }
20 |
21 | fun defineAttr(
22 | attrId: Int,
23 | map: Map,
24 | ) {
25 | val attr = HighlightAttribute.fromMap(map)
26 | define[attrId] = attr
27 | }
28 |
29 | fun get(attrId: Int): HighlightAttribute {
30 | val highlight = define[attrId] ?: error("Highlight $attrId not defined")
31 | return highlight.copy(
32 | foreground = highlight.foreground ?: defaultForeground,
33 | background = highlight.background ?: defaultBackground,
34 | )
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/id/BufferId.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.id
2 |
3 | @JvmInline
4 | value class BufferId(val id: Long)
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/id/TabpageId.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.id
2 |
3 | @JvmInline
4 | value class TabpageId(val id: Long)
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/id/WindowId.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.id
2 |
3 | @JvmInline
4 | value class WindowId(val id: Long)
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/mode/NvimMode.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.mode
2 |
3 | import com.ugarosa.neovim.config.nvim.option.Selection
4 |
5 | data class NvimMode(
6 | val kind: NvimModeKind,
7 | ) {
8 | companion object {
9 | // The ui `mode_change` event sends the mode string, but it's not same as the return value of mode().
10 | // For example, this could be "operator", "visual", "replace" or "cmdline_normal".
11 | fun fromModeChangeEvent(raw: String): NvimMode? {
12 | val kind =
13 | when (raw[0]) {
14 | 'n', 'o' -> NvimModeKind.NORMAL
15 | 'v' -> NvimModeKind.VISUAL
16 | 'i' -> NvimModeKind.INSERT
17 | 'r' -> NvimModeKind.REPLACE
18 | 'c' -> NvimModeKind.COMMAND
19 | else -> null
20 | }
21 | return kind?.let { NvimMode(it) }
22 | }
23 |
24 | // The ui `mode_change` event does not send selection mode.
25 | // So I set autocmd for changing select mode.
26 | fun fromMode(raw: String): NvimMode? {
27 | val kind =
28 | when (raw[0]) {
29 | 'n' -> NvimModeKind.NORMAL
30 | 'v', 'V', '\u0016' -> NvimModeKind.VISUAL
31 | 's', 'S', '\u0013' -> NvimModeKind.SELECT
32 | 'i' -> NvimModeKind.INSERT
33 | 'R' -> NvimModeKind.REPLACE
34 | 'c' -> NvimModeKind.COMMAND
35 | else -> null
36 | }
37 | return kind?.let { NvimMode(it) }
38 | }
39 |
40 | val default = NvimMode(NvimModeKind.NORMAL)
41 | }
42 |
43 | fun isBlock(selection: Selection): Boolean =
44 | when (kind) {
45 | NvimModeKind.NORMAL -> true
46 |
47 | NvimModeKind.SELECT,
48 | NvimModeKind.VISUAL,
49 | -> selection == Selection.INCLUSIVE
50 |
51 | else -> false
52 | }
53 |
54 | fun isInsert(): Boolean = kind == NvimModeKind.INSERT
55 |
56 | fun isVisualOrSelect(): Boolean = kind == NvimModeKind.VISUAL || kind == NvimModeKind.SELECT
57 |
58 | fun isCommand(): Boolean = kind == NvimModeKind.COMMAND
59 | }
60 |
61 | enum class NvimModeKind {
62 | NORMAL,
63 | VISUAL,
64 | SELECT,
65 | INSERT,
66 | REPLACE,
67 | COMMAND,
68 | }
69 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/mode/NvimModeManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.mode
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.intellij.openapi.components.service
5 | import com.jetbrains.rd.util.CopyOnWriteArrayList
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.ObsoleteCoroutinesApi
8 | import kotlinx.coroutines.channels.Channel
9 | import kotlinx.coroutines.channels.actor
10 | import java.util.concurrent.atomic.AtomicReference
11 |
12 | fun getMode() = service().get()
13 |
14 | fun setMode(mode: NvimMode) = service().set(mode)
15 |
16 | private typealias OnModeChange = suspend (old: NvimMode, new: NvimMode) -> Unit
17 |
18 | @Service(Service.Level.APP)
19 | class NeovimModeManager(
20 | scope: CoroutineScope,
21 | ) {
22 | private val atomicMode = AtomicReference(NvimMode.default)
23 | private val hooks = CopyOnWriteArrayList()
24 |
25 | @OptIn(ObsoleteCoroutinesApi::class)
26 | private val actor =
27 | scope.actor>(capacity = Channel.UNLIMITED) {
28 | for ((oldMode, newMode) in channel) {
29 | for (hook in hooks) {
30 | hook(oldMode, newMode)
31 | }
32 | }
33 | }
34 |
35 | fun get(): NvimMode {
36 | return atomicMode.get()
37 | }
38 |
39 | fun set(mode: NvimMode) {
40 | val oldMode = atomicMode.getAndSet(mode)
41 | actor.trySend(oldMode to mode)
42 | }
43 |
44 | fun addHook(hook: OnModeChange) {
45 | hooks.add(hook)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/position/NvimPosition.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.position
2 |
3 | import com.intellij.openapi.editor.Document
4 | import com.intellij.openapi.util.TextRange
5 |
6 | // (0, 0) index, column is byte offset
7 | data class NvimPosition(
8 | val line: Int,
9 | val col: Int,
10 | // Adjusted to 0-based.
11 | // `:h getcurpos()`
12 | val curswant: Int = col,
13 | ) {
14 | fun toOffset(document: Document): Int {
15 | val lineStartOffset = document.getLineStartOffset(line)
16 | val lineEndOffset = document.getLineEndOffset(line)
17 | val lineText = document.getText(TextRange(lineStartOffset, lineEndOffset))
18 | val colChar = utf8ByteOffsetToCharOffset(lineText, col)
19 | return (lineStartOffset + colChar).coerceAtMost(lineEndOffset)
20 | }
21 |
22 | companion object {
23 | fun fromOffset(
24 | offset: Int,
25 | document: Document,
26 | ): NvimPosition {
27 | val line = document.getLineNumber(offset)
28 | val lineStartOffset = document.getLineStartOffset(line)
29 | val lineEndOffset = document.getLineEndOffset(line)
30 | val lineText = document.getText(TextRange(lineStartOffset, lineEndOffset))
31 | val byteCol = charOffsetToUtf8ByteOffset(lineText, offset - lineStartOffset)
32 |
33 | return NvimPosition(line, byteCol)
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/position/NvimRegion.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.position
2 |
3 | import com.intellij.openapi.editor.Document
4 |
5 | // (0, 0) index, column is byte offset, end-exclusive
6 | data class NvimRegion(
7 | val row: Int,
8 | val startColumn: Int,
9 | val endColumn: Int,
10 | ) {
11 | fun startOffset(document: Document): Int {
12 | return NvimPosition(row, startColumn).toOffset(document)
13 | }
14 |
15 | fun endOffset(document: Document): Int {
16 | val offset = NvimPosition(row, endColumn).toOffset(document)
17 | val line = document.getLineNumber(offset)
18 | val lineEndOffset = document.getLineEndOffset(line)
19 | return offset.coerceAtMost(lineEndOffset)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/domain/position/Utf8OffsetConverter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.position
2 |
3 | fun utf8ByteOffsetToCharOffset(
4 | text: String,
5 | byteOffset: Int,
6 | ): Int {
7 | var acc = 0
8 | text.forEachIndexed { i, c ->
9 | val bytes = c.toString().toByteArray(Charsets.UTF_8).size
10 | if (acc + bytes > byteOffset) return i
11 | acc += bytes
12 | }
13 | return text.length
14 | }
15 |
16 | fun charOffsetToUtf8ByteOffset(
17 | text: String,
18 | charOffset: Int,
19 | ): Int {
20 | val safeOffset = charOffset.coerceAtMost(text.length)
21 | return text.substring(0, safeOffset).toByteArray(Charsets.UTF_8).size
22 | }
23 |
24 | fun String.takeByte(byteCount: Int): String {
25 | val charCount = utf8ByteOffsetToCharOffset(this, byteCount)
26 | return this.take(charCount)
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/logger/MyLogger.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.logger
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.diagnostic.Logger
5 |
6 | inline fun T.myLogger(): MyLogger = MyLogger.getInstance(T::class.java)
7 |
8 | class MyLogger private constructor(
9 | private val delegate: Logger,
10 | ) {
11 | companion object {
12 | fun getInstance(category: String): MyLogger {
13 | return MyLogger(Logger.getInstance(category))
14 | }
15 |
16 | fun getInstance(clazz: Class<*>): MyLogger {
17 | return MyLogger(Logger.getInstance(clazz))
18 | }
19 | }
20 |
21 | private val channel = service()
22 |
23 | private fun enqueue(event: LogEvent) {
24 | if (!channel.send(event)) {
25 | delegate.warn("Log channel is full or closed: ${event.msg}")
26 | }
27 | }
28 |
29 | fun trace(msg: String) {
30 | enqueue(LogEvent.Trace(delegate, msg))
31 | }
32 |
33 | fun debug(
34 | msg: String,
35 | throwable: Throwable? = null,
36 | ) {
37 | enqueue(LogEvent.Debug(delegate, msg, throwable))
38 | }
39 |
40 | fun info(
41 | msg: String,
42 | throwable: Throwable? = null,
43 | ) {
44 | enqueue(LogEvent.Info(delegate, msg, throwable))
45 | }
46 |
47 | fun warn(
48 | msg: String,
49 | throwable: Throwable? = null,
50 | ) {
51 | enqueue(LogEvent.Warn(delegate, msg, throwable))
52 | }
53 |
54 | fun error(
55 | msg: String,
56 | throwable: Throwable? = null,
57 | ) {
58 | enqueue(LogEvent.Error(delegate, msg, throwable))
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/logger/MyLoggerChannel.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.logger
2 |
3 | import com.intellij.openapi.components.Service
4 | import com.intellij.openapi.diagnostic.Logger
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.channels.Channel
8 | import kotlinx.coroutines.launch
9 |
10 | sealed class LogEvent(val logger: Logger, val msg: String, val throwable: Throwable?) {
11 | class Trace(logger: Logger, msg: String) : LogEvent(logger, msg, null)
12 |
13 | class Debug(logger: Logger, msg: String, throwable: Throwable?) : LogEvent(logger, msg, throwable)
14 |
15 | class Info(logger: Logger, msg: String, throwable: Throwable?) : LogEvent(logger, msg, throwable)
16 |
17 | class Warn(logger: Logger, msg: String, throwable: Throwable?) : LogEvent(logger, msg, throwable)
18 |
19 | class Error(logger: Logger, msg: String, throwable: Throwable?) : LogEvent(logger, msg, throwable)
20 | }
21 |
22 | @Service(Service.Level.APP)
23 | class MyLoggerChannel(
24 | scope: CoroutineScope,
25 | ) {
26 | private val channel = Channel(Channel.UNLIMITED)
27 |
28 | init {
29 | scope.launch(Dispatchers.Default) {
30 | for (event in channel) {
31 | when (event) {
32 | is LogEvent.Trace -> event.logger.trace(event.msg)
33 | is LogEvent.Debug -> event.logger.debug(event.msg, event.throwable)
34 | is LogEvent.Info -> event.logger.info(event.msg, event.throwable)
35 | is LogEvent.Warn -> event.logger.warn(event.msg, event.throwable)
36 | is LogEvent.Error -> event.logger.error(event.msg, event.throwable)
37 | }
38 | }
39 | }
40 | }
41 |
42 | fun send(event: LogEvent): Boolean {
43 | return channel.trySend(event).isSuccess
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/NvimClient.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.components.Service
5 | import com.ugarosa.neovim.rpc.connection.NvimConnectionManager
6 | import com.ugarosa.neovim.rpc.event.NeovimEventDispatcher
7 | import com.ugarosa.neovim.rpc.event.NotificationHandler
8 | import com.ugarosa.neovim.rpc.process.NvimProcessManager
9 | import com.ugarosa.neovim.rpc.transport.NvimObject
10 | import com.ugarosa.neovim.rpc.transport.NvimTransport
11 | import kotlinx.coroutines.CompletableDeferred
12 | import kotlinx.coroutines.CoroutineScope
13 | import kotlinx.coroutines.launch
14 |
15 | @Service(Service.Level.APP)
16 | class NvimClient(
17 | val scope: CoroutineScope,
18 | ) : Disposable {
19 | private val processManager = NvimProcessManager()
20 | private val transport = NvimTransport(processManager)
21 | private val connectionManager = NvimConnectionManager(transport, scope)
22 | private val dispatcher = NeovimEventDispatcher(connectionManager, scope)
23 |
24 | internal val deferredChanId = CompletableDeferred()
25 |
26 | init {
27 | // health check
28 | scope.launch {
29 | val result = connectionManager.request("nvim_get_api_info", emptyList())
30 | val chanId = result.asArray()[0].asLong()
31 | deferredChanId.complete(chanId)
32 | }
33 | }
34 |
35 | fun register(
36 | method: String,
37 | handler: NotificationHandler,
38 | ) {
39 | dispatcher.register(method, handler)
40 | }
41 |
42 | @Suppress("unused")
43 | fun unregister(
44 | method: String,
45 | handler: NotificationHandler,
46 | ) {
47 | dispatcher.unregister(method, handler)
48 | }
49 |
50 | internal suspend fun request(
51 | method: String,
52 | args: List = emptyList(),
53 | ): NvimObject {
54 | deferredChanId.await()
55 | return connectionManager.request(method, args)
56 | }
57 |
58 | internal suspend fun notify(
59 | method: String,
60 | args: List = emptyList(),
61 | ) {
62 | deferredChanId.await()
63 | connectionManager.notify(method, args)
64 | }
65 |
66 | internal suspend fun execLua(
67 | packageName: String,
68 | method: String,
69 | args: List = emptyList(),
70 | ): NvimObject {
71 | val code = "return require('intellij.$packageName').$method(...)"
72 | return request("nvim_exec_lua", listOf(code, args))
73 | }
74 |
75 | internal suspend fun execLuaNotify(
76 | packageName: String,
77 | method: String,
78 | args: List = emptyList(),
79 | ) {
80 | val code = "require('intellij.$packageName').$method(...)"
81 | notify("nvim_exec_lua", listOf(code, args))
82 | }
83 |
84 | override fun dispose() {
85 | connectionManager.close()
86 | dispatcher.clear()
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/Buffer.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.domain.buffer.RepeatableChange
4 | import com.ugarosa.neovim.domain.id.BufferId
5 | import com.ugarosa.neovim.rpc.client.NvimClient
6 |
7 | suspend fun NvimClient.createBuffer(): BufferId {
8 | val result = request("nvim_create_buf", listOf(true, false))
9 | return result.asBufferId()
10 | }
11 |
12 | suspend fun NvimClient.bufferSetLines(
13 | bufferId: BufferId,
14 | start: Int,
15 | end: Int,
16 | lines: List,
17 | ) {
18 | // Indexing is zero-based, end-exclusive. Negative indices are interpreted as length+1+index: -1 refers to the index
19 | // past the end. So to change or delete the last line use start=-2 and end=-1.
20 | request(
21 | "nvim_buf_set_lines",
22 | listOf(bufferId, start, end, false, lines),
23 | )
24 | }
25 |
26 | suspend fun NvimClient.bufferSetText(
27 | bufferId: BufferId,
28 | start: Int,
29 | end: Int,
30 | replacement: List,
31 | ) {
32 | execLuaNotify("buffer", "set_text", listOf(bufferId, start, end, replacement))
33 | }
34 |
35 | suspend fun NvimClient.sendRepeatableChange(change: RepeatableChange) {
36 | execLuaNotify(
37 | "buffer",
38 | "send_repeatable_change",
39 | listOf(
40 | change.leftDel,
41 | change.rightDel,
42 | change.body.toString(),
43 | ),
44 | )
45 | }
46 |
47 | suspend fun NvimClient.bufferAttach(bufferId: BufferId) {
48 | request("nvim_buf_attach", listOf(bufferId, false, mapOf()))
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/Editor.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.domain.position.NvimPosition
5 | import com.ugarosa.neovim.rpc.client.NvimClient
6 |
7 | suspend fun NvimClient.input(text: String) {
8 | notify("nvim_input", listOf(text))
9 | }
10 |
11 | suspend fun NvimClient.insert(
12 | beforeDelete: Int,
13 | afterDelete: Int,
14 | inputBefore: String,
15 | inputAfter: String,
16 | ): Int =
17 | execLua("insert", "input", listOf(beforeDelete, afterDelete, inputBefore, inputAfter))
18 | .asInt()
19 |
20 | suspend fun NvimClient.setCursor(
21 | bufferId: BufferId,
22 | pos: NvimPosition,
23 | ) {
24 | execLuaNotify("buffer", "cursor", listOf(bufferId, pos.line, pos.col, pos.curswant))
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/Hook.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.rpc.client.NvimClient
4 |
5 | suspend fun NvimClient.installHook() {
6 | val chanId = deferredChanId.await()
7 | execLuaNotify("hook", "install", listOf(chanId))
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/Option.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.rpc.client.NvimClient
5 |
6 | suspend fun NvimClient.getGlobalOption(): Map {
7 | return execLua("option", "get_global").asStringMap()
8 | }
9 |
10 | suspend fun NvimClient.getLocalOption(bufferId: BufferId): Map {
11 | return execLua("option", "get_local", listOf(bufferId)).asStringMap()
12 | }
13 |
14 | suspend fun NvimClient.setFiletype(
15 | bufferId: BufferId,
16 | path: String,
17 | ) {
18 | execLua("option", "set_filetype", listOf(bufferId, path))
19 | }
20 |
21 | suspend fun NvimClient.modifiable(bufferId: BufferId) {
22 | execLua("option", "set_writable", listOf(bufferId))
23 | }
24 |
25 | suspend fun NvimClient.noModifiable(bufferId: BufferId) {
26 | execLua("option", "set_no_writable", listOf(bufferId))
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/UI.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.rpc.client.NvimClient
4 |
5 | suspend fun NvimClient.uiAttach() {
6 | request(
7 | "nvim_ui_attach",
8 | listOf(
9 | 80,
10 | 40,
11 | mapOf(
12 | "ext_multigrid" to true,
13 | "ext_cmdline" to true,
14 | "ext_messages" to true,
15 | ),
16 | ),
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/client/api/Variable.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.client.api
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.rpc.client.NvimClient
5 |
6 | const val CHANGED_TICK = "changedtick"
7 |
8 | suspend fun NvimClient.bufVar(
9 | bufferId: BufferId,
10 | name: String,
11 | ): Long {
12 | return request("nvim_buf_get_var", listOf(bufferId, name)).asLong()
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/connection/NvimConnectionManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.connection
2 |
3 | import com.ugarosa.neovim.logger.myLogger
4 | import com.ugarosa.neovim.rpc.transport.NvimObject
5 | import com.ugarosa.neovim.rpc.transport.NvimTransport
6 | import com.ugarosa.neovim.rpc.transport.RpcMessage
7 | import kotlinx.coroutines.CancellationException
8 | import kotlinx.coroutines.CompletableDeferred
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.Dispatchers
11 | import kotlinx.coroutines.SupervisorJob
12 | import kotlinx.coroutines.flow.MutableSharedFlow
13 | import kotlinx.coroutines.flow.SharedFlow
14 | import kotlinx.coroutines.isActive
15 | import kotlinx.coroutines.launch
16 | import kotlinx.coroutines.sync.Mutex
17 | import kotlinx.coroutines.sync.withLock
18 | import kotlinx.coroutines.withTimeout
19 | import java.util.concurrent.ConcurrentHashMap
20 | import java.util.concurrent.atomic.AtomicInteger
21 | import kotlin.time.Duration
22 | import kotlin.time.Duration.Companion.seconds
23 |
24 | class NvimConnectionManager(
25 | private val transport: NvimTransport,
26 | scope: CoroutineScope,
27 | private val timeout: Duration = 3.seconds,
28 | ) {
29 | private val logger = myLogger()
30 | private val msgIdGen = AtomicInteger(1)
31 | private val pending = ConcurrentHashMap>()
32 | private val sendMutex = Mutex()
33 | private val _notificationFlow = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE)
34 |
35 | val notificationFlow: SharedFlow = _notificationFlow
36 |
37 | init {
38 | scope.launch(Dispatchers.IO + SupervisorJob()) {
39 | try {
40 | while (isActive) {
41 | when (val msg = transport.receive()) {
42 | is RpcMessage.Response -> {
43 | logger.trace("Received response [${msg.id}]: $msg")
44 | pending.remove(msg.id)?.complete(msg)
45 | }
46 |
47 | is RpcMessage.Notification -> {
48 | logger.trace("Received notification: $msg")
49 | _notificationFlow.emit(msg)
50 | }
51 | }
52 | }
53 | } catch (e: CancellationException) {
54 | throw e
55 | } catch (e: Throwable) {
56 | e.printStackTrace()
57 | } finally {
58 | transport.close()
59 | pending.values.forEach { it.cancel() }
60 | pending.clear()
61 | }
62 | }
63 | }
64 |
65 | suspend fun request(
66 | method: String,
67 | params: List,
68 | ): NvimObject {
69 | val id = msgIdGen.getAndIncrement()
70 | val deferred = CompletableDeferred()
71 | pending[id] = deferred
72 | logger.trace("Request [$id]: $method, params: $params")
73 |
74 | sendMutex.withLock {
75 | transport.sendRequest(id, method, params)
76 | }
77 |
78 | val resp =
79 | withTimeout(timeout) {
80 | deferred.await()
81 | }
82 | check(resp.error is NvimObject.Nil) { "Error: ${resp.error}" }
83 | return resp.result
84 | }
85 |
86 | suspend fun notify(
87 | method: String,
88 | params: List,
89 | ) {
90 | logger.trace("Notify: $method, params: $params")
91 | sendMutex.withLock {
92 | transport.sendNotification(method, params)
93 | }
94 | }
95 |
96 | fun close() {
97 | transport.close()
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/event/NvimEventDispatcher.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.event
2 |
3 | import com.ugarosa.neovim.logger.myLogger
4 | import com.ugarosa.neovim.rpc.connection.NvimConnectionManager
5 | import com.ugarosa.neovim.rpc.transport.NvimObject
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.flow.launchIn
8 | import kotlinx.coroutines.flow.onEach
9 |
10 | typealias NotificationHandler = suspend (List) -> Unit
11 |
12 | class NeovimEventDispatcher(
13 | connectionManager: NvimConnectionManager,
14 | scope: CoroutineScope,
15 | ) {
16 | private val logger = myLogger()
17 |
18 | private val handlers = mutableMapOf>()
19 |
20 | init {
21 | connectionManager.notificationFlow
22 | .onEach { notification ->
23 | handlers[notification.method]?.forEach { handler ->
24 | try {
25 | handler(notification.params)
26 | } catch (e: Exception) {
27 | logger.warn("Error in handler for ${notification.method}", e)
28 | }
29 | }
30 | }
31 | .launchIn(scope)
32 | }
33 |
34 | fun register(
35 | method: String,
36 | handler: NotificationHandler,
37 | ) {
38 | handlers.getOrPut(method) { mutableListOf() }
39 | .add(handler)
40 | }
41 |
42 | fun unregister(
43 | method: String,
44 | handler: NotificationHandler,
45 | ) {
46 | handlers[method]?.remove(handler)
47 | }
48 |
49 | fun clear() {
50 | handlers.clear()
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/process/NvimProcessManager.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.process
2 |
3 | import com.intellij.ide.plugins.PluginManagerCore
4 | import com.intellij.openapi.extensions.PluginId
5 | import java.io.InputStream
6 | import java.io.OutputStream
7 | import java.util.concurrent.TimeUnit
8 |
9 | class NvimProcessManager {
10 | private val process: Process
11 | private val inputStream: InputStream
12 | private val outputStream: OutputStream
13 |
14 | init {
15 | val plugin = PluginManagerCore.getPlugin(PluginId.getId("com.ugarosa.neovim"))!!
16 | val rtpDir = plugin.pluginPath.resolve("runtime").toAbsolutePath().toString()
17 | val builder =
18 | ProcessBuilder(
19 | "nvim",
20 | "--embed",
21 | "--headless",
22 | "--cmd",
23 | "let g:intellij=v:true",
24 | "-c",
25 | "execute 'set rtp+=$rtpDir'",
26 | "-c",
27 | "runtime plugin/intellij.lua",
28 | )
29 | builder.redirectErrorStream(true)
30 | process = builder.start()
31 | inputStream = process.inputStream
32 | outputStream = process.outputStream
33 | }
34 |
35 | fun getInputStream() = inputStream
36 |
37 | fun getOutputStream() = outputStream
38 |
39 | fun close() {
40 | runCatching { inputStream.close() }
41 | runCatching { outputStream.close() }
42 | runCatching { process.destroy() }
43 | runCatching {
44 | if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
45 | process.destroyForcibly()
46 | }
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/transport/NvimObject.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.transport
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.domain.id.TabpageId
5 | import com.ugarosa.neovim.domain.id.WindowId
6 |
7 | /**
8 | * Response object from Neovim. :h api-types
9 | */
10 | @Suppress("unused")
11 | sealed class NvimObject() {
12 | data object Nil : NvimObject()
13 |
14 | data class Bool(val bool: Boolean) : NvimObject() {
15 | override fun toString(): String {
16 | return "$bool"
17 | }
18 | }
19 |
20 | data class Int64(val long: Long) : NvimObject() {
21 | override fun toString(): String {
22 | return "$long"
23 | }
24 | }
25 |
26 | data class Float64(val double: Double) : NvimObject() {
27 | override fun toString(): String {
28 | return "$double"
29 | }
30 | }
31 |
32 | data class Str(val str: String) : NvimObject() {
33 | override fun toString(): String {
34 | return "\"$str\""
35 | }
36 | }
37 |
38 | data class Array(val list: List) : NvimObject() {
39 | override fun toString(): String {
40 | return "$list"
41 | }
42 | }
43 |
44 | data class Dict(val map: Map) : NvimObject() {
45 | override fun toString(): String {
46 | return "$map"
47 | }
48 | }
49 |
50 | // EXT type
51 | data class Buffer(val long: Long) : NvimObject() {
52 | override fun toString(): String {
53 | return "Buffer($long)"
54 | }
55 | }
56 |
57 | data class Window(val long: Long) : NvimObject() {
58 | override fun toString(): String {
59 | return "Window($long)"
60 | }
61 | }
62 |
63 | data class Tabpage(val long: Long) : NvimObject() {
64 | override fun toString(): String {
65 | return "Tabpage($long)"
66 | }
67 | }
68 |
69 | // Utility methods
70 | fun asNull(): Nothing? = (this as Nil).let { null }
71 |
72 | fun asBool(): Boolean = (this as Bool).bool
73 |
74 | fun asInt(): Int = (this as Int64).long.toInt()
75 |
76 | fun asLong(): Long = (this as Int64).long
77 |
78 | fun asFloat(): Float = (this as Float64).double.toFloat()
79 |
80 | fun asDouble(): Double = (this as Float64).double
81 |
82 | fun asString(): String = (this as Str).str
83 |
84 | fun asArray(): List = (this as Array).list
85 |
86 | fun asDict(): Map = (this as Dict).map
87 |
88 | fun asStringMap(): Map = asDict().mapValues { it.value.asAny() }
89 |
90 | fun asBufferId(): BufferId =
91 | when (this) {
92 | is Buffer -> BufferId(long)
93 | // My custom event sends bufferId as just an Int64
94 | is Int64 -> BufferId(long)
95 | else -> error("Cannot convert $this to BufferId")
96 | }
97 |
98 | fun asWindowId(): WindowId = WindowId((this as Window).long)
99 |
100 | fun asTabpageId(): TabpageId = TabpageId((this as Tabpage).long)
101 |
102 | fun asAny(): Any {
103 | return when (this) {
104 | is Nil -> error("Nil cannot be converted to Any")
105 | is Bool -> bool
106 | is Int64 -> long
107 | is Float64 -> double
108 | is Str -> str
109 | is Array -> list.map { it.asAny() }
110 | is Dict -> map.mapValues { it.value.asAny() }
111 | is Buffer -> long
112 | is Window -> long
113 | is Tabpage -> long
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/transport/NvimTransport.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.transport
2 |
3 | import com.ugarosa.neovim.domain.id.BufferId
4 | import com.ugarosa.neovim.domain.id.TabpageId
5 | import com.ugarosa.neovim.domain.id.WindowId
6 | import com.ugarosa.neovim.rpc.process.NvimProcessManager
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.withContext
9 | import org.msgpack.core.MessagePack
10 |
11 | private const val REQUEST_TYPE = 0
12 | private const val RESPONSE_TYPE = 1
13 | private const val NOTIFICATION_TYPE = 2
14 |
15 | class NvimTransport(
16 | private val processManager: NvimProcessManager = NvimProcessManager(),
17 | ) {
18 | private val packer = MessagePack.newDefaultPacker(processManager.getOutputStream())
19 | private val unpacker = MessagePack.newDefaultUnpacker(processManager.getInputStream())
20 |
21 | suspend fun sendRequest(
22 | id: Int,
23 | method: String,
24 | params: List,
25 | ) = withContext(Dispatchers.IO) {
26 | // [type, msgId, method, params]
27 | packer.packArrayHeader(4)
28 | packer.packInt(REQUEST_TYPE)
29 | packer.packInt(id)
30 | packer.packString(method)
31 | packParams(params)
32 | packer.flush()
33 | }
34 |
35 | suspend fun sendNotification(
36 | method: String,
37 | params: List,
38 | ) = withContext(Dispatchers.IO) {
39 | // [type, method, params]
40 | packer.packArrayHeader(3)
41 | packer.packInt(NOTIFICATION_TYPE)
42 | packer.packString(method)
43 | packParams(params)
44 | packer.flush()
45 | }
46 |
47 | suspend fun receive(): RpcMessage =
48 | withContext(Dispatchers.IO) {
49 | unpacker.unpackArrayHeader()
50 | when (unpacker.unpackInt()) {
51 | RESPONSE_TYPE -> {
52 | // [type, msgId, error, result]
53 | val id = unpacker.unpackInt()
54 | val error = unpacker.unpackValue().asNeovimObject()
55 | val result = unpacker.unpackValue().asNeovimObject()
56 | RpcMessage.Response(id, result, error)
57 | }
58 |
59 | NOTIFICATION_TYPE -> {
60 | val method = unpacker.unpackString()
61 | val params = unpacker.unpackValue().asArrayValue().list().map { it.asNeovimObject() }
62 | RpcMessage.Notification(method, params)
63 | }
64 |
65 | else -> error("Unknown message type")
66 | }
67 | }
68 |
69 | fun close() {
70 | processManager.close()
71 | packer.close()
72 | unpacker.close()
73 | }
74 |
75 | private fun packParams(params: List) {
76 | packer.packArrayHeader(params.size)
77 | params.forEach { packParam(it) }
78 | }
79 |
80 | private fun packParam(param: Any?) {
81 | when (param) {
82 | is String -> packer.packString(param)
83 | is Int -> packer.packInt(param)
84 | is Long -> packer.packLong(param)
85 | is Float -> packer.packFloat(param)
86 | is Double -> packer.packDouble(param)
87 | is Boolean -> packer.packBoolean(param)
88 | is List<*> -> {
89 | packer.packArrayHeader(param.size)
90 | param.forEach { packParam(it) }
91 | }
92 |
93 | is Map<*, *> -> {
94 | packer.packMapHeader(param.size)
95 | param.forEach { (key, value) ->
96 | packParam(key)
97 | packParam(value)
98 | }
99 | }
100 |
101 | null -> packer.packNil()
102 |
103 | is BufferId -> packer.packLong(param.id)
104 | is WindowId -> packer.packLong(param.id)
105 | is TabpageId -> packer.packLong(param.id)
106 |
107 | else -> error("Unsupported param type: ${param::class}")
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/transport/RpcMessage.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.transport
2 |
3 | sealed interface RpcMessage {
4 | data class Response(
5 | val id: Int,
6 | val result: NvimObject,
7 | val error: NvimObject,
8 | ) : RpcMessage
9 |
10 | data class Notification(
11 | val method: String,
12 | val params: List,
13 | ) : RpcMessage
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/ugarosa/neovim/rpc/transport/ValueConverter.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.rpc.transport
2 |
3 | import org.msgpack.core.MessagePack
4 | import org.msgpack.value.Value
5 |
6 | fun Value.asNeovimObject(): NvimObject =
7 | when {
8 | isNilValue -> NvimObject.Nil
9 | isBooleanValue -> NvimObject.Bool(asBooleanValue().boolean)
10 | isIntegerValue -> NvimObject.Int64(asIntegerValue().toLong())
11 | isFloatValue -> NvimObject.Float64(asFloatValue().toDouble())
12 | isStringValue -> NvimObject.Str(asStringValue().toString())
13 | isArrayValue -> NvimObject.Array(asArrayValue().list().map { it.asNeovimObject() })
14 | isMapValue ->
15 | NvimObject.Dict(
16 | asMapValue().map()
17 | .mapKeys { it.key.asStringValue().asString() }
18 | .mapValues { it.value.asNeovimObject() },
19 | )
20 |
21 | isExtensionValue -> {
22 | val ext = asExtensionValue()
23 | when (ext.type) {
24 | EXT_BUFFER -> NvimObject.Buffer(ext.data.toLong())
25 | EXT_WINDOW -> NvimObject.Window(ext.data.toLong())
26 | EXT_TABPAGE -> NvimObject.Tabpage(ext.data.toLong())
27 | else -> error("Unknown EXT type ${ext.type}")
28 | }
29 | }
30 |
31 | else -> error("Unsupported MsgPack value: $this")
32 | }
33 |
34 | private const val EXT_BUFFER = 0.toByte()
35 | private const val EXT_WINDOW = 1.toByte()
36 | private const val EXT_TABPAGE = 2.toByte()
37 |
38 | private fun ByteArray.toLong(): Long = MessagePack.newDefaultUnpacker(this).unpackLong()
39 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/plugin.xml:
--------------------------------------------------------------------------------
1 |
2 | com.ugarosa.neovim
3 | Neovim
4 | uga-rosa
5 |
6 |
8 | Provides a real Neovim experience in IntelliJ IDEA.
9 | ]]>
10 |
11 | com.intellij.modules.platform
12 |
13 |
14 |
15 |
17 |
20 |
23 |
24 |
25 |
26 |
28 |
29 |
30 |
31 |
33 |
34 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/pluginIcon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main/resources/icons/green-circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/src/main/resources/icons/neovim-message@20x20.svg:
--------------------------------------------------------------------------------
1 |
2 |
15 |
--------------------------------------------------------------------------------
/src/main/resources/icons/neovim-message@20x20_dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
15 |
--------------------------------------------------------------------------------
/src/main/resources/messages/NeovimBundle.properties:
--------------------------------------------------------------------------------
1 | configurable.display.name.neovim=Neovim Keymap
--------------------------------------------------------------------------------
/src/main/resources/runtime/lua/intellij/buffer.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 |
3 | function M.cursor(bufferId, line, col, curswant)
4 | vim.opt.eventignorewin = "all"
5 | vim.api.nvim_set_current_buf(bufferId)
6 | vim.fn.cursor({ line + 1, col + 1, 0, curswant + 1 })
7 | vim.schedule(function()
8 | vim.opt.eventignorewin = ""
9 | end)
10 | end
11 |
12 | ---@param bufferId number
13 | ---@param offset number 0-based char offset
14 | ---@return number line 0-based line number
15 | ---@return number col 0-based column number
16 | local function offset_to_pos(bufferId, offset)
17 | local ff = vim.api.nvim_get_option_value("fileformat", { scope = "local" })
18 | local nl_len = ff == "dos" and 2 or 1
19 |
20 | local lines = vim.api.nvim_buf_get_lines(bufferId, 0, -1, false)
21 | local rem = offset
22 | for i, line in ipairs(lines) do
23 | local char_cnt = vim.fn.strchars(line)
24 | if rem <= char_cnt then
25 | local prefix = vim.fn.strcharpart(line, 0, rem)
26 | return i - 1, #prefix
27 | end
28 | rem = rem - (char_cnt + nl_len)
29 | end
30 | local last = #lines
31 | local tail = lines[last] or ""
32 | return last - 1, #tail
33 | end
34 |
35 | function M.set_text(bufferId, start, end_, lines)
36 | local start_line, start_col = offset_to_pos(bufferId, start)
37 | local end_line, end_col = offset_to_pos(bufferId, end_)
38 | vim.api.nvim_buf_set_text(bufferId, start_line, start_col, end_line, end_col, lines)
39 | end
40 |
41 | local bs = vim.keycode("")
42 | local del = vim.keycode("")
43 |
44 | local set_options = vim.keycode(table.concat({
45 | "setl backspace=eol,start",
46 | "setl softtabstop=0",
47 | "setl nosmarttab",
48 | "setl textwidth=0",
49 | }, ""))
50 | local reset_options = vim.keycode(table.concat({
51 | "setl backspace=%s",
52 | "setl softtabstop=%s",
53 | "setl %ssmarttab",
54 | "setl textwidth=%s",
55 | }, ""))
56 |
57 | function M.send_repeatable_change(before, after, text)
58 | local keys = ""
59 | keys = keys .. bs:rep(before)
60 | keys = keys .. del:rep(after)
61 |
62 | if before > 0 then
63 | local backspace = vim.api.nvim_get_option_value("backspace", { scope = "local" })
64 | local softtabstop = vim.api.nvim_get_option_value("softtabstop", { scope = "local" })
65 | local smarttab = vim.api.nvim_get_option_value("smarttab", { scope = "local" }) and "" or "no"
66 | local textwidth = vim.api.nvim_get_option_value("textwidth", { scope = "local" })
67 | keys = set_options .. keys .. reset_options:format(backspace, softtabstop, smarttab, textwidth)
68 | end
69 |
70 | vim.opt.eventignorewin = "all"
71 | vim.api.nvim_feedkeys(keys, "n", false)
72 | vim.api.nvim_paste(text, false, -1)
73 | vim.schedule(function()
74 | vim.opt.eventignorewin = ""
75 | end)
76 | end
77 |
78 | return M
79 |
--------------------------------------------------------------------------------
/src/main/resources/runtime/lua/intellij/hook.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 |
3 | function M.install(chan_id)
4 | M.on_cursor_moved(chan_id)
5 | M.on_option_set(chan_id)
6 | M.on_visual_selection_changed(chan_id)
7 | M.on_mode_changed(chan_id)
8 | M.create_command(chan_id)
9 | end
10 |
11 | -- CursorMoved events
12 | function M.on_cursor_moved(chan_id)
13 | local group = vim.api.nvim_create_augroup("IdeaNeovim:CursorMoved", { clear = true })
14 |
15 | local on_cursor_moved = function()
16 | local bufferId = vim.api.nvim_get_current_buf()
17 | local pos = vim.fn.getcurpos()
18 | local _, lnum, col, _, curswant = unpack(pos)
19 | -- [bufferId, line, column]
20 | vim.rpcnotify(chan_id, "IdeaNeovim:CursorMoved", bufferId, lnum - 1, col - 1, curswant - 1)
21 | end
22 |
23 | vim.api.nvim_create_autocmd("CursorMoved", {
24 | group = group,
25 | callback = on_cursor_moved,
26 | })
27 | vim.api.nvim_create_autocmd("ModeChanged", {
28 | group = group,
29 | pattern = "*:i",
30 | callback = on_cursor_moved,
31 | })
32 | end
33 |
34 | -- OptionSet events
35 | function M.on_option_set(chan_id)
36 | vim.api.nvim_create_autocmd("OptionSet", {
37 | group = vim.api.nvim_create_augroup("IdeaNeovim:OptionSet:Global", { clear = true }),
38 | pattern = { "filetype", "selection", "scrolloff", "sidescrolloff" },
39 | callback = function(event)
40 | if vim.v.option_type ~= "global" then
41 | return
42 | end
43 | -- [bufferId, scope, name, value]
44 | vim.rpcnotify(chan_id, "IdeaNeovim:OptionSet", -1, "global", event.match, vim.v.option_new)
45 | end,
46 | })
47 |
48 | local local_group = vim.api.nvim_create_augroup("IdeaNeovim:OptionSet:Local", { clear = true })
49 | vim.api.nvim_create_autocmd("BufNew", {
50 | group = local_group,
51 | callback = function(event)
52 | local buffer_id = event.buf
53 | vim.api.nvim_create_autocmd("OptionSet", {
54 | group = local_group,
55 | buffer = buffer_id,
56 | callback = function(event2)
57 | if vim.v.option_type ~= "local" then
58 | return
59 | end
60 | if
61 | not vim.tbl_contains({ "filetype", "selection", "scrolloff", "sidescrolloff" }, event2.match)
62 | then
63 | return
64 | end
65 | -- [bufferId, scope, name, value]
66 | vim.rpcnotify(chan_id, "IdeaNeovim:OptionSet", buffer_id, "local", event2.match, vim.v.option_new)
67 | end,
68 | })
69 | end,
70 | })
71 | end
72 |
73 | -- VisualSelection events
74 | function M.on_visual_selection_changed(chan_id)
75 | vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, {
76 | group = vim.api.nvim_create_augroup("IdeaNeovim:VisualSelection", { clear = true }),
77 | callback = function()
78 | local mode = vim.api.nvim_get_mode().mode:sub(1, 1)
79 | if mode:find("[vV\22]") == nil then
80 | return
81 | end
82 |
83 | local buffer_id = vim.api.nvim_get_current_buf()
84 | local vpos = vim.fn.getpos("v")
85 | local cpos = vim.fn.getpos(".")
86 | local region_pos = vim.fn.getregionpos(vpos, cpos, { type = mode })
87 | local regions = vim.iter(region_pos)
88 | :map(function(arg)
89 | local startPos, endPos = unpack(arg)
90 | -- (0, 0) index, end-exclusive
91 | return { startPos[2] - 1, startPos[3] - 1, endPos[3] }
92 | end)
93 | :totable()
94 | -- [bufferId, regions]
95 | -- regions = []{ row, start_col, end_col }
96 | vim.rpcnotify(chan_id, "IdeaNeovim:VisualSelection", buffer_id, regions)
97 | end,
98 | })
99 | end
100 |
101 | -- ModeChanged events
102 | function M.on_mode_changed(chan_id)
103 | vim.api.nvim_create_autocmd("ModeChanged", {
104 | group = vim.api.nvim_create_augroup("IdeaNeovim:ModeChanged", { clear = true }),
105 | callback = function()
106 | local buffer_id = vim.api.nvim_get_current_buf()
107 | vim.rpcnotify(chan_id, "IdeaNeovim:ModeChanged", buffer_id, vim.v.event.new_mode)
108 | end,
109 | })
110 | end
111 |
112 | -- ExecIdeaAction command
113 | function M.create_command(chan_id)
114 | vim.api.nvim_create_user_command("ExecIdeaAction", function(opt)
115 | local action_id = opt.args
116 | vim.rpcnotify(chan_id, "IdeaNeovim:ExecIdeaAction", action_id)
117 | end, {
118 | nargs = 1,
119 | })
120 | end
121 |
122 | return M
123 |
--------------------------------------------------------------------------------
/src/main/resources/runtime/lua/intellij/option.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 |
3 | function M.get_global()
4 | local opt = { scope = "global" }
5 | return {
6 | selection = vim.api.nvim_get_option_value("selection", opt),
7 | scrolloff = vim.api.nvim_get_option_value("scrolloff", opt),
8 | sidescrolloff = vim.api.nvim_get_option_value("sidescrolloff", opt),
9 | }
10 | end
11 |
12 | function M.get_local(buffer_id)
13 | local buf_opt = { scope = "local", buf = buffer_id }
14 | local win_id = vim.api.nvim_get_current_win()
15 | local win_opt = { scope = "local", win = win_id }
16 | return {
17 | filetype = vim.api.nvim_get_option_value("filetype", buf_opt),
18 | scrolloff = vim.api.nvim_get_option_value("scrolloff", win_opt),
19 | sidescrolloff = vim.api.nvim_get_option_value("sidescrolloff", win_opt),
20 | }
21 | end
22 |
23 | function M.set_filetype(buffer_id, path)
24 | local filetype = vim.filetype.match({ filename = path })
25 | vim.api.nvim_set_option_value("filetype", filetype, { scope = "local", buf = buffer_id })
26 | end
27 |
28 | function M.set_writable(buffer_id)
29 | vim.api.nvim_set_option_value("buftype", "", { scope = "local", buf = buffer_id })
30 | vim.api.nvim_set_option_value("modified", true, { scope = "local", buf = buffer_id })
31 | end
32 |
33 | function M.set_no_writable(buffer_id)
34 | vim.api.nvim_set_option_value("buftype", "nofile", { scope = "local", buf = buffer_id })
35 | vim.api.nvim_set_option_value("modified", false, { scope = "local", buf = buffer_id })
36 | end
37 |
38 | return M
39 |
--------------------------------------------------------------------------------
/src/main/resources/runtime/lua/intellij/window.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 |
3 | function M.reset()
4 | local last_win
5 | for i, win in ipairs(vim.api.nvim_list_wins()) do
6 | if i == 1 then
7 | last_win = win
8 | else
9 | vim.api.nvim_win_close(win, true)
10 | end
11 | end
12 | return last_win
13 | end
14 |
15 | return M
16 |
--------------------------------------------------------------------------------
/src/main/resources/runtime/plugin/intellij.lua:
--------------------------------------------------------------------------------
1 | local win_id = vim.api.nvim_get_current_win()
2 |
3 | -- Simplify grid conversion between Neovim and IntelliJ
4 | vim.api.nvim_set_option_value("wrap", false, { win = win_id })
5 | vim.api.nvim_set_option_value("number", false, { win = win_id })
6 | vim.api.nvim_set_option_value("signcolumn", "no", { win = win_id })
7 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/ugarosa/neovim/domain/buffer/RepeatableChange.kt:
--------------------------------------------------------------------------------
1 | package com.ugarosa.neovim.domain.buffer
2 |
3 | import com.ugarosa.neovim.bus.IdeaDocumentChange
4 | import com.ugarosa.neovim.domain.id.BufferId
5 | import io.kotest.core.spec.style.FunSpec
6 | import io.kotest.matchers.collections.shouldBeEmpty
7 | import io.kotest.matchers.nulls.shouldNotBeNull
8 | import io.kotest.matchers.shouldBe
9 |
10 | class RepeatableChangeTest : FunSpec({
11 | context("splitDocumentChanges") {
12 | val id = BufferId(1)
13 |
14 | context("Simple Typing") {
15 | val changes =
16 | listOf(
17 | IdeaDocumentChange(id, 0, 0, "a", 0),
18 | IdeaDocumentChange(id, 1, 0, "b", 1),
19 | IdeaDocumentChange(id, 2, 0, "c", 2),
20 | )
21 | val (fixed, block) = splitDocumentChanges(changes)
22 | fixed.shouldBeEmpty()
23 | block.shouldNotBeNull()
24 | block.body.toString() shouldBe "abc"
25 | }
26 |
27 | context("Typing with Deletion") {
28 | val changes =
29 | listOf(
30 | IdeaDocumentChange(id, 0, 0, "a", 0),
31 | // delete 'a' and insert 'b'
32 | IdeaDocumentChange(id, 0, 1, "b", 1),
33 | IdeaDocumentChange(id, 1, 0, "c", 1),
34 | )
35 | val (fixed, block) = splitDocumentChanges(changes)
36 | fixed.shouldBeEmpty()
37 | block.shouldNotBeNull()
38 | block.body.toString() shouldBe "bc"
39 | }
40 |
41 | context("Input char after caret") {
42 | val changes =
43 | listOf(
44 | IdeaDocumentChange(id, 0, 0, "f", 0),
45 | IdeaDocumentChange(id, 1, 0, "(", 1),
46 | IdeaDocumentChange(id, 2, 0, ")", 2),
47 | IdeaDocumentChange(id, 2, 0, "x", 2),
48 | )
49 | val (fixed, block) = splitDocumentChanges(changes)
50 | fixed.shouldBeEmpty()
51 | block.shouldNotBeNull()
52 | block.body.toString() shouldBe "f(x)"
53 | }
54 |
55 | context("Far cursor input before block") {
56 | val changes =
57 | listOf(
58 | IdeaDocumentChange(id, 10, 0, "f", 10),
59 | IdeaDocumentChange(id, 0, 0, "x".repeat(10), 11),
60 | // offset and caret are shifted, but should be merged into block
61 | IdeaDocumentChange(id, 21, 0, "(", 21),
62 | IdeaDocumentChange(id, 22, 0, ")", 22),
63 | IdeaDocumentChange(id, 22, 0, "x", 22),
64 | )
65 | val (fixed, block) = splitDocumentChanges(changes)
66 | fixed.size shouldBe 1
67 | fixed[0] shouldBe FixedChange(0, 0, "x".repeat(10))
68 | block.shouldNotBeNull()
69 | block.body.toString() shouldBe "f(x)"
70 | }
71 |
72 | context("Far cursor input after block") {
73 | val changes =
74 | listOf(
75 | IdeaDocumentChange(id, 0, 0, "f", 0),
76 | IdeaDocumentChange(id, 1, 0, "(", 1),
77 | IdeaDocumentChange(id, 2, 0, ")", 2),
78 | // Should be shifted 3 (`f()`)
79 | IdeaDocumentChange(id, 13, 0, "after", 2),
80 | IdeaDocumentChange(id, 2, 0, "x", 2),
81 | // Should be shifted 4 (`f(x)`)
82 | IdeaDocumentChange(id, 24, 0, "after2", 3),
83 | )
84 | val (fixed, block) = splitDocumentChanges(changes)
85 | fixed.size shouldBe 2
86 | fixed[0] shouldBe FixedChange(10, 10, "after")
87 | fixed[1] shouldBe FixedChange(20, 20, "after2")
88 | block.shouldNotBeNull()
89 | block.body.toString() shouldBe "f(x)"
90 | }
91 | }
92 | })
93 |
--------------------------------------------------------------------------------