├── .idea ├── .name ├── .gitignore ├── compiler.xml ├── kotlinc.xml ├── yatool.xml ├── misc.xml └── codeStyles ├── settings.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── changelog.txt ├── src ├── main │ ├── kotlin │ │ └── io │ │ │ └── github │ │ │ └── mishkun │ │ │ └── ataman │ │ │ ├── ConfigService.kt │ │ │ ├── PluginStartup.kt │ │ │ ├── ReloadAtamanConfigAction.kt │ │ │ ├── RepeatLatestAtamanCommandAction.kt │ │ │ ├── OpenAtamanConfigAction.kt │ │ │ ├── LeaderBinding.kt │ │ │ ├── AtamanAction.kt │ │ │ ├── LeaderPopupUI.kt │ │ │ ├── LeaderKeyDispatcher.kt │ │ │ └── AtamanConfig.kt │ └── resources │ │ └── META-INF │ │ ├── plugin.xml │ │ └── pluginIcon.svg ├── test │ └── kotlin │ │ └── io │ │ └── github │ │ └── mishkun │ │ └── ataman │ │ ├── core │ │ ├── BaseTestWithConfig.kt │ │ └── HomeDirStub.kt │ │ ├── AtamanStartupTest.kt │ │ ├── OpenAtamanConfigActionTest.kt │ │ ├── AtamanConfigFileFinderTest.kt │ │ ├── ReloadAtamanConfigActionTest.kt │ │ ├── RepeatLatestAtamanCommandActionTest.kt │ │ ├── ConfigServiceTest.kt │ │ ├── AtamanActionUnitTest.kt │ │ ├── MergeBindingsTest.kt │ │ ├── LeaderKeyDispatcherTest.kt │ │ └── AtamanConfigParsingTest.kt └── uiTest │ └── kotlin │ └── io │ └── github │ └── mishkun │ └── ataman │ ├── MyTestCase.kt │ └── AtamanActionTest.kt ├── .github └── workflows │ ├── build.yml │ ├── release-snapshot.yml │ └── release-plugin.yml ├── LICENSE.txt ├── example-config └── .atamanrc.config ├── gradlew.bat ├── .gitignore ├── README.md └── gradlew /.idea/.name: -------------------------------------------------------------------------------- 1 | ataman-plugin -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "ataman-plugin" 2 | 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1024m 2 | kotlin.stdlib.default.dependency=false -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mishkun/ataman-intellij/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | Fix compatability issues with Intellij IDEA 2025.1, Bump minimum required version to 2024.2, Fix proper consuming of keys for bindings 2 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /.idea/yatool.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/ConfigService.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.components.Service 4 | import java.io.File 5 | 6 | @Service 7 | class ConfigService { 8 | var parsedBindings: List = emptyList() 9 | var latestCommand: String? = null 10 | } 11 | 12 | class Config { 13 | val configDir: File = File(System.getProperty("ataman.configFolder") ?: System.getProperty("user.home")) 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/PluginStartup.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.application.ApplicationInfo 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.startup.ProjectActivity 6 | 7 | class PluginStartup : ProjectActivity { 8 | override suspend fun execute(project: Project) { 9 | val configDir = Config().configDir 10 | val build = ApplicationInfo.getInstance().build.productCode 11 | updateConfig(project, configDir, build) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/ReloadAtamanConfigAction.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.actionSystem.AnActionEvent 4 | import com.intellij.openapi.application.ApplicationInfo 5 | import com.intellij.openapi.project.DumbAwareAction 6 | 7 | class ReloadAtamanConfigAction : DumbAwareAction() { 8 | override fun actionPerformed(e: AnActionEvent) { 9 | val project = checkNotNull(e.project) 10 | val build = ApplicationInfo.getInstance().build.productCode 11 | updateConfig(project, Config().configDir, build) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/RepeatLatestAtamanCommandAction.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.actionSystem.AnActionEvent 4 | import com.intellij.openapi.components.service 5 | import com.intellij.openapi.project.DumbAwareAction 6 | 7 | class RepeatLatestAtamanCommandAction : DumbAwareAction() { 8 | override fun actionPerformed(e: AnActionEvent) { 9 | val configService = service() 10 | configService.latestCommand?.let { command -> 11 | executeAction(command, e.dataContext) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/OpenAtamanConfigAction.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.ide.actions.OpenFileAction 4 | import com.intellij.openapi.actionSystem.AnActionEvent 5 | import com.intellij.openapi.project.DumbAwareAction 6 | 7 | class OpenAtamanConfigAction : DumbAwareAction() { 8 | 9 | override fun actionPerformed(e: AnActionEvent) { 10 | val eventProject = e.project 11 | if (eventProject != null) { 12 | val atamanRc = findOrCreateRcFile(Config().configDir) 13 | OpenFileAction.openFile(atamanRc.path, eventProject) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/core/BaseTestWithConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman.core 2 | 3 | import com.intellij.testFramework.LightPlatform4TestCase 4 | 5 | abstract class BaseTestWithConfig : LightPlatform4TestCase() { 6 | protected open val mockConfig: MockConfig = MockConfig() 7 | 8 | override fun setUp() { 9 | mockConfig.setup() 10 | System.setProperty("ataman.configFolder", mockConfig.configFolder.root.absolutePath) 11 | super.setUp() 12 | } 13 | 14 | override fun tearDown() { 15 | super.tearDown() 16 | mockConfig.teardown() 17 | setProject(null) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/AtamanStartupTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.components.service 4 | import io.github.mishkun.ataman.core.BaseTestWithConfig 5 | import io.github.mishkun.ataman.core.MockConfig 6 | import org.hamcrest.MatcherAssert 7 | import org.hamcrest.Matchers 8 | import org.junit.Test 9 | 10 | class AtamanStartupTest : BaseTestWithConfig() { 11 | 12 | override val mockConfig: MockConfig = MockConfig() 13 | 14 | @Test 15 | fun `executes startup`() { 16 | MatcherAssert.assertThat(service().parsedBindings, Matchers.not(Matchers.empty())) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/uiTest/kotlin/io/github/mishkun/ataman/MyTestCase.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.ide.starter.ide.IdeProductProvider 4 | import com.intellij.ide.starter.project.GitHubProject 5 | import com.intellij.ide.starter.project.TestCaseTemplate 6 | 7 | object UltimateCase : TestCaseTemplate(IdeProductProvider.IU) { 8 | 9 | val simpleProject = withProject( 10 | GitHubProject.fromGithub( 11 | branchName = "master", 12 | repoRelativeUrl = "Mishkun/Puerh.git" 13 | ) 14 | ) 15 | } 16 | 17 | object MyTestCase : TestCaseTemplate(IdeProductProvider.IC) { 18 | 19 | val simpleProject = withProject( 20 | GitHubProject.fromGithub( 21 | branchName = "master", 22 | repoRelativeUrl = "Mishkun/Puerh.git" 23 | ) 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up JDK 17 15 | uses: actions/setup-java@v4 16 | with: 17 | java-version: '17' 18 | distribution: 'temurin' 19 | cache: gradle 20 | - name: Build with Gradle 21 | run: ./gradlew build 22 | - name: Upload build artifacts 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: plugin-archive 26 | path: build/libs/*.jar 27 | 28 | - name: Upload test and coverage reports 29 | if: always() 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: build-reports 33 | path: | 34 | build/reports/tests/ 35 | build/reports/kover/ 36 | build/reports/plugin-verification/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2021 Mikhail Levchenko 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the “Software”), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/OpenAtamanConfigActionTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.components.service 4 | import com.intellij.openapi.editor.EditorFactory 5 | import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess 6 | import com.intellij.testFramework.PlatformTestUtil 7 | import io.github.mishkun.ataman.core.BaseTestWithConfig 8 | import org.hamcrest.MatcherAssert.assertThat 9 | import org.hamcrest.Matchers 10 | import org.junit.Test 11 | 12 | class OpenAtamanConfigActionTest : BaseTestWithConfig() { 13 | @Test 14 | fun `test action opens config file in editor`() { 15 | VfsRootAccess.allowRootAccess(testRootDisposable, mockConfig.configFile.canonicalPath) 16 | PlatformTestUtil.invokeNamedAction("OpenAtamanConfigAction") 17 | val editorFactory = service() 18 | val editor = editorFactory.allEditors.first() 19 | assertThat(editor, Matchers.notNullValue()) 20 | assertThat(editor.virtualFile.canonicalPath, Matchers.equalTo(mockConfig.configFile.canonicalPath)) 21 | editorFactory.releaseEditor(editor!!) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/core/HomeDirStub.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman.core 2 | 3 | import io.github.mishkun.ataman.ATAMAN_RC_FILENAME 4 | import io.github.mishkun.ataman.RC_TEMPLATE 5 | import org.junit.rules.TemporaryFolder 6 | import java.io.File 7 | 8 | class MockConfig(private val config: String = RC_TEMPLATE) { 9 | 10 | val configFolder: TemporaryFolder 11 | get() = Companion.configFolder 12 | 13 | val configFile: File 14 | get() = configFolder.root.resolve(ATAMAN_RC_FILENAME) 15 | 16 | fun setup() { 17 | configFolder.create() 18 | configFolder.setupStubConfigDir(config) 19 | } 20 | 21 | fun stubConfig(text: String) { 22 | configFolder.setupStubConfigDir(text) 23 | } 24 | 25 | fun teardown() { 26 | configFolder.delete() 27 | } 28 | 29 | companion object { 30 | @JvmStatic 31 | private val configFolder = TemporaryFolder() 32 | } 33 | } 34 | 35 | fun TemporaryFolder.setupEmptyConfigDir(): File = this.root 36 | 37 | fun TemporaryFolder.setupStubConfigDir(text: String = RC_TEMPLATE): File = this.root.also { 38 | File(it, ATAMAN_RC_FILENAME).writeText(text) 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/LeaderBinding.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import javax.swing.KeyStroke 4 | 5 | sealed class LeaderBinding { 6 | abstract val key: KeyStroke 7 | abstract val char: String 8 | abstract val description: String 9 | 10 | data class SingleBinding( 11 | override val key: KeyStroke, 12 | override val char: String, 13 | override val description: String, 14 | val action: List 15 | ) : LeaderBinding() { 16 | constructor( 17 | key: KeyStroke, 18 | char: String, 19 | description: String, 20 | action: String 21 | ) : this(key, char, description, listOf(action)) 22 | } 23 | 24 | data class GroupBinding( 25 | override val key: KeyStroke, 26 | override val char: String, 27 | override val description: String, 28 | val bindings: List 29 | ) : LeaderBinding() 30 | } 31 | 32 | fun List.toKeyStrokeMap(): Map = associateBy { it.key } 33 | 34 | fun List.toCharMap(): Map = 35 | filter { it.char.length == 1 }.associateBy { it.char[0] } 36 | -------------------------------------------------------------------------------- /example-config/.atamanrc.config: -------------------------------------------------------------------------------- 1 | bindings { // always present 2 | // You can use actionId for calling actions from the IDE 3 | m {actionId: ShowIntentionActions, description: Local Intentions} 4 | // You can also define nested bindings 5 | q { 6 | // Description is required to show in the action list 7 | description: Session... 8 | // These bindings are nested under 'q' key 9 | bindings { 10 | // Even more nested bindings! 11 | a { 12 | description: Ataman... 13 | bindings { 14 | f {actionId: OpenAtamanConfigAction, description: 'Open ~/.atamanrc.config'} 15 | i {actionId: ReloadAtamanConfigAction, description: 'Reload from ~/.atamanrc.config'} 16 | } 17 | } 18 | 19 | // You can use F keys as well 20 | F1 {actionId: CloseProject, description: Close Project} 21 | } 22 | } 23 | } 24 | 25 | // Binding specific to the IDE. Intellij Ultimate in this case. See your IDE's about dialog for the correct value under the 26 | // build number section. 27 | // Build #-242.23339.11 28 | IU { 29 | // this binding will replace the default action for the key 'q' 30 | q { actionId: Vcs.Show.Log, description: Log } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/AtamanConfigFileFinderTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import io.github.mishkun.ataman.core.setupEmptyConfigDir 4 | import io.github.mishkun.ataman.core.setupStubConfigDir 5 | import org.hamcrest.MatcherAssert.assertThat 6 | import org.hamcrest.Matchers.allOf 7 | import org.hamcrest.Matchers.`is` 8 | import org.hamcrest.Matchers.notNullValue 9 | import org.hamcrest.io.FileMatchers.aFileNamed 10 | import org.hamcrest.io.FileMatchers.anExistingFile 11 | import org.junit.Rule 12 | import org.junit.Test 13 | import org.junit.rules.TemporaryFolder 14 | 15 | class AtamanConfigFileFinderTest { 16 | 17 | @get:Rule 18 | val tmpFolder = TemporaryFolder() 19 | 20 | @Test 21 | fun `creates rc file if it does not exist`() { 22 | val foundRcFile = findOrCreateRcFile(tmpFolder.setupEmptyConfigDir()) 23 | assertThat(foundRcFile, notNullValue()) 24 | assertThat( 25 | foundRcFile, allOf( 26 | anExistingFile(), 27 | aFileNamed(`is`(ATAMAN_RC_FILENAME)), 28 | ) 29 | ) 30 | assertThat(foundRcFile.readText(), `is`(RC_TEMPLATE)) 31 | } 32 | 33 | @Test 34 | fun `finds rc file if it exists`() { 35 | val foundRcFile = findOrCreateRcFile(tmpFolder.setupStubConfigDir()) 36 | assertThat(foundRcFile, notNullValue()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/ReloadAtamanConfigActionTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.notification.Notification 4 | import com.intellij.notification.NotificationsManager 5 | import com.intellij.openapi.components.service 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.testFramework.PlatformTestUtil 8 | import io.github.mishkun.ataman.core.BaseTestWithConfig 9 | import org.hamcrest.MatcherAssert.assertThat 10 | import org.hamcrest.Matchers 11 | import org.junit.Test 12 | 13 | class ReloadAtamanConfigActionTest : BaseTestWithConfig() { 14 | 15 | @Test 16 | fun `reloads config successfully`() { 17 | mockConfig.stubConfig("bindings { q { actionId: A, description: Session }, w { actionId: B, description: Session } }") 18 | reloadWithAction() 19 | assertThat(service().parsedBindings.size, Matchers.equalTo(2)) 20 | } 21 | 22 | @Test 23 | fun `displays a notification if bindings schema is invalid`() { 24 | mockConfig.stubConfig("bindings { q { description: Session } }") 25 | reloadWithAction() 26 | project.checkNotification("Bindings schema is invalid. Aborting...") 27 | } 28 | 29 | @Test 30 | fun `displays a notification if config is malformed`() { 31 | mockConfig.stubConfig("}malformed{") 32 | reloadWithAction() 33 | project.checkNotification("Config is malformed. Aborting...") 34 | } 35 | } 36 | 37 | private fun reloadWithAction() { 38 | PlatformTestUtil.invokeNamedAction("ReloadAtamanConfigAction") 39 | } 40 | 41 | private fun Project.checkNotification(notification: String) { 42 | val notifications = NotificationsManager.getNotificationsManager() 43 | .getNotificationsOfType(Notification::class.java, this) 44 | assertThat( 45 | notifications.toList(), Matchers.allOf( 46 | Matchers.not(Matchers.empty()), 47 | Matchers.hasSize(1) 48 | ) 49 | ) 50 | assertThat( 51 | notifications.first().content, 52 | Matchers.containsString(notification) 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/RepeatLatestAtamanCommandActionTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.ide.DataManager 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.actionSystem.ex.AnActionListener 7 | import com.intellij.testFramework.PlatformTestUtil 8 | import com.intellij.util.application 9 | import io.github.mishkun.ataman.core.BaseTestWithConfig 10 | import org.hamcrest.MatcherAssert.assertThat 11 | import org.hamcrest.Matchers 12 | import org.junit.Test 13 | 14 | class RepeatLatestAtamanCommandActionTest : BaseTestWithConfig() { 15 | 16 | private val recentActions = mutableListOf() 17 | 18 | private val myActionListener = object : AnActionListener { 19 | override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) { 20 | recentActions.add(action.javaClass.simpleName) 21 | } 22 | } 23 | 24 | @Test 25 | fun `executes action`() { 26 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener) 27 | val bindings = listOf( 28 | LeaderBinding.SingleBinding( 29 | getKeyStroke('c'), 30 | "c", 31 | "CommentAction", 32 | "CommentByLineComment" 33 | ) 34 | ) 35 | val popup = LeaderPopupUI( 36 | project, 37 | LeaderListStep( 38 | "Ataman", 39 | DataManager.getInstance().dataContext, 40 | bindings, 41 | ) 42 | ) 43 | popup.selectAndExecuteValue('c') 44 | popup.handleSelect(true) 45 | PlatformTestUtil.invokeNamedAction("RepeatLatestAtamanCommandAction") 46 | PlatformTestUtil.dispatchAllEventsInIdeEventQueue() 47 | assertThat( 48 | recentActions, 49 | Matchers.containsInRelativeOrder( 50 | "CommentByLineCommentAction", 51 | "RepeatLatestAtamanCommandAction", 52 | "CommentByLineCommentAction" 53 | ) 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release-snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Release Snapshot 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | release-snapshot: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 2 # Need at least 2 commits to get the commit message 14 | 15 | - name: Set up JDK 21 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: '21' 19 | distribution: 'temurin' 20 | cache: gradle 21 | 22 | - name: Build with Gradle 23 | run: ./gradlew buildPlugin 24 | 25 | - name: Get plugin version 26 | id: get_version 27 | run: echo "VERSION=$(grep 'version =' build.gradle.kts | head -n 1 | cut -d '"' -f 2)-SNAPSHOT" >> $GITHUB_OUTPUT 28 | 29 | - name: Get commit details 30 | id: get_commit 31 | run: | 32 | COMMIT_HASH=$(git rev-parse --short HEAD) 33 | COMMIT_MESSAGE=$(git log -1 --pretty=%B) 34 | echo "COMMIT=${COMMIT_HASH}" >> $GITHUB_OUTPUT 35 | # Escape newlines and special characters for GitHub Actions 36 | COMMIT_MESSAGE="${COMMIT_MESSAGE//'%'/'%25'}" 37 | COMMIT_MESSAGE="${COMMIT_MESSAGE//$'\n'/'%0A'}" 38 | COMMIT_MESSAGE="${COMMIT_MESSAGE//$'\r'/'%0D'}" 39 | echo "COMMIT_MESSAGE=${COMMIT_MESSAGE}" >> $GITHUB_OUTPUT 40 | 41 | - name: Create GitHub Release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | tag_name: snapshot-${{ steps.get_commit.outputs.COMMIT }} 45 | name: Snapshot ${{ steps.get_version.outputs.VERSION }}-${{ steps.get_commit.outputs.COMMIT }} 46 | body: | 47 | Automated snapshot build from commit ${{ steps.get_commit.outputs.COMMIT }} 48 | 49 | **Commit Message:** 50 | ${{ steps.get_commit.outputs.COMMIT_MESSAGE }} 51 | 52 | This is an automated snapshot release created from the latest commit to the master branch. 53 | files: build/distributions/*.jar 54 | prerelease: true 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/ConfigServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Test 6 | import java.awt.event.KeyEvent 7 | import javax.swing.KeyStroke 8 | 9 | class LeaderBindingExtensionsTest { 10 | 11 | @Test 12 | fun `toKeyStrokeMap returns empty map for empty list`() { 13 | val bindings = emptyList() 14 | assertTrue(bindings.toKeyStrokeMap().isEmpty()) 15 | } 16 | 17 | @Test 18 | fun `toKeyStrokeMap creates map keyed by keystroke`() { 19 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 20 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true) 21 | 22 | val bindings = listOf( 23 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"), 24 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB") 25 | ) 26 | 27 | val map = bindings.toKeyStrokeMap() 28 | 29 | assertEquals(2, map.size) 30 | assertEquals(bindings[0], map[keyStrokeA]) 31 | assertEquals(bindings[1], map[keyStrokeB]) 32 | } 33 | 34 | @Test 35 | fun `toKeyStrokeMap works with group bindings`() { 36 | val keyStrokeG = KeyStroke.getKeyStroke(KeyEvent.VK_G, 0, false) 37 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 38 | 39 | val nestedBindings = listOf( 40 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Nested Action", "NestedAction") 41 | ) 42 | val groupBinding = LeaderBinding.GroupBinding(keyStrokeG, "g", "Group", nestedBindings) 43 | 44 | val map = listOf(groupBinding).toKeyStrokeMap() 45 | 46 | assertEquals(1, map.size) 47 | assertEquals(groupBinding, map[keyStrokeG]) 48 | } 49 | 50 | @Test 51 | fun `toKeyStrokeMap works for nested bindings list`() { 52 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 53 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true) 54 | 55 | val nestedBindings = listOf( 56 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"), 57 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB") 58 | ) 59 | 60 | val nestedMap = nestedBindings.toKeyStrokeMap() 61 | 62 | assertEquals(2, nestedMap.size) 63 | assertEquals(nestedBindings[0], nestedMap[keyStrokeA]) 64 | assertEquals(nestedBindings[1], nestedMap[keyStrokeB]) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/AtamanActionUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.ide.DataManager 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.actionSystem.ex.AnActionListener 7 | import com.intellij.testFramework.LightPlatform4TestCase 8 | import com.intellij.util.application 9 | import org.hamcrest.MatcherAssert.assertThat 10 | import org.hamcrest.Matchers 11 | import org.junit.Test 12 | 13 | class AtamanActionUnitTest : LightPlatform4TestCase() { 14 | 15 | private val recentActions = mutableListOf() 16 | 17 | private val myActionListener = object : AnActionListener { 18 | override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) { 19 | recentActions.add(action.javaClass.simpleName) 20 | } 21 | } 22 | 23 | @Test 24 | fun `executes multiple actions`() { 25 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener) 26 | val bindings = listOf( 27 | LeaderBinding.SingleBinding( 28 | getKeyStroke('w'), 29 | "w", 30 | "split and unsplit", 31 | listOf("SplitVertically", "Unsplit") 32 | ) 33 | ) 34 | val popup = LeaderPopupUI( 35 | project, 36 | LeaderListStep( 37 | "Ataman", 38 | dataContext(), 39 | bindings, 40 | ) 41 | ) 42 | popup.selectAndExecuteValue('w') 43 | popup.handleSelect(true) 44 | assertThat( 45 | recentActions, 46 | Matchers.containsInRelativeOrder( 47 | "SplitVerticallyAction", 48 | "Unsplit" 49 | ) 50 | ) 51 | } 52 | 53 | @Test 54 | fun `executes action`() { 55 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener) 56 | val bindings = listOf( 57 | LeaderBinding.SingleBinding( 58 | getKeyStroke('c'), 59 | "c", 60 | "CommentAction", 61 | "CommentByLineComment" 62 | ) 63 | ) 64 | val popup = LeaderPopupUI( 65 | project, 66 | LeaderListStep( 67 | "Ataman", 68 | dataContext(), 69 | bindings, 70 | ) 71 | ) 72 | popup.selectAndExecuteValue('c') 73 | popup.handleSelect(true) 74 | assertThat(recentActions, Matchers.contains("CommentByLineCommentAction")) 75 | } 76 | 77 | @Suppress("DEPRECATION") 78 | private fun dataContext() = DataManager.getInstance().dataContext 79 | } 80 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | io.github.mishkun.ataman-plugin 3 | Ataman 4 | Mishkun 5 | 6 | 11 |
  • LeaderAction can be used to access leader menu everywhere
  • 12 |
  • TransparentLeaderAction triggers only when no editable text is focused
  • 13 |
      14 | If you want to use modifier-base shortcut like C-c, just assign LeaderAction to it. 15 | If you want to use SPACE as leader, install IdeaVim plugin and set SPACE as a shortcut 16 | for TransparentLeaderAction and add `:nnoremap :action LeaderAction` 17 | to your ~/.ideavimrc file. This way SPACE will work as a leader unless you are entering 18 | some text anywhere 19 | 20 | More info and examples can be found at the plugin's GitHub. 21 | ]]> 22 | 23 | 25 | com.intellij.modules.platform 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 37 | 38 | 39 | 42 | 43 | 45 | 46 | 47 | 48 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /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 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/AtamanAction.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.actionSystem.ActionManager 4 | import com.intellij.openapi.actionSystem.ActionPlaces 5 | import com.intellij.openapi.actionSystem.ActionUiKind 6 | import com.intellij.openapi.actionSystem.ActionUpdateThread 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.DataContext 9 | import com.intellij.openapi.actionSystem.ex.ActionUtil 10 | import com.intellij.openapi.components.service 11 | import com.intellij.openapi.project.DumbAwareAction 12 | import com.intellij.openapi.ui.popup.JBPopupListener 13 | import com.intellij.openapi.ui.popup.LightweightWindowEvent 14 | import com.intellij.ui.speedSearch.SpeedSearchSupply 15 | import java.awt.KeyboardFocusManager 16 | import javax.swing.JComponent 17 | import javax.swing.text.JTextComponent 18 | 19 | class TransparentLeaderAction : DumbAwareAction() { 20 | 21 | private val delegateAction = LeaderAction() 22 | 23 | override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT 24 | 25 | override fun update(e: AnActionEvent) { 26 | super.update(e) 27 | val focusOwner = getCurrentFocus() ?: kotlin.run { 28 | e.presentation.isEnabled = false 29 | return 30 | } 31 | val isSupplyActive = isSpeedSearchActive(focusOwner) 32 | val isTextField = isTextField(focusOwner) 33 | e.presentation.isEnabled = !(isSupplyActive || isTextField) 34 | } 35 | 36 | override fun actionPerformed(e: AnActionEvent) { 37 | delegateAction.actionPerformed(e) 38 | } 39 | 40 | private fun getCurrentFocus(): JComponent? { 41 | val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() 42 | return focusManager.focusOwner as? JComponent 43 | } 44 | 45 | private fun isTextField(focusOwner: JComponent) = focusOwner is JTextComponent 46 | 47 | private fun isSpeedSearchActive(focusOwner: JComponent): Boolean { 48 | fun JComponent.getSupply(): SpeedSearchSupply? = 49 | SpeedSearchSupply.getSupply(this) ?: (parent as? JComponent)?.getSupply() 50 | 51 | val supply = focusOwner.getSupply() 52 | return supply?.isPopupActive == true 53 | } 54 | } 55 | 56 | class LeaderAction : DumbAwareAction() { 57 | 58 | override fun actionPerformed(event: AnActionEvent) { 59 | val dispatcher = service() 60 | val bindings = service().parsedBindings 61 | 62 | // Start key capture FIRST, before any UI work 63 | dispatcher.start(bindings, event.dataContext) 64 | 65 | // Create display-only popup 66 | val popup = LeaderPopupUI( 67 | event.project, 68 | LeaderListStep( 69 | "Ataman", 70 | event.dataContext, 71 | values = bindings 72 | ) 73 | ) 74 | 75 | // Wire up lifecycle: popup close -> stop dispatcher 76 | popup.addListener(object : JBPopupListener { 77 | override fun onClosed(event: LightweightWindowEvent) { 78 | dispatcher.stop() 79 | } 80 | }) 81 | 82 | // Show popup 83 | val project = event.project 84 | if (project != null) { 85 | popup.showCenteredInCurrentWindow(project) 86 | } else { 87 | popup.showInFocusCenter() 88 | } 89 | } 90 | } 91 | 92 | fun executeAction(actionId: String, context: DataContext) { 93 | val action = ActionManager.getInstance().getAction(actionId) 94 | val event = AnActionEvent.createEvent( 95 | action, context, null, ActionPlaces.KEYBOARD_SHORTCUT, ActionUiKind.NONE, null, 96 | ) 97 | ActionUtil.performDumbAwareUpdate(action, event, true) 98 | ActionUtil.invokeAction(action, event, null) 99 | service().latestCommand = actionId 100 | } 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Java template 3 | # Compiled class file 4 | *.class 5 | 6 | # Log file 7 | *.log 8 | 9 | # BlueJ files 10 | *.ctxt 11 | 12 | # Mobile Tools for Java (J2ME) 13 | .mtj.tmp/ 14 | 15 | # Package Files # 16 | *.jar 17 | *.war 18 | *.nar 19 | *.ear 20 | *.zip 21 | *.tar.gz 22 | *.rar 23 | 24 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 25 | hs_err_pid* 26 | 27 | ### JetBrains template 28 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 29 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 30 | 31 | # User-specific stuff 32 | .idea/**/workspace.xml 33 | .idea/**/tasks.xml 34 | .idea/**/usage.statistics.xml 35 | .idea/**/dictionaries 36 | .idea/**/shelf 37 | 38 | # Generated files 39 | .idea/**/contentModel.xml 40 | 41 | # Sensitive or high-churn files 42 | .idea/**/dataSources/ 43 | .idea/**/dataSources.ids 44 | .idea/**/dataSources.local.xml 45 | .idea/**/sqlDataSources.xml 46 | .idea/**/dynamic.xml 47 | .idea/**/uiDesigner.xml 48 | .idea/**/dbnavigator.xml 49 | 50 | # Gradle 51 | .idea/**/gradle.xml 52 | .idea/**/libraries 53 | 54 | # Gradle and Maven with auto-import 55 | # When using Gradle or Maven with auto-import, you should exclude module files, 56 | # since they will be recreated, and may cause churn. Uncomment if using 57 | # auto-import. 58 | # .idea/modules.xml 59 | # .idea/*.iml 60 | # .idea/modules 61 | # *.iml 62 | # *.ipr 63 | 64 | # CMake 65 | cmake-build-*/ 66 | 67 | # Mongo Explorer plugin 68 | .idea/**/mongoSettings.xml 69 | 70 | # File-based project format 71 | *.iws 72 | 73 | # IntelliJ 74 | out/ 75 | 76 | # mpeltonen/sbt-idea plugin 77 | .idea_modules/ 78 | 79 | # JIRA plugin 80 | atlassian-ide-plugin.xml 81 | 82 | # Cursive Clojure plugin 83 | .idea/replstate.xml 84 | 85 | # Crashlytics plugin (for Android Studio and IntelliJ) 86 | com_crashlytics_export_strings.xml 87 | crashlytics.properties 88 | crashlytics-build.properties 89 | fabric.properties 90 | 91 | # Editor-based Rest Client 92 | .idea/httpRequests 93 | 94 | # Android studio 3.1+ serialized cache file 95 | .idea/caches/build_file_checksums.ser 96 | 97 | ### Vim template 98 | # Swap 99 | [._]*.s[a-v][a-z] 100 | [._]*.sw[a-p] 101 | [._]s[a-rt-v][a-z] 102 | [._]ss[a-gi-z] 103 | [._]sw[a-p] 104 | 105 | # Session 106 | Session.vim 107 | Sessionx.vim 108 | 109 | # Temporary 110 | .netrwhist 111 | *~ 112 | # Auto-generated tag files 113 | tags 114 | # Persistent undo 115 | [._]*.un~ 116 | 117 | ### Gradle template 118 | .gradle 119 | /build/ 120 | 121 | # Ignore Gradle GUI config 122 | gradle-app.setting 123 | 124 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 125 | !gradle-wrapper.jar 126 | 127 | # Cache of project 128 | .gradletasknamecache 129 | 130 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 131 | # gradle/wrapper/gradle-wrapper.properties 132 | 133 | ### macOS template 134 | # General 135 | .DS_Store 136 | .AppleDouble 137 | .LSOverride 138 | 139 | # Icon must end with two \r 140 | Icon 141 | 142 | # Thumbnails 143 | ._* 144 | 145 | # Files that might appear in the root of a volume 146 | .DocumentRevisions-V100 147 | .fseventsd 148 | .Spotlight-V100 149 | .TemporaryItems 150 | .Trashes 151 | .VolumeIcon.icns 152 | .com.apple.timemachine.donotpresent 153 | 154 | # Directories potentially created on remote AFP share 155 | .AppleDB 156 | .AppleDesktop 157 | Network Trash Folder 158 | Temporary Items 159 | .apdisk 160 | 161 | /AceJump/ 162 | /ideavim/ 163 | /.idea/$PRODUCT_WORKSPACE_FILE$ 164 | /.idea/StickySelectionHighlights.xml 165 | /.idea/vcs.xml 166 | /.idea/jarRepositories.xml 167 | /.idea/modules.xml 168 | /allure-results/ 169 | /.intellijPlatform/ 170 | 171 | # Claude Code 172 | .claude/ 173 | 174 | # Auto-generated module files 175 | .idea/modules/ 176 | .idea/*.iml 177 | *.iml 178 | 179 | # Scratchpad 180 | scratchpad.txt 181 | -------------------------------------------------------------------------------- /.idea/codeStyles: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 |
      12 | 13 | 14 | 15 | xmlns:android 16 | 17 | ^$ 18 | 19 | 20 | 21 |
      22 |
      23 | 24 | 25 | 26 | xmlns:.* 27 | 28 | ^$ 29 | 30 | 31 | BY_NAME 32 | 33 |
      34 |
      35 | 36 | 37 | 38 | .*:id 39 | 40 | http://schemas.android.com/apk/res/android 41 | 42 | 43 | 44 |
      45 |
      46 | 47 | 48 | 49 | .*:name 50 | 51 | http://schemas.android.com/apk/res/android 52 | 53 | 54 | 55 |
      56 |
      57 | 58 | 59 | 60 | name 61 | 62 | ^$ 63 | 64 | 65 | 66 |
      67 |
      68 | 69 | 70 | 71 | style 72 | 73 | ^$ 74 | 75 | 76 | 77 |
      78 |
      79 | 80 | 81 | 82 | .* 83 | 84 | ^$ 85 | 86 | 87 | BY_NAME 88 | 89 |
      90 |
      91 | 92 | 93 | 94 | .* 95 | 96 | http://schemas.android.com/apk/res/android 97 | 98 | 99 | ANDROID_ATTRIBUTE_ORDER 100 | 101 |
      102 |
      103 | 104 | 105 | 106 | .* 107 | 108 | .* 109 | 110 | 111 | BY_NAME 112 | 113 |
      114 |
      115 |
      116 |
      117 |
      118 |
      119 |
      -------------------------------------------------------------------------------- /.github/workflows/release-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release Plugin 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | release_type: 7 | description: 'Release Type' 8 | required: true 9 | default: 'patch' 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | additional_notes: 16 | description: 'Additional Release Notes (optional)' 17 | required: false 18 | type: string 19 | 20 | jobs: 21 | build-and-release: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 # Fetch all history for tags and commits 27 | 28 | - name: Set up JDK 21 29 | uses: actions/setup-java@v4 30 | with: 31 | java-version: '21' 32 | distribution: 'temurin' 33 | cache: gradle 34 | 35 | - name: Get current version 36 | id: get_current_version 37 | run: echo "CURRENT_VERSION=$(grep 'version =' build.gradle.kts | head -n 1 | cut -d '"' -f 2)" >> $GITHUB_OUTPUT 38 | 39 | - name: Calculate new version 40 | id: calculate_version 41 | run: | 42 | CURRENT_VERSION=${{ steps.get_current_version.outputs.CURRENT_VERSION }} 43 | RELEASE_TYPE=${{ github.event.inputs.release_type }} 44 | 45 | major=$(echo $CURRENT_VERSION | cut -d. -f1) 46 | minor=$(echo $CURRENT_VERSION | cut -d. -f2) 47 | patch=$(echo $CURRENT_VERSION | cut -d. -f3) 48 | 49 | if [ "$RELEASE_TYPE" == "major" ]; then 50 | major=$((major + 1)) 51 | minor=0 52 | patch=0 53 | elif [ "$RELEASE_TYPE" == "minor" ]; then 54 | minor=$((minor + 1)) 55 | patch=0 56 | else 57 | patch=$((patch + 1)) 58 | fi 59 | 60 | NEW_VERSION="${major}.${minor}.${patch}" 61 | echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_OUTPUT 62 | 63 | - name: Update version in build.gradle.kts 64 | run: | 65 | sed -i "s/version = \"${{ steps.get_current_version.outputs.CURRENT_VERSION }}\"/version = \"${{ steps.calculate_version.outputs.NEW_VERSION }}\"/" build.gradle.kts 66 | 67 | - name: Generate changelog from commits 68 | id: generate_changelog 69 | run: | 70 | # Find the latest tag 71 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") 72 | 73 | if [ -z "$LATEST_TAG" ]; then 74 | # If no tags exist, use all commits 75 | echo "No previous tags found. Using all commits for changelog." 76 | CHANGELOG=$(git log --pretty=format:"- %s" --no-merges) 77 | else 78 | echo "Found latest tag: $LATEST_TAG" 79 | # Get commits since the latest tag 80 | CHANGELOG=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s" --no-merges) 81 | fi 82 | 83 | # Add additional notes if provided 84 | ADDITIONAL_NOTES="${{ github.event.inputs.additional_notes }}" 85 | if [ ! -z "$ADDITIONAL_NOTES" ]; then 86 | CHANGELOG="${ADDITIONAL_NOTES} 87 | 88 | ${CHANGELOG}" 89 | fi 90 | 91 | # Save changelog to file for build 92 | echo "$CHANGELOG" > changelog.txt 93 | 94 | # Save changelog for GitHub release body 95 | CHANGELOG="${CHANGELOG//'%'/'%25'}" 96 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" 97 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" 98 | echo "CHANGELOG=${CHANGELOG}" >> $GITHUB_OUTPUT 99 | 100 | - name: Build plugin 101 | run: ./gradlew buildPlugin 102 | 103 | - name: Commit version bump and changelog 104 | run: | 105 | git config --local user.email "action@github.com" 106 | git config --local user.name "GitHub Action" 107 | git add build.gradle.kts changelog.txt 108 | git commit -m "Bump version to ${{ steps.calculate_version.outputs.NEW_VERSION }}" 109 | git tag -a "v${{ steps.calculate_version.outputs.NEW_VERSION }}" -m "Version ${{ steps.calculate_version.outputs.NEW_VERSION }}" 110 | git push --follow-tags 111 | 112 | - name: Create GitHub Release 113 | uses: softprops/action-gh-release@v2 114 | with: 115 | tag_name: v${{ steps.calculate_version.outputs.NEW_VERSION }} 116 | name: Release v${{ steps.calculate_version.outputs.NEW_VERSION }} 117 | body: ${{ steps.generate_changelog.outputs.CHANGELOG }} 118 | files: build/distributions/*.jar 119 | draft: false 120 | prerelease: false 121 | env: 122 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 123 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/MergeBindingsTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import org.hamcrest.MatcherAssert.assertThat 4 | import org.hamcrest.Matchers 5 | import org.junit.Test 6 | 7 | class MergeBindingsTest { 8 | 9 | @Test 10 | fun `replaces with override if type mismatches`() { 11 | val singleBindingOriginal = LeaderBinding.GroupBinding( 12 | getKeyStroke('c'), 13 | "c", 14 | "Comment...", 15 | bindings = listOf( 16 | LeaderBinding.SingleBinding( 17 | getKeyStroke('c'), 18 | "c", 19 | "CommentAction", 20 | "CommentByLineComment" 21 | ) 22 | ) 23 | ) 24 | val singleBindingOverriden = LeaderBinding.SingleBinding( 25 | getKeyStroke('c'), 26 | "c", 27 | "DeleteAction", 28 | "DeleteLine" 29 | ) 30 | val bindings = mergeBindings( 31 | bindingConfig = listOf( 32 | singleBindingOriginal 33 | ), 34 | overrideConfig = listOf( 35 | singleBindingOverriden 36 | ) 37 | ) 38 | assertThat( 39 | bindings, Matchers.containsInAnyOrder( 40 | singleBindingOverriden 41 | ) 42 | ) 43 | } 44 | 45 | @Test 46 | fun `merges nested binding`() { 47 | val singleBindingOriginal = LeaderBinding.SingleBinding( 48 | getKeyStroke('c'), 49 | "c", 50 | "CommentAction", 51 | "CommentByLineComment" 52 | ) 53 | val singleBindingOverriden = LeaderBinding.SingleBinding( 54 | getKeyStroke('d'), 55 | "d", 56 | "DeleteAction", 57 | "DeleteLine" 58 | ) 59 | val bindings = mergeBindings( 60 | bindingConfig = listOf( 61 | LeaderBinding.GroupBinding( 62 | getKeyStroke('c'), 63 | "c", 64 | description = "Comment...", 65 | listOf( 66 | singleBindingOriginal 67 | ) 68 | ) 69 | ), 70 | overrideConfig = listOf( 71 | LeaderBinding.GroupBinding( 72 | getKeyStroke('c'), 73 | "c", 74 | description = "Deletion...", 75 | listOf( 76 | singleBindingOverriden 77 | ) 78 | ) 79 | ) 80 | ) 81 | assertThat( 82 | bindings, Matchers.containsInAnyOrder( 83 | LeaderBinding.GroupBinding( 84 | getKeyStroke('c'), 85 | "c", 86 | description = "Deletion...", 87 | listOf( 88 | singleBindingOriginal, 89 | singleBindingOverriden 90 | ) 91 | ) 92 | ) 93 | ) 94 | } 95 | 96 | @Test 97 | fun `overrides top level binding`() { 98 | val singleBindingOriginal = LeaderBinding.SingleBinding( 99 | getKeyStroke('c'), 100 | "c", 101 | "CommentAction", 102 | "CommentByLineComment" 103 | ) 104 | val singleBindingOverriden = LeaderBinding.SingleBinding( 105 | getKeyStroke('c'), 106 | "c", 107 | "DeleteAction", 108 | "DeleteLine" 109 | ) 110 | val bindings = mergeBindings( 111 | bindingConfig = listOf( 112 | singleBindingOriginal 113 | ), 114 | overrideConfig = listOf( 115 | singleBindingOverriden 116 | ) 117 | ) 118 | assertThat( 119 | bindings, Matchers.containsInAnyOrder( 120 | singleBindingOverriden 121 | ) 122 | ) 123 | } 124 | 125 | @Test 126 | fun `merges top level bindings`() { 127 | val singleBindingOriginal = LeaderBinding.SingleBinding( 128 | getKeyStroke('c'), 129 | "c", 130 | "CommentAction", 131 | "CommentByLineComment" 132 | ) 133 | val singleBindingOverriden = LeaderBinding.SingleBinding( 134 | getKeyStroke('d'), 135 | "d", 136 | "DeleteAction", 137 | "DeleteLine" 138 | ) 139 | val bindings = mergeBindings( 140 | bindingConfig = listOf( 141 | singleBindingOriginal 142 | ), 143 | overrideConfig = listOf( 144 | singleBindingOverriden 145 | ) 146 | ) 147 | assertThat( 148 | bindings, Matchers.containsInAnyOrder( 149 | singleBindingOriginal, 150 | singleBindingOverriden 151 | ) 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 11 | 12 | Untitled 5 13 | Drawing exported from Concepts: Smarter Sketching 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | icon 2 | 3 | # Ataman 4 | 5 | [![JetBrains IntelliJ Plugins](https://img.shields.io/jetbrains/plugin/v/17567-ataman?label=version)](https://plugins.jetbrains.com/plugin/17567-ataman) 6 | [![jb downloads](https://img.shields.io/jetbrains/plugin/d/17567-ataman?label=downloads)](https://plugins.jetbrains.com/plugin/17567-ataman) 7 | [![Build & Test](https://github.com/mishkun/ataman-intellij/workflows/Build%20&%20Test/badge.svg)](https://github.com/mishkun/ataman-intellij/actions) 8 | 9 | 10 | > Ataman - an elected leader of the Cossack troops and settlements 11 | 12 | Ataman is an Intellij Idea plugin for using leader key for bindings (almost like in Spacemacs). Great way to enchance 13 | your 14 | IdeaVim productivity or just to have a more convenient way to access actions, as it is not required. 15 | 16 | ## Rationale 17 | 18 | IntelliJ IDEA is notorious for its tricky keybindings involving multiple modifiers and F1-F12 keys. Another approach of 19 | using `Cmd+Shift+A` command pallete and search for most of the actions, reducing the speed. 20 | 21 | There is another way, popularized by Spacemacs and Doom Emacs – leader (or sticky) keys. It works fairly simple – you 22 | choose a combination to use as a leader, e.g. `Ctrl-E`. After you activate leader, next keys can be simply typed one 23 | after another. For example, we can have `Ctrl-E c r` for opening refactoring menu and `Ctrl-E c f` to reformat file. 24 | With this approach keybindings are easier to type and memorize. 25 | 26 | This approach could already be done in IntelliJ using IdeaVim and 27 | some [tricks](https://ztlevi.github.io/posts/The-Minimal-Spacemacs-Tweaks-for-Jetbrain-IDES/). Ataman is independent of 28 | your choice to use IdeaVim and works everywhere across Intellij 29 | 30 | ## Easy setup 31 | 32 | Install plugin from Jetbrains Marketplace (or build it yourself as shown below). In your keymap 33 | settings (`Preferences -> Keymap`) 34 | find and bind `Ataman: Leader Key` to the shortcut of your choice. When executed first time, the only binding is to open 35 | your config. Enjoy! 36 | 37 | ## Advanced setup for IdeaVim users 38 | 39 | To use leader key without modifier (e.g. to use SPACE as leader), bind your desired leader key to 40 | the `Ataman: Transparent Leader Key` action and add these lines 41 | 42 | ``` 43 | nnoremap :action LeaderAction 44 | vnoremap :action LeaderAction 45 | ``` 46 | 47 | to your `~/.ideavimrc` file. This way leader key will work unless you are entering text anywhere 48 | 49 | ## Other actions 50 | 51 | - `Ataman: Open or Create ~/atamanrc.config` - opens your config file in the editor (creates if it does not exist yet) 52 | - `Ataman: Reload ~/.atamanrc.config File` - reloads your config file. Call this action after editing your config 53 | - `Ataman: Repeat Latest Command` - repeats the last command executed by Ataman 54 | 55 | ## Config structure 56 | 57 | Your mappings config is located at `~/.atamanrc.config`. File is 58 | in [HOCON](https://github.com/lightbend/config/blob/master/HOCON.md) format. Suggested style is here: 59 | 60 | ```hocon 61 | bindings { # always present 62 | c { # tree of bindings starting with 'c' 63 | description: Code... 64 | bindings { 65 | # some leaves with actions to call 66 | r { actionId: RefactoringMenu, description: Refactor this... } 67 | c { # you can nest arbitrary amount of tree groups 68 | description: Compile/Run... 69 | bindings { 70 | a { actionId: RunAnything, description: Run Anything... } 71 | r { actionId: ReRun, description: Rerun last build } 72 | } 73 | # actionId: ... -- error! do not mix actionId and bindings clause together! 74 | } 75 | # You can use F keys as well 76 | F12 {actionId: CloseProject, description: Close Project} 77 | 78 | # You can also do multiple actions in a row 79 | f { actionId: [ReformatCode, OptimizeImports], description: Reformat and Optimize Imports } 80 | } 81 | } 82 | } 83 | 84 | # You can also have ide-specific bindings 85 | # For example, to have different bindings for PyCharm, use its product code 86 | # To find out product code, look at the about screen build info section 87 | # Build #-242.23339.11 88 | PY { 89 | # This binding will override defaults for PyCharm 90 | c { 91 | description: Closing... 92 | bindings { 93 | r { actionId: CloseProject, description: Close Project } 94 | } 95 | } 96 | # This binding will be added to the list for PyCharm 97 | F12 { actionId: CloseProject, description: Close Project } 98 | } 99 | ``` 100 | 101 | You can look at my own config [here](https://gist.github.com/Mishkun/b3fa501f82a5ad1205adf87c89c70031) for more examples 102 | 103 | ### Finding actionId 104 | 105 | To find actionId of the action you want to bind, you can use IdeaVim's action "IdeaVim: Track Action IDs" 106 | 107 | ## Building from source 108 | 109 | This repo uses [gradle-intellij-plugin](https://github.com/JetBrains/gradle-intellij-plugin/) for building. 110 | To build plugin, use this command: 111 | 112 | ``` 113 | ./gradlew buildPlugin 114 | ``` 115 | 116 | For running tests: 117 | 118 | ``` 119 | ./gradlew test # Unit tests 120 | ./gradlew uiTests # UI tests 121 | ``` 122 | 123 | For more advanced usecases, please refer to gradle-intellij-plugin documentation. 124 | 125 | ## License 126 | 127 | This project is distributed under MIT License. Please refer to [LICENSE.txt](LICENSE.txt) for details. 128 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/LeaderPopupUI.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.icons.AllIcons 4 | import com.intellij.openapi.actionSystem.DataContext 5 | import com.intellij.openapi.components.service 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.openapi.ui.popup.PopupStep 8 | import com.intellij.openapi.ui.popup.util.BaseListPopupStep 9 | import com.intellij.ui.popup.WizardPopup 10 | import com.intellij.ui.popup.list.ListPopupImpl 11 | import com.intellij.util.ui.EmptyIcon 12 | import com.intellij.util.ui.GridBag 13 | import com.intellij.util.ui.JBUI 14 | import com.intellij.util.ui.UIUtil 15 | import java.awt.Font 16 | import java.awt.GridBagLayout 17 | import java.awt.event.KeyEvent 18 | import javax.swing.JComponent 19 | import javax.swing.JLabel 20 | import javax.swing.JList 21 | import javax.swing.JPanel 22 | import javax.swing.ListCellRenderer 23 | 24 | /** 25 | * Display-only popup for showing leader bindings. 26 | * All key handling is done by LeaderKeyDispatcher - this popup only renders the UI. 27 | */ 28 | class LeaderPopupUI( 29 | project: Project? = null, 30 | step: LeaderListStep, 31 | parent: WizardPopup? = null, 32 | parentObject: Any? = null 33 | ) : ListPopupImpl(project, parent, step, parentObject), LeaderStateListener { 34 | 35 | private val dispatcher = service() 36 | 37 | init { 38 | dispatcher.addListener(this) 39 | } 40 | 41 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) { 42 | // Visual feedback - highlight the selected binding 43 | list.setSelectedValue(binding, true) 44 | } 45 | 46 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) { 47 | // Navigate to sub-group using ListPopupImpl's built-in mechanism 48 | list.setSelectedValue(group, true) 49 | handleSelect(true) 50 | } 51 | 52 | override fun onDismiss() { 53 | cancel() 54 | } 55 | 56 | override fun dispose() { 57 | dispatcher.removeListener(this) 58 | super.dispose() 59 | } 60 | 61 | // Consume all key events - dispatcher handles them 62 | override fun process(aEvent: KeyEvent) { 63 | aEvent.consume() 64 | } 65 | 66 | override fun getListElementRenderer(): ListCellRenderer<*> = ActionItemRenderer() 67 | 68 | override fun createPopup(parent: WizardPopup?, step: PopupStep<*>?, parentValue: Any?): LeaderPopupUI { 69 | return LeaderPopupUI( 70 | parent?.project, 71 | step as LeaderListStep, 72 | parent, 73 | parentValue 74 | ) 75 | } 76 | } 77 | 78 | /** 79 | * Step for the leader popup list. 80 | */ 81 | class LeaderListStep( 82 | title: String? = null, 83 | val dataContext: DataContext, 84 | values: List 85 | ) : BaseListPopupStep(title, values) { 86 | 87 | override fun hasSubstep(selectedValue: LeaderBinding?): Boolean { 88 | return selectedValue is LeaderBinding.GroupBinding 89 | } 90 | 91 | override fun onChosen(selectedValue: LeaderBinding?, finalChoice: Boolean): PopupStep<*>? = 92 | when (selectedValue) { 93 | is LeaderBinding.SingleBinding -> { 94 | doFinalStep { 95 | selectedValue.action.forEach { action -> 96 | executeAction(action, dataContext) 97 | } 98 | } 99 | } 100 | is LeaderBinding.GroupBinding -> { 101 | LeaderListStep(null, dataContext, selectedValue.bindings) 102 | } 103 | null -> null 104 | } 105 | 106 | override fun getBackgroundFor(value: LeaderBinding?) = UIUtil.getPanelBackground() 107 | } 108 | 109 | /** 110 | * Renderer for leader binding items in the popup list. 111 | */ 112 | class ActionItemRenderer : JPanel(GridBagLayout()), ListCellRenderer { 113 | private val leftInset = 12 114 | private val innerInset = 8 115 | private val emptyMenuRightArrowIcon = EmptyIcon.create(AllIcons.General.ArrowRight) 116 | 117 | private val listSelectionBackground = UIUtil.getListSelectionBackground(true) 118 | 119 | private val actionTextLabel = JLabel() 120 | private val bindingKeyLabel = JLabel() 121 | 122 | init { 123 | val topBottom = 3 124 | val insets = JBUI.insets(topBottom, leftInset, topBottom, 0) 125 | 126 | var gbc = GridBag().setDefaultAnchor(GridBag.WEST) 127 | gbc = gbc.nextLine().next().insets(insets) 128 | add(bindingKeyLabel, gbc) 129 | gbc = gbc.next().insetLeft(innerInset) 130 | add(actionTextLabel, gbc) 131 | gbc = gbc.next().fillCellHorizontally().weightx(1.0).anchor(GridBag.EAST) 132 | .insets(JBUI.insets(topBottom, leftInset, topBottom, innerInset)) 133 | add(JLabel(emptyMenuRightArrowIcon), gbc) 134 | } 135 | 136 | override fun getListCellRendererComponent( 137 | list: JList?, 138 | value: LeaderBinding, 139 | index: Int, 140 | selected: Boolean, 141 | focused: Boolean 142 | ): JComponent { 143 | bindingKeyLabel.text = value.char 144 | bindingKeyLabel.font = UIUtil.getFontWithFallback("monospaced", Font.PLAIN, 12) 145 | bindingKeyLabel.foreground = UIUtil.getListForeground(selected, false) 146 | actionTextLabel.text = value.description 147 | actionTextLabel.foreground = UIUtil.getListForeground(selected, value is LeaderBinding.GroupBinding) 148 | UIUtil.setBackgroundRecursively( 149 | this, 150 | if (selected) listSelectionBackground else UIUtil.getPanelBackground() 151 | ) 152 | return this 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/uiTest/kotlin/io/github/mishkun/ataman/AtamanActionTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.driver.client.Driver 4 | import com.intellij.driver.sdk.invokeAction 5 | import com.intellij.driver.sdk.ui.Finder 6 | import com.intellij.driver.sdk.ui.components.UiComponent 7 | import com.intellij.driver.sdk.ui.components.common.WelcomeScreenUI 8 | import com.intellij.driver.sdk.ui.components.common.ideFrame 9 | import com.intellij.driver.sdk.ui.components.common.toolwindows.projectView 10 | import com.intellij.driver.sdk.ui.components.common.vcsToolWindow 11 | import com.intellij.driver.sdk.ui.components.common.welcomeScreen 12 | import com.intellij.driver.sdk.ui.ui 13 | import com.intellij.driver.sdk.waitForProjectOpen 14 | import com.intellij.ide.starter.driver.engine.BackgroundRun 15 | import com.intellij.ide.starter.driver.engine.runIdeWithDriver 16 | import com.intellij.ide.starter.ide.IDETestContext 17 | import com.intellij.ide.starter.runner.IDECommandLine 18 | import com.intellij.ide.starter.runner.Starter 19 | import com.intellij.openapi.util.io.toNioPathOrNull 20 | import com.intellij.tools.ide.performanceTesting.commands.CommandChain 21 | import com.intellij.tools.ide.performanceTesting.commands.waitForDumbMode 22 | import org.hamcrest.MatcherAssert.assertThat 23 | import org.hamcrest.Matchers.equalTo 24 | import org.hamcrest.Matchers.notNullValue 25 | import org.junit.Test 26 | import java.awt.event.KeyEvent 27 | 28 | class AtamanActionTest { 29 | 30 | @Test 31 | fun `activates ide specific binding`() { 32 | val testContext = Starter 33 | .newContext("test_plugin_action", UltimateCase.simpleProject) 34 | .prepareProjectCleanImport() 35 | .disableAutoImport(disabled = true) 36 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!) 37 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde { 38 | this.invokeAction("LeaderAction") 39 | ideFrame { 40 | assertThat(atamanPopup().isVisible(), equalTo(true)) 41 | } 42 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_Q) 43 | ideFrame { 44 | vcsToolWindow { 45 | assertThat(isVisible(), equalTo(true)) 46 | } 47 | } 48 | } 49 | } 50 | 51 | @Test 52 | fun `should not activate transparent leader action if speed search is active`() { 53 | val testContext = Starter 54 | .newContext("transparent_action", MyTestCase.simpleProject) 55 | .prepareProjectCleanImport() 56 | .disableAutoImport(disabled = true) 57 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!) 58 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde { 59 | waitForProjectOpen() 60 | // trigger speed search in project view 61 | ideFrame { 62 | ui.robot.focus(this.projectView { }.component) 63 | ui.robot.type('p') 64 | } 65 | // trigger action. This time it should not display the popup because speed search is active 66 | runCatching { this.invokeAction("TransparentLeaderAction") } 67 | ideFrame { 68 | val popup = runCatching { atamanPopup().component } 69 | assertThat(popup, notNullValue()) 70 | } 71 | // close speed search 72 | ui.robot.pressKey(KeyEvent.VK_ESCAPE) 73 | 74 | // trigger action again. This time it should display the popup 75 | this.invokeAction("TransparentLeaderAction") 76 | ideFrame { 77 | assertThat(atamanPopup().isVisible(), equalTo(true)) 78 | } 79 | } 80 | } 81 | 82 | @Test 83 | fun test_plugin_main_action() { 84 | val testContext = Starter 85 | .newContext("test_plugin_action", MyTestCase.simpleProject) 86 | .prepareProjectCleanImport() 87 | .disableAutoImport(disabled = true) 88 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!) 89 | CommandChain().waitForDumbMode(10) 90 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde { 91 | waitForProjectOpen() 92 | this.invokeAction("LeaderAction") 93 | ideFrame { 94 | assertThat(atamanPopup().isVisible(), equalTo(true)) 95 | } 96 | exitProjectViaAtamanAndCheckSuccess() 97 | } 98 | } 99 | 100 | private fun IDETestContext.runIdeWithConfiguredDriver(): BackgroundRun { 101 | val commands = CommandChain().waitForDumbMode(10) 102 | 103 | return this.runIdeWithDriver( 104 | commands = commands, 105 | commandLine = { 106 | IDECommandLine.OpenTestCaseProject( 107 | this, 108 | listOf("-Dataman.configFolder=${System.getProperty("ataman.configFolder")}") 109 | ) 110 | } 111 | ) 112 | } 113 | 114 | private fun Finder.atamanPopup(): UiComponent = xx( 115 | "//div[@text='Ataman']" 116 | ).list().first() 117 | 118 | private fun Driver.exitProjectViaAtamanAndCheckSuccess(): WelcomeScreenUI { 119 | this.ui.robot.waitForIdle() 120 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_Q) 121 | this.ui.robot.waitForIdle() 122 | takeScreenshot("test_plugin_main_action") 123 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_F1) 124 | this.ui.robot.waitForIdle() 125 | return this.welcomeScreen { 126 | this.isVisible() 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/LeaderKeyDispatcher.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.openapi.actionSystem.DataContext 4 | import com.intellij.openapi.components.Service 5 | import com.intellij.openapi.diagnostic.Logger 6 | import java.awt.KeyEventDispatcher 7 | import java.awt.KeyboardFocusManager 8 | import java.awt.event.KeyEvent 9 | import javax.swing.KeyStroke 10 | 11 | private val LOG = Logger.getInstance(LeaderKeyDispatcher::class.java) 12 | 13 | /** 14 | * Listener interface for LeaderKeyDispatcher state changes. 15 | */ 16 | interface LeaderStateListener { 17 | /** Called when a SingleBinding is matched and should be executed. */ 18 | fun onBindingSelected(binding: LeaderBinding.SingleBinding) 19 | 20 | /** Called when entering a group (for nested popup creation). */ 21 | fun onGroupEntered(group: LeaderBinding.GroupBinding) 22 | 23 | /** Called when the dispatcher is dismissed (escape or action complete). */ 24 | fun onDismiss() 25 | } 26 | 27 | /** 28 | * State machine that captures all key events and matches against bindings. 29 | * This is the single source of truth for key handling in the leader popup flow. 30 | */ 31 | @Service(Service.Level.APP) 32 | class LeaderKeyDispatcher { 33 | private var isCapturing = false 34 | private var keyEventDispatcher: KeyEventDispatcher? = null 35 | private val listeners = mutableListOf() 36 | 37 | private var currentBindings: List = emptyList() 38 | private var currentBindingsMap: Map = emptyMap() 39 | private var currentCharMap: Map = emptyMap() 40 | private var dataContext: DataContext? = null 41 | 42 | /** 43 | * Start capturing key events and matching against the provided bindings. 44 | * Call this immediately when LeaderAction is invoked, before creating the popup. 45 | */ 46 | fun start(bindings: List, dataContext: DataContext) { 47 | if (isCapturing) { 48 | // Already capturing - stop first 49 | stop() 50 | } 51 | 52 | isCapturing = true 53 | currentBindings = bindings 54 | currentBindingsMap = bindings.toKeyStrokeMap() 55 | currentCharMap = bindings.toCharMap() 56 | this.dataContext = dataContext 57 | 58 | LOG.warn("LeaderKeyDispatcher started with ${bindings.size} bindings, charMap keys: ${currentCharMap.keys}") 59 | 60 | keyEventDispatcher = KeyEventDispatcher { event -> 61 | if (!isCapturing) return@KeyEventDispatcher false 62 | handleKeyEvent(event) 63 | } 64 | 65 | KeyboardFocusManager.getCurrentKeyboardFocusManager() 66 | .addKeyEventDispatcher(keyEventDispatcher) 67 | 68 | LOG.warn("KeyEventDispatcher registered") 69 | } 70 | 71 | /** 72 | * Stop capturing key events and clean up. 73 | */ 74 | fun stop() { 75 | isCapturing = false 76 | keyEventDispatcher?.let { 77 | KeyboardFocusManager.getCurrentKeyboardFocusManager() 78 | .removeKeyEventDispatcher(it) 79 | } 80 | keyEventDispatcher = null 81 | currentBindings = emptyList() 82 | currentBindingsMap = emptyMap() 83 | currentCharMap = emptyMap() 84 | dataContext = null 85 | } 86 | 87 | /** 88 | * Update bindings for nested group navigation. 89 | * Called when entering a sub-group to update what keys we're matching against. 90 | */ 91 | fun updateBindings(bindings: List) { 92 | currentBindings = bindings 93 | currentBindingsMap = bindings.toKeyStrokeMap() 94 | currentCharMap = bindings.toCharMap() 95 | } 96 | 97 | fun addListener(listener: LeaderStateListener) { 98 | listeners.add(listener) 99 | } 100 | 101 | fun removeListener(listener: LeaderStateListener) { 102 | listeners.remove(listener) 103 | } 104 | 105 | private fun handleKeyEvent(event: KeyEvent): Boolean { 106 | LOG.warn("LeaderKeyDispatcher received event: id=${event.id}, keyCode=${event.keyCode}, keyChar='${event.keyChar}', isCapturing=$isCapturing") 107 | 108 | when (event.id) { 109 | KeyEvent.KEY_PRESSED -> { 110 | LOG.warn("KEY_PRESSED: keyCode=${event.keyCode}") 111 | when (event.keyCode) { 112 | KeyEvent.VK_ESCAPE -> { 113 | LOG.warn("Escape pressed, dismissing") 114 | notifyDismiss() 115 | return true 116 | } 117 | // Skip modifier-only key presses 118 | KeyEvent.VK_SHIFT, KeyEvent.VK_CONTROL, KeyEvent.VK_ALT, KeyEvent.VK_META -> { 119 | return false 120 | } 121 | } 122 | // Consume KEY_PRESSED to prevent other handlers, but wait for KEY_RELEASED to act 123 | return true 124 | } 125 | KeyEvent.KEY_RELEASED -> { 126 | LOG.warn("KEY_RELEASED: keyCode=${event.keyCode}, keyChar='${event.keyChar}'") 127 | // Skip modifier-only key releases 128 | when (event.keyCode) { 129 | KeyEvent.VK_SHIFT, KeyEvent.VK_CONTROL, KeyEvent.VK_ALT, KeyEvent.VK_META, KeyEvent.VK_ESCAPE -> { 130 | return false 131 | } 132 | } 133 | val binding = findBindingForEvent(event) 134 | LOG.warn("Found binding: $binding for char '${event.keyChar}', charMap keys: ${currentCharMap.keys}") 135 | if (binding != null) { 136 | processBinding(binding) 137 | return true 138 | } 139 | // No match - consume anyway to prevent other handlers 140 | return true 141 | } 142 | } 143 | return false 144 | } 145 | 146 | private fun findBindingForEvent(event: KeyEvent): LeaderBinding? { 147 | // Match by character directly 148 | currentCharMap[event.keyChar]?.let { return it } 149 | 150 | // Also try matching by KeyStroke 151 | val keyStroke = event.toKeyStroke() 152 | if (keyStroke != null) { 153 | currentBindingsMap[keyStroke]?.let { return it } 154 | } 155 | 156 | return null 157 | } 158 | 159 | private fun processBinding(binding: LeaderBinding) { 160 | when (binding) { 161 | is LeaderBinding.SingleBinding -> { 162 | val ctx = dataContext 163 | if (ctx != null) { 164 | binding.action.forEach { actionId -> 165 | executeAction(actionId, ctx) 166 | } 167 | } 168 | listeners.toList().forEach { it.onBindingSelected(binding) } 169 | notifyDismiss() 170 | } 171 | is LeaderBinding.GroupBinding -> { 172 | updateBindings(binding.bindings) 173 | listeners.toList().forEach { it.onGroupEntered(binding) } 174 | } 175 | } 176 | } 177 | 178 | private fun notifyDismiss() { 179 | listeners.toList().forEach { it.onDismiss() } 180 | } 181 | } 182 | 183 | /** 184 | * Utility to convert a KeyEvent to a KeyStroke for matching against bindings. 185 | */ 186 | fun KeyEvent.toKeyStroke(): KeyStroke? { 187 | return when (id) { 188 | KeyEvent.KEY_TYPED -> { 189 | if (keyChar != KeyEvent.CHAR_UNDEFINED) { 190 | KeyStroke.getKeyStroke(keyChar) 191 | } else null 192 | } 193 | KeyEvent.KEY_PRESSED -> { 194 | KeyStroke.getKeyStroke(keyCode, modifiersEx, false) 195 | } 196 | KeyEvent.KEY_RELEASED -> { 197 | KeyStroke.getKeyStroke(keyCode, modifiersEx, true) 198 | } 199 | else -> null 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/mishkun/ataman/AtamanConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import com.intellij.notification.NotificationGroupManager 4 | import com.intellij.notification.NotificationType 5 | import com.intellij.openapi.components.service 6 | import com.intellij.openapi.project.Project 7 | import com.intellij.openapi.ui.playback.commands.KeyStrokeMap 8 | import com.typesafe.config.ConfigFactory 9 | import java.awt.event.KeyEvent 10 | import java.io.File 11 | import java.io.IOException 12 | import javax.swing.KeyStroke 13 | 14 | const val ATAMAN_RC_FILENAME = ".atamanrc.config" 15 | private const val COMMON_BINDINGS_KEY = "bindings" 16 | 17 | val RC_TEMPLATE = """ 18 | # This file is written in HOCON (Human-Optimized Config Object Notation) format. 19 | # For more information about HOCON see https://github.com/lightbend/config/blob/master/HOCON.md 20 | 21 | bindings { 22 | q { 23 | description: Session... 24 | bindings { 25 | f { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config } 26 | } 27 | }, 28 | } 29 | """.trimIndent() 30 | 31 | fun updateConfig(project: Project, configDir: File, ideProductKey: String) { 32 | parseConfig(configDir, ideProductKey).fold( 33 | onSuccess = { values -> 34 | service().parsedBindings = values 35 | }, 36 | onFailure = { error -> 37 | when (error) { 38 | is IllegalStateException -> project.showNotification( 39 | "Bindings schema is invalid. Aborting...\n${error.message}", 40 | NotificationType.ERROR 41 | ) 42 | 43 | is IOException -> project.showNotification( 44 | "Config file is not found and I could not create it. Aborting...\n${error.message}", 45 | NotificationType.ERROR 46 | ) 47 | 48 | else -> project.showNotification( 49 | "Config is malformed. Aborting...\n${error.message}", 50 | NotificationType.ERROR 51 | ) 52 | } 53 | }) 54 | } 55 | 56 | private fun Project.showNotification( 57 | message: String, 58 | notificationType: NotificationType = NotificationType.INFORMATION 59 | ) { 60 | NotificationGroupManager.getInstance() 61 | .getNotificationGroup("io.github.mishkun.ataman") 62 | .createNotification( 63 | "Ataman", 64 | message, 65 | notificationType 66 | ) 67 | .notify(this) 68 | } 69 | 70 | fun mergeBindings(bindingConfig: List, overrideConfig: List): List { 71 | val commonBindingsMap = bindingConfig.associateBy { it.char } 72 | val productBindingsMap = overrideConfig.associateBy { it.char } 73 | val commonKeys = commonBindingsMap.keys.toSet().intersect(productBindingsMap.keys) 74 | val mergedBindings = commonKeys.associateWith { key -> 75 | val commonBinding = commonBindingsMap.getValue(key) 76 | val productBinding = productBindingsMap.getValue(key) 77 | when { 78 | commonBinding is LeaderBinding.GroupBinding && productBinding is LeaderBinding.GroupBinding -> { 79 | productBinding.copy( 80 | bindings = mergeBindings(commonBinding.bindings, productBinding.bindings) 81 | ) 82 | } 83 | 84 | else -> productBinding 85 | } 86 | } 87 | return (commonBindingsMap + productBindingsMap + mergedBindings).values.toList() 88 | } 89 | 90 | fun parseConfig(configDir: File, ideProductKey: String): Result> { 91 | return runCatching { 92 | val rcFile = findOrCreateRcFile(configDir) 93 | val config = execFile(rcFile) 94 | val (commonBindingsParsed, commonThrowables) = buildBindingsTree(config, COMMON_BINDINGS_KEY) 95 | val (productBindingsParsed, productThrowables) = buildBindingsTree(config, ideProductKey) 96 | val bindings = mergeBindings(commonBindingsParsed, productBindingsParsed) 97 | val throwables = commonThrowables + productThrowables 98 | if (throwables.isNotEmpty()) { 99 | throw IllegalStateException(throwables.joinToString("\n")) 100 | } else { 101 | bindings 102 | } 103 | } 104 | } 105 | private val keyStrokeMap = KeyStrokeMap() 106 | 107 | // Extension function to get a keystroke from the map with the isLeafBinding parameter 108 | private fun KeyStrokeMap.get(char: Char, isLeafBinding: Boolean): KeyStroke { 109 | val keyStroke = get(char) 110 | // If we need to override the release value 111 | return KeyStroke.getKeyStroke( 112 | keyStroke.keyCode, 113 | keyStroke.modifiers, 114 | isLeafBinding // Use key release only for leaf bindings 115 | ) 116 | } 117 | 118 | fun getKeyStroke(key: String, isLeafBinding: Boolean = false): KeyStroke { 119 | val trimmedKey = key.trim('"') 120 | return when (trimmedKey.length) { 121 | 1 -> keyStrokeMap.get(trimmedKey[0], isLeafBinding) 122 | else -> getFKeyStroke(key, isLeafBinding) 123 | } 124 | } 125 | 126 | fun getFKeyStroke(key: String, isLeafBinding: Boolean = false): KeyStroke = KeyStroke.getKeyStroke( 127 | key.substringAfter("F").toInt() + 111, 128 | 0, 129 | isLeafBinding // Only leaf bindings use key release 130 | ) 131 | 132 | fun getKeyStroke(char: Char, isLeafBinding: Boolean = false): KeyStroke = KeyStroke.getKeyStroke( 133 | KeyEvent.getExtendedKeyCodeForChar(char.code), 134 | if (char.isUpperCase()) KeyEvent.SHIFT_DOWN_MASK else 0, 135 | isLeafBinding // Only leaf bindings use key release 136 | ) 137 | 138 | private const val BINDINGS_KEYWORD = COMMON_BINDINGS_KEY 139 | private const val DESCRIPTION_KEYWORD = "description" 140 | private const val ACTION_ID_KEYWORD = "actionId" 141 | 142 | @Suppress("UNCHECKED_CAST") 143 | private fun execFile(file: File): Map>> = 144 | (ConfigFactory.parseFile(file).root().unwrapped() as Map>).mapValues { it.value.toList() } 145 | 146 | private fun buildBindingsTree( 147 | rootBindingConfig: Map>>, 148 | key: String 149 | ): Pair, List> { 150 | val commonBindings = rootBindingConfig[key] ?: emptyList() 151 | return buildBindingsTree(commonBindings) 152 | } 153 | 154 | @Suppress("UNCHECKED_CAST") 155 | private fun buildBindingsTree(bindingConfig: List>): Pair, List> { 156 | val errors = mutableListOf() 157 | val bindings = bindingConfig.mapNotNull { (keyword, bodyObject) -> 158 | val body = bodyObject as Map 159 | val description = bodyObject[DESCRIPTION_KEYWORD] as String 160 | when { 161 | body.containsKey(ACTION_ID_KEYWORD) -> { 162 | // This is a leaf binding, should use key release 163 | when (val actionId = body[ACTION_ID_KEYWORD]) { 164 | is String -> LeaderBinding.SingleBinding( 165 | getKeyStroke(keyword, isLeafBinding = true), 166 | keyword, 167 | description, 168 | actionId 169 | ) 170 | is List<*> -> LeaderBinding.SingleBinding( 171 | getKeyStroke(keyword, isLeafBinding = true), 172 | keyword, 173 | description, 174 | actionId as List 175 | ) 176 | 177 | else -> { 178 | errors.add("Expected either String or List for $ACTION_ID_KEYWORD but got $actionId") 179 | null 180 | } 181 | } 182 | } 183 | 184 | body.containsKey(BINDINGS_KEYWORD) -> { 185 | // This is a group binding, should use key press 186 | val childBindingsObject = body[BINDINGS_KEYWORD] as Map 187 | val childBindings = buildBindingsTree(childBindingsObject.toList()).first 188 | LeaderBinding.GroupBinding( 189 | getKeyStroke(keyword, isLeafBinding = false), 190 | keyword, 191 | description, 192 | childBindings 193 | ) 194 | } 195 | 196 | else -> { 197 | errors.add("Expected either $ACTION_ID_KEYWORD or $BINDINGS_KEYWORD for $keyword but got $bodyObject") 198 | null 199 | } 200 | } 201 | }.sortedByDescending { it.char }.sortedBy { it.char.lowercase() } 202 | return bindings to errors 203 | } 204 | 205 | fun findOrCreateRcFile(homeDir: File): File { 206 | val file = File(homeDir, ATAMAN_RC_FILENAME) 207 | if (file.exists()) { 208 | return file 209 | } else { 210 | file.createNewFile() 211 | file.writeText(RC_TEMPLATE) 212 | return file 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/LeaderKeyDispatcherTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Assert.assertFalse 5 | import org.junit.Assert.assertNull 6 | import org.junit.Assert.assertTrue 7 | import org.junit.Before 8 | import org.junit.Test 9 | import java.awt.Component 10 | import java.awt.event.KeyEvent 11 | import javax.swing.JPanel 12 | import javax.swing.KeyStroke 13 | 14 | class LeaderKeyDispatcherTest { 15 | 16 | private lateinit var dispatcher: LeaderKeyDispatcher 17 | 18 | @Before 19 | fun setUp() { 20 | dispatcher = LeaderKeyDispatcher() 21 | } 22 | 23 | @Test 24 | fun `start can be called multiple times safely`() { 25 | // This would require a DataContext mock, so we just verify no exception 26 | // In real usage, start() stops any existing capture first 27 | } 28 | 29 | @Test 30 | fun `stop can be called without start`() { 31 | dispatcher.stop() // Should not throw 32 | } 33 | 34 | @Test 35 | fun `stop can be called multiple times safely`() { 36 | dispatcher.stop() 37 | dispatcher.stop() // Should not throw 38 | } 39 | 40 | @Test 41 | fun `addListener and removeListener work correctly`() { 42 | var dismissed = false 43 | val listener = object : LeaderStateListener { 44 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) {} 45 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) {} 46 | override fun onDismiss() { dismissed = true } 47 | } 48 | 49 | dispatcher.addListener(listener) 50 | dispatcher.removeListener(listener) 51 | // After removal, listener should not be called 52 | // (can't easily test without starting dispatcher with mock DataContext) 53 | } 54 | 55 | @Test 56 | fun `removeListener is safe when listener not present`() { 57 | val listener = object : LeaderStateListener { 58 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) {} 59 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) {} 60 | override fun onDismiss() {} 61 | } 62 | 63 | dispatcher.removeListener(listener) // Should not throw 64 | } 65 | } 66 | 67 | class KeyEventToKeyStrokeTest { 68 | 69 | private lateinit var dummyComponent: Component 70 | 71 | @Before 72 | fun setUp() { 73 | dummyComponent = JPanel() 74 | } 75 | 76 | @Test 77 | fun `toKeyStroke converts KEY_TYPED event to KeyStroke`() { 78 | val event = KeyEvent( 79 | dummyComponent, 80 | KeyEvent.KEY_TYPED, 81 | System.currentTimeMillis(), 82 | 0, 83 | KeyEvent.VK_UNDEFINED, 84 | 'a' 85 | ) 86 | val keyStroke = event.toKeyStroke() 87 | assertEquals(KeyStroke.getKeyStroke('a'), keyStroke) 88 | } 89 | 90 | @Test 91 | fun `toKeyStroke converts KEY_PRESSED event to KeyStroke`() { 92 | val event = KeyEvent( 93 | dummyComponent, 94 | KeyEvent.KEY_PRESSED, 95 | System.currentTimeMillis(), 96 | 0, 97 | KeyEvent.VK_UP, 98 | KeyEvent.CHAR_UNDEFINED 99 | ) 100 | val keyStroke = event.toKeyStroke() 101 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), keyStroke) 102 | } 103 | 104 | @Test 105 | fun `toKeyStroke converts KEY_RELEASED event to KeyStroke with onKeyRelease true`() { 106 | val event = KeyEvent( 107 | dummyComponent, 108 | KeyEvent.KEY_RELEASED, 109 | System.currentTimeMillis(), 110 | 0, 111 | KeyEvent.VK_A, 112 | 'a' 113 | ) 114 | val keyStroke = event.toKeyStroke() 115 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true), keyStroke) 116 | } 117 | 118 | @Test 119 | fun `toKeyStroke handles navigation keys`() { 120 | val event = KeyEvent( 121 | dummyComponent, 122 | KeyEvent.KEY_PRESSED, 123 | System.currentTimeMillis(), 124 | 0, 125 | KeyEvent.VK_ESCAPE, 126 | KeyEvent.CHAR_UNDEFINED 127 | ) 128 | val keyStroke = event.toKeyStroke() 129 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), keyStroke) 130 | } 131 | 132 | @Test 133 | fun `toKeyStroke preserves modifiers for KEY_PRESSED`() { 134 | val event = KeyEvent( 135 | dummyComponent, 136 | KeyEvent.KEY_PRESSED, 137 | System.currentTimeMillis(), 138 | KeyEvent.CTRL_DOWN_MASK, 139 | KeyEvent.VK_C, 140 | 'c' 141 | ) 142 | val keyStroke = event.toKeyStroke() 143 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_C, KeyEvent.CTRL_DOWN_MASK), keyStroke) 144 | } 145 | 146 | @Test 147 | fun `toKeyStroke preserves modifiers for KEY_RELEASED`() { 148 | val event = KeyEvent( 149 | dummyComponent, 150 | KeyEvent.KEY_RELEASED, 151 | System.currentTimeMillis(), 152 | KeyEvent.SHIFT_DOWN_MASK, 153 | KeyEvent.VK_A, 154 | 'A' 155 | ) 156 | val keyStroke = event.toKeyStroke() 157 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK, true), keyStroke) 158 | } 159 | 160 | @Test 161 | fun `toKeyStroke returns null for unknown event type`() { 162 | // Use an arbitrary event ID that's not KEY_PRESSED, KEY_RELEASED, or KEY_TYPED 163 | val event = KeyEvent( 164 | dummyComponent, 165 | 999, // Unknown event type 166 | System.currentTimeMillis(), 167 | 0, 168 | KeyEvent.VK_A, 169 | 'a' 170 | ) 171 | val keyStroke = event.toKeyStroke() 172 | assertNull(keyStroke) 173 | } 174 | } 175 | 176 | class LeaderBindingCharMapTest { 177 | 178 | @Test 179 | fun `toCharMap returns empty map for empty list`() { 180 | val bindings = emptyList() 181 | assertTrue(bindings.toCharMap().isEmpty()) 182 | } 183 | 184 | @Test 185 | fun `toCharMap creates map keyed by char`() { 186 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 187 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true) 188 | 189 | val bindings = listOf( 190 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"), 191 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB") 192 | ) 193 | 194 | val map = bindings.toCharMap() 195 | 196 | assertEquals(2, map.size) 197 | assertEquals(bindings[0], map['a']) 198 | assertEquals(bindings[1], map['b']) 199 | } 200 | 201 | @Test 202 | fun `toCharMap handles uppercase chars`() { 203 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK, true) 204 | 205 | val bindings = listOf( 206 | LeaderBinding.SingleBinding(keyStrokeA, "A", "Action A", "ActionA") 207 | ) 208 | 209 | val map = bindings.toCharMap() 210 | 211 | assertEquals(1, map.size) 212 | assertEquals(bindings[0], map['A']) 213 | } 214 | 215 | @Test 216 | fun `toCharMap skips bindings with multi-char keys`() { 217 | val keyStrokeF1 = KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true) 218 | 219 | val bindings = listOf( 220 | LeaderBinding.SingleBinding(keyStrokeF1, "F1", "Action F1", "ActionF1") 221 | ) 222 | 223 | val map = bindings.toCharMap() 224 | 225 | assertTrue(map.isEmpty()) 226 | } 227 | 228 | @Test 229 | fun `toCharMap works with group bindings`() { 230 | val keyStrokeG = KeyStroke.getKeyStroke(KeyEvent.VK_G, 0, false) 231 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 232 | 233 | val nestedBindings = listOf( 234 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Nested Action", "NestedAction") 235 | ) 236 | val groupBinding = LeaderBinding.GroupBinding(keyStrokeG, "g", "Group", nestedBindings) 237 | 238 | val map = listOf(groupBinding).toCharMap() 239 | 240 | assertEquals(1, map.size) 241 | assertEquals(groupBinding, map['g']) 242 | } 243 | 244 | @Test 245 | fun `toCharMap mixed single and multi-char bindings`() { 246 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true) 247 | val keyStrokeF1 = KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true) 248 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true) 249 | 250 | val bindings = listOf( 251 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"), 252 | LeaderBinding.SingleBinding(keyStrokeF1, "F1", "Action F1", "ActionF1"), 253 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB") 254 | ) 255 | 256 | val map = bindings.toCharMap() 257 | 258 | assertEquals(2, map.size) 259 | assertEquals(bindings[0], map['a']) 260 | assertEquals(bindings[2], map['b']) 261 | assertFalse(map.containsKey('F')) 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /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/HEAD/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 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Use the maximum available, or set MAX_FD != -1 to use that value. 89 | MAX_FD=maximum 90 | 91 | warn () { 92 | echo "$*" 93 | } >&2 94 | 95 | die () { 96 | echo 97 | echo "$*" 98 | echo 99 | exit 1 100 | } >&2 101 | 102 | # OS specific support (must be 'true' or 'false'). 103 | cygwin=false 104 | msys=false 105 | darwin=false 106 | nonstop=false 107 | case "$( uname )" in #( 108 | CYGWIN* ) cygwin=true ;; #( 109 | Darwin* ) darwin=true ;; #( 110 | MSYS* | MINGW* ) msys=true ;; #( 111 | NONSTOP* ) nonstop=true ;; 112 | esac 113 | 114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 115 | 116 | 117 | # Determine the Java command to use to start the JVM. 118 | if [ -n "$JAVA_HOME" ] ; then 119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 120 | # IBM's JDK on AIX uses strange locations for the executables 121 | JAVACMD=$JAVA_HOME/jre/sh/java 122 | else 123 | JAVACMD=$JAVA_HOME/bin/java 124 | fi 125 | if [ ! -x "$JAVACMD" ] ; then 126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 127 | 128 | Please set the JAVA_HOME variable in your environment to match the 129 | location of your Java installation." 130 | fi 131 | else 132 | JAVACMD=java 133 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 134 | 135 | Please set the JAVA_HOME variable in your environment to match the 136 | location of your Java installation." 137 | fi 138 | 139 | # Increase the maximum file descriptors if we can. 140 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 141 | case $MAX_FD in #( 142 | max*) 143 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 144 | # shellcheck disable=SC3045 145 | MAX_FD=$( ulimit -H -n ) || 146 | warn "Could not query maximum file descriptor limit" 147 | esac 148 | case $MAX_FD in #( 149 | '' | soft) :;; #( 150 | *) 151 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 152 | # shellcheck disable=SC3045 153 | ulimit -n "$MAX_FD" || 154 | warn "Could not set maximum file descriptor limit to $MAX_FD" 155 | esac 156 | fi 157 | 158 | # Collect all arguments for the java command, stacking in reverse order: 159 | # * args from the command line 160 | # * the main class name 161 | # * -classpath 162 | # * -D...appname settings 163 | # * --module-path (only if needed) 164 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 165 | 166 | # For Cygwin or MSYS, switch paths to Windows format before running java 167 | if "$cygwin" || "$msys" ; then 168 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 169 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 170 | 171 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 172 | 173 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 174 | for arg do 175 | if 176 | case $arg in #( 177 | -*) false ;; # don't mess with options #( 178 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 179 | [ -e "$t" ] ;; #( 180 | *) false ;; 181 | esac 182 | then 183 | arg=$( cygpath --path --ignore --mixed "$arg" ) 184 | fi 185 | # Roll the args list around exactly as many times as the number of 186 | # args, so each arg winds up back in the position where it started, but 187 | # possibly modified. 188 | # 189 | # NB: a `for` loop captures its iteration list before it begins, so 190 | # changing the positional parameters here affects neither the number of 191 | # iterations, nor the values presented in `arg`. 192 | shift # remove old arg 193 | set -- "$@" "$arg" # push replacement arg 194 | done 195 | fi 196 | 197 | 198 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 199 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 200 | 201 | # Collect all arguments for the java command; 202 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 203 | # shell script including quotes and variable substitutions, so put them in 204 | # double quotes to make sure that they get re-expanded; and 205 | # * put everything else in single quotes, so that it's not re-expanded. 206 | 207 | set -- \ 208 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 209 | -classpath "$CLASSPATH" \ 210 | org.gradle.wrapper.GradleWrapperMain \ 211 | "$@" 212 | 213 | # Stop when "xargs" is not available. 214 | if ! command -v xargs >/dev/null 2>&1 215 | then 216 | die "xargs is not available" 217 | fi 218 | 219 | # Use "xargs" to parse quoted args. 220 | # 221 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 222 | # 223 | # In Bash we could simply go: 224 | # 225 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 226 | # set -- "${ARGS[@]}" "$@" 227 | # 228 | # but POSIX shell has neither arrays nor command substitution, so instead we 229 | # post-process each arg (as a line of input to sed) to backslash-escape any 230 | # character that might be a shell metacharacter, then use eval to reverse 231 | # that process (while maintaining the separation between arguments), and wrap 232 | # the whole thing up as a single "set" statement. 233 | # 234 | # This will of course break if any of these variables contains a newline or 235 | # an unmatched quote. 236 | # 237 | 238 | eval "set -- $( 239 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 240 | xargs -n1 | 241 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 242 | tr '\n' ' ' 243 | )" '"$@"' 244 | 245 | exec "$JAVACMD" "$@" 246 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/mishkun/ataman/AtamanConfigParsingTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.mishkun.ataman 2 | 3 | import io.github.mishkun.ataman.core.setupStubConfigDir 4 | import org.hamcrest.MatcherAssert.assertThat 5 | import org.hamcrest.Matchers 6 | import org.junit.Rule 7 | import org.junit.Test 8 | import org.junit.rules.TemporaryFolder 9 | import java.awt.event.KeyEvent 10 | import javax.swing.KeyStroke 11 | 12 | class AtamanConfigParsingTest { 13 | 14 | @get:Rule 15 | val tmpFolder = TemporaryFolder() 16 | 17 | @Test 18 | fun `supports special characters`() { 19 | val parsedBindings = parseConfig( 20 | configDir = tmpFolder.setupStubConfigDir( 21 | text = """ 22 | |bindings { 23 | | "." { actionId: CommentByLineComment, description: Comment } 24 | | ">" { actionId: CommentByLineComment, description: Comment } 25 | |}""".trimMargin() 26 | ), 27 | ideProductKey = "IC" 28 | ) 29 | assertThat(parsedBindings.exceptionOrNull(), Matchers.nullValue()) 30 | 31 | // Get the parsed bindings 32 | val bindings = parsedBindings.getOrNull()!! 33 | 34 | assertThat( 35 | bindings, Matchers.equalTo( 36 | listOf( 37 | LeaderBinding.SingleBinding( 38 | KeyStroke.getKeyStroke(KeyEvent.VK_PERIOD, 0, true), // Use key release for leaf binding 39 | ".", 40 | "Comment", 41 | "CommentByLineComment", 42 | ), 43 | LeaderBinding.SingleBinding( 44 | KeyStroke.getKeyStroke(KeyEvent.VK_PERIOD, KeyEvent.SHIFT_DOWN_MASK, true), // Use key release for leaf binding 45 | ">", 46 | "Comment", 47 | "CommentByLineComment", 48 | ) 49 | ) 50 | ) 51 | ) 52 | 53 | // Verify that single bindings use key release 54 | bindings.forEach { 55 | assertThat("Leaf binding should use key release event", (it as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true)) 56 | } 57 | } 58 | 59 | @Test 60 | fun `supports multibindings`() { 61 | val parsedBindings = parseConfig( 62 | configDir = tmpFolder.setupStubConfigDir( 63 | text = """ 64 | |bindings { 65 | | w { actionId: [SplitVertically, Unsplit], description: Split vertically and unsplit } 66 | |}""".trimMargin() 67 | ), 68 | ideProductKey = "IC" 69 | ) 70 | 71 | // Get the parsed bindings 72 | val bindings = parsedBindings.getOrNull()!! 73 | 74 | assertThat( 75 | bindings, Matchers.equalTo( 76 | listOf( 77 | LeaderBinding.SingleBinding( 78 | KeyStroke.getKeyStroke(KeyEvent.VK_W, 0, true), // Use key release for leaf binding 79 | "w", 80 | "Split vertically and unsplit", 81 | listOf("SplitVertically", "Unsplit") 82 | ) 83 | ) 84 | ) 85 | ) 86 | 87 | // Verify that single bindings use key release 88 | assertThat("Leaf binding should use key release event", (bindings[0] as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true)) 89 | } 90 | 91 | @Test 92 | fun `merges ide specific config`() { 93 | val parsedBindings = parseConfig( 94 | configDir = tmpFolder.setupStubConfigDir( 95 | text = """ 96 | |bindings { 97 | | q { actionId: CommentByLineComment, description: Comment } 98 | |} 99 | |IU { 100 | | q { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config } 101 | |}""".trimMargin() 102 | ), ideProductKey = "IU" 103 | ) 104 | 105 | // Get the parsed bindings 106 | val bindings = parsedBindings.getOrNull()!! 107 | 108 | assertThat( 109 | bindings, Matchers.equalTo( 110 | listOf( 111 | LeaderBinding.SingleBinding( 112 | KeyStroke.getKeyStroke(KeyEvent.VK_Q, 0, true), // Use key release for leaf binding 113 | "q", 114 | "Open ~/.atamanrc.config", 115 | "OpenAtamanConfigAction", 116 | ) 117 | ) 118 | ) 119 | ) 120 | 121 | // Verify that single bindings use key release 122 | assertThat("Leaf binding should use key release event", (bindings[0] as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true)) 123 | } 124 | 125 | @Test 126 | fun `supports f keys and does not mistake them with capitalized F`() { 127 | val parsedBindings = parseConfig( 128 | configDir = tmpFolder.setupStubConfigDir( 129 | text = """ 130 | |bindings { 131 | | F { actionId: Unsplit, description: Unsplit } 132 | | F1 { actionId: CommentByLineComment, description: Comment } 133 | | F12 { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config } 134 | |}""".trimMargin() 135 | ), 136 | ideProductKey = "IC" 137 | ) 138 | 139 | // Get the parsed bindings 140 | val bindings = parsedBindings.getOrNull()!! 141 | 142 | assertThat( 143 | bindings, Matchers.equalTo( 144 | listOf( 145 | LeaderBinding.SingleBinding( 146 | KeyStroke.getKeyStroke(KeyEvent.VK_F, KeyEvent.SHIFT_DOWN_MASK, true), // Use key release 147 | "F", 148 | "Unsplit", 149 | "Unsplit", 150 | ), 151 | LeaderBinding.SingleBinding( 152 | KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true), // Use key release 153 | "F1", 154 | "Comment", 155 | "CommentByLineComment", 156 | ), 157 | LeaderBinding.SingleBinding( 158 | KeyStroke.getKeyStroke(KeyEvent.VK_F12, 0, true), // Use key release 159 | "F12", 160 | "Open ~/.atamanrc.config", 161 | "OpenAtamanConfigAction", 162 | ) 163 | ) 164 | ) 165 | ) 166 | 167 | // Verify that all bindings use key release 168 | bindings.forEach { binding -> 169 | assertThat("Leaf binding should use key release event", 170 | (binding as LeaderBinding.SingleBinding).key.isOnKeyRelease, 171 | Matchers.equalTo(true)) 172 | } 173 | } 174 | 175 | @Test 176 | fun `returns error on malformed config`() { 177 | val parsedBindings = parseConfig( 178 | configDir = tmpFolder.setupStubConfigDir(text = "}malformed{"), 179 | ideProductKey = "IC" 180 | ) 181 | assertThat(parsedBindings.isFailure, Matchers.equalTo(true)) 182 | } 183 | 184 | @Test 185 | fun `throws if bindings are not set up properly`() { 186 | val parsedBindings = parseConfig( 187 | configDir = tmpFolder.setupStubConfigDir(text = "bindings { q { description: Session } }"), 188 | ideProductKey = "IC" 189 | ) 190 | assertThat(parsedBindings.isFailure, Matchers.equalTo(true)) 191 | println(parsedBindings.exceptionOrNull()) 192 | } 193 | 194 | @Test 195 | fun `parses config to the list of bindings`() { 196 | val parsedBindings = parseConfig( 197 | configDir = tmpFolder.setupStubConfigDir( 198 | text = """ 199 | |bindings { 200 | | q { 201 | | description: Session... 202 | | bindings { 203 | | F { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config } 204 | | } 205 | | } 206 | |}""".trimMargin() 207 | ), 208 | ideProductKey = "IC" 209 | ) 210 | parsedBindings.exceptionOrNull()?.printStackTrace() 211 | assertThat(parsedBindings.exceptionOrNull(), Matchers.nullValue()) 212 | 213 | // Get the parsed bindings 214 | val bindings = parsedBindings.getOrNull()!! 215 | 216 | // Check the structure of the bindings 217 | assertThat( 218 | bindings, Matchers.equalTo( 219 | listOf( 220 | LeaderBinding.GroupBinding( 221 | KeyStroke.getKeyStroke(KeyEvent.VK_Q, 0, false), // Group binding should use key press (released=false) 222 | "q", 223 | "Session...", 224 | listOf( 225 | LeaderBinding.SingleBinding( 226 | KeyStroke.getKeyStroke(KeyEvent.VK_F, KeyEvent.SHIFT_DOWN_MASK, true), // Leaf binding should use key release (released=true) 227 | "F", 228 | "Open ~/.atamanrc.config", 229 | "OpenAtamanConfigAction", 230 | ) 231 | ) 232 | ) 233 | ) 234 | ) 235 | ) 236 | 237 | // Verify explicitly that group bindings use key press and leaf bindings use key release 238 | val groupBinding = bindings[0] as LeaderBinding.GroupBinding 239 | assertThat("Group binding should use key press event", groupBinding.key.isOnKeyRelease, Matchers.equalTo(false)) 240 | 241 | val leafBinding = groupBinding.bindings[0] as LeaderBinding.SingleBinding 242 | assertThat("Leaf binding should use key release event", leafBinding.key.isOnKeyRelease, Matchers.equalTo(true)) 243 | } 244 | } 245 | --------------------------------------------------------------------------------