diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..d90a41b --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,28 @@ +name: PR Check + +on: + pull_request: + branches: [ main, master ] + +jobs: + check: + name: ktlint and unit tests + runs-on: ubuntu-latest + steps: + - name: Fetch Sources + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Ktlint Check + run: ./gradlew ktlintCheck + + - name: Run Unit Tests + run: ./gradlew test diff --git a/build.gradle.kts b/build.gradle.kts index 929c850..a858d4d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,13 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + plugins { id("java") id("org.jetbrains.kotlin.jvm") version "1.9.22" id("org.jetbrains.intellij.platform") version "2.1.0" + id("org.jlleitschuh.gradle.ktlint") version "12.1.2" } -version = "1.0.3" +version = "1.0.4" group = "com.github.kiolk.typingplugin" @@ -20,30 +23,45 @@ dependencies { intellijIdeaCommunity("2024.3") bundledPlugins("com.intellij.java") instrumentationTools() + testFramework(TestFrameworkType.Platform) zipSigner() } + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.10.2") + testImplementation("junit:junit:4.13.2") } intellijPlatform { pluginConfiguration { name = "Typing Training" + description = + """ + Improved stability and usability. + - Added ability to drag typing dialog to any position on the screen. + - Improved stability. + """.trimIndent() + changeNotes = + """ + - Added ability to drag typing dialog to any position on the screen. + - Improved stability. + """.trimIndent() ideaVersion { sinceBuild = "241" untilBuild = "253.*" - // untilBuild is omitted to allow compatibility with all future versions - // and avoid "made-up" build number rejections. } } signing { - // Robust handling for both literal newlines and the "\n" string - val cert = providers.environmentVariable("CERTIFICATE_CHAIN") - .orElse(providers.gradleProperty("certificateChain")) - .map { it.replace("\\n", "\n") } - - val key = providers.environmentVariable("PRIVATE_KEY") - .orElse(providers.gradleProperty("privateKey")) - .map { it.replace("\\n", "\n") } + val cert = + providers.environmentVariable("CERTIFICATE_CHAIN") + .orElse(providers.gradleProperty("certificateChain")) + .map { it.replace("\\n", "\n") } + + val key = + providers.environmentVariable("PRIVATE_KEY") + .orElse(providers.gradleProperty("privateKey")) + .map { it.replace("\\n", "\n") } certificateChain.set(cert) privateKey.set(key) @@ -59,3 +77,13 @@ intellijPlatform { kotlin { jvmToolchain(21) } + +ktlint { + verbose.set(true) + outputToConsole.set(true) + coloredOutput.set(true) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/actions/StartTypingAction.kt b/src/main/kotlin/com/github/kiolk/typingplugin/actions/StartTypingAction.kt index 33dbfb1..168ac5d 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/actions/StartTypingAction.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/actions/StartTypingAction.kt @@ -1,35 +1,48 @@ package com.github.kiolk.typingplugin.actions +import com.github.kiolk.typingplugin.ui.TypingDialog import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys -import com.github.kiolk.typingplugin.ui.TypingDialog +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project class StartTypingAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return val editor = e.getData(CommonDataKeys.EDITOR) ?: return - - // Check for selection - val selectionModel = editor.selectionModel - val textToType = if (selectionModel.hasSelection()) { - selectionModel.selectedText ?: editor.document.text - } else { - editor.document.text - } - - // Clean up the selected text: - // If we select a middle block, we might want to trim initial common indentation - val cleanedText = textToType.trimIndent() - - val typingDialog = TypingDialog(project, cleanedText) + + val cleanedText = getCleanedText(editor) + + val typingDialog = createTypingDialog(project, cleanedText) typingDialog.show() } + fun getCleanedText(editor: Editor): String { + val selectionModel = editor.selectionModel + val textToType = + if (selectionModel.hasSelection()) { + selectionModel.selectedText ?: editor.document.text + } else { + editor.document.text + } + + // Clean up the selected text: + // If we select a middle block, we might want to trim initial common indentation + return textToType.trimIndent() + } + + fun createTypingDialog( + project: Project, + text: String, + ): TypingDialog { + return TypingDialog(project, text) + } + override fun update(e: AnActionEvent) { val editor = e.getData(CommonDataKeys.EDITOR) e.presentation.isEnabledAndVisible = editor != null - + // Optional: Update text based on selection if (editor?.selectionModel?.hasSelection() == true) { e.presentation.text = "Type Selected Area" diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt b/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt index c59b0d4..5256044 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt @@ -8,16 +8,19 @@ import com.intellij.openapi.ui.Messages import com.intellij.ui.components.JBScrollPane import java.awt.BorderLayout import java.awt.Color +import java.awt.Point import java.awt.event.KeyAdapter import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JTextPane +import javax.swing.SwingUtilities import javax.swing.text.SimpleAttributeSet import javax.swing.text.StyleConstants class TypingDialog(private val project: Project, private val sourceCode: String) : DialogWrapper(project) { - private var currentIndex = 0 private var errorCount = 0 private var startTime: Long = 0 @@ -25,35 +28,65 @@ class TypingDialog(private val project: Project, private val sourceCode: String) private val skippedIndices = mutableSetOf() // Styles - private val ghostAttributes = SimpleAttributeSet().apply { - StyleConstants.setForeground(this, Color.GRAY) - } - private val correctAttributes = SimpleAttributeSet().apply { - StyleConstants.setForeground(this, Color.GREEN) - } - private val wrongAttributes = SimpleAttributeSet().apply { - StyleConstants.setForeground(this, Color.RED) - StyleConstants.setUnderline(this, true) - } - private val cursorAttributes = SimpleAttributeSet().apply { - StyleConstants.setBackground(this, Color.LIGHT_GRAY) - StyleConstants.setForeground(this, Color.BLACK) - } + private val ghostAttributes = + SimpleAttributeSet().apply { + StyleConstants.setForeground(this, Color.GRAY) + } + private val correctAttributes = + SimpleAttributeSet().apply { + StyleConstants.setForeground(this, Color.GREEN) + } + private val wrongAttributes = + SimpleAttributeSet().apply { + StyleConstants.setForeground(this, Color.RED) + StyleConstants.setUnderline(this, true) + } + private val cursorAttributes = + SimpleAttributeSet().apply { + StyleConstants.setBackground(this, Color.LIGHT_GRAY) + StyleConstants.setForeground(this, Color.BLACK) + } init { title = "Typing Training" + isModal = false init() } + override fun getDimensionServiceKey(): String? = "com.github.kiolk.typingplugin.ui.TypingDialog" + override fun createCenterPanel(): JComponent { val panel = JPanel(BorderLayout()) - + + val dragListener = + object : MouseAdapter() { + private var initialScreenClick: Point? = null + private var initialWindowLocation: Point? = null + + override fun mousePressed(e: MouseEvent) { + initialScreenClick = e.locationOnScreen + initialWindowLocation = SwingUtilities.getWindowAncestor(panel)?.location + } + + override fun mouseDragged(e: MouseEvent) { + val window = SwingUtilities.getWindowAncestor(panel) + if (window != null && initialScreenClick != null && initialWindowLocation != null) { + val deltaX = e.locationOnScreen.x - initialScreenClick!!.x + val deltaY = e.locationOnScreen.y - initialScreenClick!!.y + window.setLocation(initialWindowLocation!!.x + deltaX, initialWindowLocation!!.y + deltaY) + } + } + } + + panel.addMouseListener(dragListener) + panel.addMouseMotionListener(dragListener) + textPane.apply { text = sourceCode isEditable = false background = EditorColorsManager.getInstance().globalScheme.defaultBackground font = EditorColorsManager.getInstance().globalScheme.getFont(EditorFontType.PLAIN) - + StyleConstants.setBackground(ghostAttributes, background) StyleConstants.setBackground(correctAttributes, background) StyleConstants.setBackground(wrongAttributes, background) @@ -62,30 +95,37 @@ class TypingDialog(private val project: Project, private val sourceCode: String) textPane.styledDocument.setCharacterAttributes(0, sourceCode.length, ghostAttributes, true) updateCursor() - addKeyListener(object : KeyAdapter() { - override fun keyTyped(e: KeyEvent) { - if (e.keyChar.code < 32 || e.keyChar.code == 127) return - if (startTime == 0L) startTime = System.currentTimeMillis() - handleTyping(e.keyChar) - } + addKeyListener( + object : KeyAdapter() { + override fun keyTyped(e: KeyEvent) { + if (e.keyChar.code < 32 || e.keyChar.code == 127) return + if (startTime == 0L) startTime = System.currentTimeMillis() + handleTyping(e.keyChar) + } - override fun keyPressed(e: KeyEvent) { - when (e.keyCode) { - KeyEvent.VK_BACK_SPACE, KeyEvent.VK_DELETE, KeyEvent.VK_CLEAR -> { - handleBackspace() - } - KeyEvent.VK_ENTER -> { - if (startTime == 0L) startTime = System.currentTimeMillis() - handleTyping('\n') + override fun keyPressed(e: KeyEvent) { + when (e.keyCode) { + KeyEvent.VK_BACK_SPACE, KeyEvent.VK_DELETE, KeyEvent.VK_CLEAR -> { + handleBackspace() + } + KeyEvent.VK_ENTER -> { + if (startTime == 0L) startTime = System.currentTimeMillis() + handleTyping('\n') + } } } - } - }) + }, + ) + addMouseListener(dragListener) + addMouseMotionListener(dragListener) } - panel.add(JBScrollPane(textPane), BorderLayout.CENTER) + val scrollPane = JBScrollPane(textPane) + scrollPane.addMouseListener(dragListener) + scrollPane.addMouseMotionListener(dragListener) + panel.add(scrollPane, BorderLayout.CENTER) panel.preferredSize = java.awt.Dimension(800, 600) - + return panel } @@ -98,11 +138,11 @@ class TypingDialog(private val project: Project, private val sourceCode: String) val targetChar = sourceCode[currentIndex] val isNewlineMatch = (targetChar == '\n' || targetChar == '\r') && (charTyped == '\n' || charTyped == '\r') - + if (charTyped == targetChar || isNewlineMatch) { textPane.styledDocument.setCharacterAttributes(currentIndex, 1, correctAttributes, true) - - if (targetChar == '\r' && currentIndex + 1 < sourceCode.length && sourceCode[currentIndex+1] == '\n') { + + if (targetChar == '\r' && currentIndex + 1 < sourceCode.length && sourceCode[currentIndex + 1] == '\n') { currentIndex += 2 } else { currentIndex++ @@ -166,15 +206,16 @@ class TypingDialog(private val project: Project, private val sourceCode: String) if (currentIndex < sourceCode.length) { val attrs = textPane.styledDocument.getCharacterElement(currentIndex).attributes val isWrong = StyleConstants.getForeground(attrs) == Color.RED - - val style = if (isWrong) { - val s = SimpleAttributeSet(wrongAttributes) - StyleConstants.setBackground(s, Color.LIGHT_GRAY) - s - } else { - cursorAttributes - } - + + val style = + if (isWrong) { + val s = SimpleAttributeSet(wrongAttributes) + StyleConstants.setBackground(s, Color.LIGHT_GRAY) + s + } else { + cursorAttributes + } + textPane.styledDocument.setCharacterAttributes(currentIndex, 1, style, true) textPane.caretPosition = currentIndex } @@ -186,11 +227,16 @@ class TypingDialog(private val project: Project, private val sourceCode: String) val totalTimeMinutes = totalTimeSeconds / 60.0 val totalChars = sourceCode.length val wpm = if (totalTimeMinutes > 0) (totalChars / 5.0) / totalTimeMinutes else 0.0 - val accuracy = if (totalChars + errorCount > 0) { - (totalChars.toDouble() / (totalChars + errorCount)) * 100 - } else 0.0 + val accuracy = + if (totalChars + errorCount > 0) { + (totalChars.toDouble() / (totalChars + errorCount)) * 100 + } else { + 0.0 + } - val statsMessage = "Typing Finished!\n\nTime: ${"%.1f".format(totalTimeSeconds)}s\nWPM: ${"%.1f".format(wpm)}\nAccuracy: ${"%.1f".format(accuracy)}%\nErrors: $errorCount" + val statsMessage = "Typing Finished!\n\nTime: ${"%.1f".format( + totalTimeSeconds, + )}s\nWPM: ${"%.1f".format(wpm)}\nAccuracy: ${"%.1f".format(accuracy)}%\nErrors: $errorCount" Messages.showInfoMessage(project, statsMessage, "Session Summary") } } diff --git a/src/test/kotlin/com/github/kiolk/typingplugin/actions/StartTypingActionTest.kt b/src/test/kotlin/com/github/kiolk/typingplugin/actions/StartTypingActionTest.kt new file mode 100644 index 0000000..0844bd1 --- /dev/null +++ b/src/test/kotlin/com/github/kiolk/typingplugin/actions/StartTypingActionTest.kt @@ -0,0 +1,96 @@ +package com.github.kiolk.typingplugin.actions + +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.testFramework.TestActionEvent +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.junit.Test + +class StartTypingActionTest : BasePlatformTestCase() { + @Test + fun testActionIsDisabledWhenNoEditor() { + val action = StartTypingAction() + val event = + TestActionEvent.createTestEvent(action) { dataId -> + if (CommonDataKeys.EDITOR.name == dataId) null else null + } + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + @Test + fun testActionIsEnabledWhenEditorIsPresent() { + myFixture.configureByText("Test.java", "public class Test {}") + val action = StartTypingAction() + val event = + TestActionEvent.createTestEvent(action) { dataId -> + when (dataId) { + CommonDataKeys.EDITOR.name -> myFixture.editor + CommonDataKeys.PROJECT.name -> project + else -> null + } + } + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Type This Class", event.presentation.text) + } + + @Test + fun testActionTextChangesWhenTextIsSelected() { + myFixture.configureByText("Test.java", "public class Test {}") + val action = StartTypingAction() + val event = + TestActionEvent.createTestEvent(action) { dataId -> + when (dataId) { + CommonDataKeys.EDITOR.name -> myFixture.editor + CommonDataKeys.PROJECT.name -> project + else -> null + } + } + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + assertEquals("Type Selected Area", event.presentation.text) + } + + @Test + fun testGetCleanedTextReturnsFullTextWhenNoSelection() { + val content = "public class Test {}" + myFixture.configureByText("Test.java", content) + val action = StartTypingAction() + + val cleanedText = action.getCleanedText(myFixture.editor) + + assertEquals(content, cleanedText) + } + + @Test + fun testGetCleanedTextReturnsTrimmedSelection() { + // We align the selection start with the indentation we want to preserve/trim consistently. + val content = + """ + public class Test { + public void main() { + System.out.println("Hello"); + System.out.println("World"); + } + } + """.trimIndent() + + myFixture.configureByText("Test.java", content) + val action = StartTypingAction() + + val cleanedText = action.getCleanedText(myFixture.editor) + + val expected = + """ + System.out.println("Hello"); + System.out.println("World"); + """.trimIndent() + assertEquals(expected, cleanedText) + } +} diff --git a/src/test/kotlin/com/github/kiolk/typingplugin/ui/TypingDialogTest.kt b/src/test/kotlin/com/github/kiolk/typingplugin/ui/TypingDialogTest.kt new file mode 100644 index 0000000..3ff2f3d --- /dev/null +++ b/src/test/kotlin/com/github/kiolk/typingplugin/ui/TypingDialogTest.kt @@ -0,0 +1,90 @@ +package com.github.kiolk.typingplugin.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.junit.Test +import java.awt.Color +import java.awt.event.KeyEvent +import javax.swing.JTextPane +import javax.swing.text.StyleConstants + +class TypingDialogTest : BasePlatformTestCase() { + private lateinit var dialog: TypingDialog + private lateinit var textPane: JTextPane + private val sourceCode = "public class Test {}" + + override fun setUp() { + super.setUp() + // TypingDialog needs to be initialized on EDT potentially, + // but for unit testing logic we might get away with it or use invokeAndWait. + dialog = TypingDialog(project, sourceCode) + textPane = dialog.getPreferredFocusedComponent() as JTextPane + } + + @Test + fun testInitialState() { + assertEquals(sourceCode, textPane.text) + val attrs = textPane.styledDocument.getCharacterElement(0).attributes + // Cursor should be at 0 (background LIGHT_GRAY) + assertEquals(Color.LIGHT_GRAY, StyleConstants.getBackground(attrs)) + } + + @Test + fun testTypingCorrectCharacter() { + simulateType('p') + + // Character 'p' (index 0) should now be GREEN + val attrs0 = textPane.styledDocument.getCharacterElement(0).attributes + assertEquals(Color.GREEN, StyleConstants.getForeground(attrs0)) + + // Cursor should move to index 1 + val attrs1 = textPane.styledDocument.getCharacterElement(1).attributes + assertEquals(Color.LIGHT_GRAY, StyleConstants.getBackground(attrs1)) + } + + @Test + fun testTypingWrongCharacter() { + simulateType('x') + + // Character at index 0 should be RED + val attrs = textPane.styledDocument.getCharacterElement(0).attributes + assertEquals(Color.RED, StyleConstants.getForeground(attrs)) + assertTrue(StyleConstants.isUnderline(attrs)) + } + + @Test + fun testSkipLeadingWhitespaceAfterNewline() { + val codeWithNewline = "a\n b" + val dialog = TypingDialog(project, codeWithNewline) + val pane = dialog.getPreferredFocusedComponent() as JTextPane + + // Type 'a' + val keyEventA = KeyEvent(pane, KeyEvent.KEY_TYPED, System.currentTimeMillis(), 0, KeyEvent.VK_UNDEFINED, 'a') + pane.keyListeners.forEach { it.keyTyped(keyEventA) } + + // Type Enter + val keyEventEnter = KeyEvent(pane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, KeyEvent.VK_ENTER, KeyEvent.CHAR_UNDEFINED) + pane.keyListeners.forEach { it.keyPressed(keyEventEnter) } + + // After Enter, it should skip 4 spaces and land on 'b' + // Index 0: 'a' (Correct) + // Index 1: '\n' (Correct) + // Index 2,3,4,5: ' ' (Skipped - Correct) + // Index 6: 'b' (Cursor) + + val attrsB = pane.styledDocument.getCharacterElement(6).attributes + assertEquals(Color.LIGHT_GRAY, StyleConstants.getBackground(attrsB)) + + val attrsSpace = pane.styledDocument.getCharacterElement(2).attributes + assertEquals(Color.GREEN, StyleConstants.getForeground(attrsSpace)) + } + + private fun simulateType(char: Char) { + val keyEvent = KeyEvent(textPane, KeyEvent.KEY_TYPED, System.currentTimeMillis(), 0, KeyEvent.VK_UNDEFINED, char) + textPane.keyListeners.forEach { it.keyTyped(keyEvent) } + } + + private fun simulateKeyPress(keyCode: Int) { + val keyEvent = KeyEvent(textPane, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, KeyEvent.CHAR_UNDEFINED) + textPane.keyListeners.forEach { it.keyPressed(keyEvent) } + } +}