├── .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 | 11 | 12 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 2 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main/resources/icons/green-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/resources/icons/neovim-message@20x20.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 11 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/resources/icons/neovim-message@20x20_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 11 | 14 | 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 | --------------------------------------------------------------------------------