From 249d4149be31929b7bb0d2f2927f10f6f06b29d1 Mon Sep 17 00:00:00 2001 From: Yauheni Slizh Date: Tue, 3 Feb 2026 19:52:49 +0100 Subject: [PATCH 1/3] Added logic for display diagram with progress --- build.gradle.kts | 7 ++ .../kiolk/typingplugin/model/TypingResult.kt | 8 ++ .../typingplugin/service/TypingService.kt | 36 ++++++ .../kiolk/typingplugin/ui/StatisticsDialog.kt | 116 ++++++++++++++++++ .../kiolk/typingplugin/ui/TypingDialog.kt | 35 ++++-- 5 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt create mode 100644 src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt create mode 100644 src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt diff --git a/build.gradle.kts b/build.gradle.kts index 44b9bd4..4295771 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { testFramework(TestFrameworkType.Platform) zipSigner() } + implementation("org.jfree:jfreechart:1.5.6") 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") @@ -81,3 +82,9 @@ ktlint { tasks.test { useJUnitPlatform() } + +tasks { + buildSearchableOptions { + enabled = false + } +} diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt b/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt new file mode 100644 index 0000000..55179a1 --- /dev/null +++ b/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt @@ -0,0 +1,8 @@ +package com.github.kiolk.typingplugin.model + +data class TypingResult( + val attemptNumber: Int, + val wpm: Double, + val errorsPerMinute: Double, + val accuracy: Double +) diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt b/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt new file mode 100644 index 0000000..a3b3d11 --- /dev/null +++ b/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt @@ -0,0 +1,36 @@ +package com.github.kiolk.typingplugin.service + +import com.github.kiolk.typingplugin.model.TypingResult +import com.intellij.openapi.components.* +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +@State( + name = "TypingStatistics", + storages = [Storage("typingStatistics.xml")] +) +class TypingService(private val project: Project) : PersistentStateComponent { + + data class State( + var results: MutableList = mutableListOf() + ) + + private var myState = State() + + fun addResult(wpm: Double, errorsPerMinute: Double, accuracy: Double) { + val attemptNumber = myState.results.size + 1 + myState.results.add(TypingResult(attemptNumber, wpm, errorsPerMinute, accuracy)) + } + + fun getResults(): List = myState.results.toList() + + override fun getState(): State = myState + + override fun loadState(state: State) { + myState = state + } + + companion object { + fun getInstance(project: Project): TypingService = project.service() + } +} diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt b/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt new file mode 100644 index 0000000..d4a8c7a --- /dev/null +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt @@ -0,0 +1,116 @@ +package com.github.kiolk.typingplugin.ui + +import com.github.kiolk.typingplugin.model.TypingResult +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import org.jfree.chart.ChartFactory +import org.jfree.chart.ChartPanel +import org.jfree.chart.axis.NumberAxis +import org.jfree.chart.plot.XYPlot +import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer +import org.jfree.data.xy.XYSeries +import org.jfree.data.xy.XYSeriesCollection +import java.awt.BasicStroke +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Font +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants + +class StatisticsDialog( + project: Project, + private val results: List, + private val currentResult: String +) : DialogWrapper(project) { + + init { + title = "Session Summary & Performance" + init() + } + + override fun createCenterPanel(): JComponent { + val mainPanel = JPanel(BorderLayout()) + + // Top panel for text statistics + val statsPanel = JPanel() + statsPanel.layout = BoxLayout(statsPanel, BoxLayout.Y_AXIS) + statsPanel.border = JBUI.Borders.empty(10) + + val titleLabel = JBLabel("Typing Finished!", SwingConstants.CENTER).apply { + font = font.deriveFont(Font.BOLD, 16f) + alignmentX = JComponent.CENTER_ALIGNMENT + } + statsPanel.add(titleLabel) + statsPanel.add(JBUI.Panels.simplePanel(5, 5)) // Spacer + + currentResult.split("\n").forEach { line -> + if (line.isNotBlank() && !line.contains("Finished")) { + val label = JBLabel(line, SwingConstants.CENTER).apply { + alignmentX = JComponent.CENTER_ALIGNMENT + } + statsPanel.add(label) + } + } + + mainPanel.add(statsPanel, BorderLayout.NORTH) + + // Chart setup + val wpmSeries = XYSeries("Words Per Minute") + val errorsSeries = XYSeries("Errors Per Minute") + val accuracySeries = XYSeries("Accuracy") + + results.forEach { result -> + wpmSeries.add(result.attemptNumber, result.wpm) + errorsSeries.add(result.attemptNumber, result.errorsPerMinute) + accuracySeries.add(result.attemptNumber, result.accuracy) + } + + val dataset = XYSeriesCollection().apply { + addSeries(wpmSeries) + addSeries(errorsSeries) + } + + val chart = ChartFactory.createXYLineChart( + "", // Title inside chart removed as it's in the dialog + "Attempt", + "WPM / Errors", + dataset + ) + + val plot = chart.plot as XYPlot + val domainAxis = plot.domainAxis as NumberAxis + domainAxis.standardTickUnits = NumberAxis.createIntegerTickUnits() + + val renderer = XYLineAndShapeRenderer() + renderer.setSeriesPaint(0, Color(52, 152, 219)) // Blue + renderer.setSeriesStroke(0, BasicStroke(2.0f)) + renderer.setSeriesPaint(1, Color(231, 76, 60)) // Red + renderer.setSeriesStroke(1, BasicStroke(2.0f)) + + plot.renderer = renderer + plot.backgroundPaint = Color.WHITE + plot.rangeGridlinePaint = Color.LIGHT_GRAY + plot.domainGridlinePaint = Color.LIGHT_GRAY + + val accuracyDataset = XYSeriesCollection(accuracySeries) + val axis2 = NumberAxis("Accuracy (%)") + plot.setRangeAxis(1, axis2) + plot.setDataset(1, accuracyDataset) + plot.mapDatasetToRangeAxis(1, 1) + + val renderer2 = XYLineAndShapeRenderer() + renderer2.setSeriesPaint(0, Color(46, 204, 113)) // Green + renderer2.setSeriesStroke(0, BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1.0f, floatArrayOf(5.0f), 0.0f)) + plot.setRenderer(1, renderer2) + + val chartPanel = ChartPanel(chart) + chartPanel.preferredSize = java.awt.Dimension(800, 400) + mainPanel.add(chartPanel, BorderLayout.CENTER) + + return mainPanel + } +} 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 b97fd93..418a645 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt @@ -1,10 +1,11 @@ package com.github.kiolk.typingplugin.ui +import com.github.kiolk.typingplugin.service.TypingService +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.Messages import com.intellij.ui.components.JBScrollPane import java.awt.BorderLayout import java.awt.Color @@ -26,6 +27,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) private var startTime: Long = 0 private val textPane = JTextPane() private val skippedIndices = mutableSetOf() + private val LOG = logger() // Styles private val ghostAttributes = @@ -51,6 +53,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) title = "Typing Training" isModal = false init() + LOG.info("TypingDialog initialized with source code length: ${sourceCode.length}") } override fun getDimensionServiceKey(): String? = "com.github.kiolk.typingplugin.ui.TypingDialog" @@ -98,18 +101,26 @@ class TypingDialog(private val project: Project, private val sourceCode: String) addKeyListener( object : KeyAdapter() { override fun keyTyped(e: KeyEvent) { + LOG.info("Key typed: '${e.keyChar}' (code: ${e.keyChar.code})") if (e.keyChar.code < 32 || e.keyChar.code == 127) return - if (startTime == 0L) startTime = System.currentTimeMillis() + if (startTime == 0L) { + startTime = System.currentTimeMillis() + LOG.info("Session started at $startTime") + } handleTyping(e.keyChar) } override fun keyPressed(e: KeyEvent) { + LOG.debug("Key pressed: code=${e.keyCode}") when (e.keyCode) { KeyEvent.VK_BACK_SPACE, KeyEvent.VK_DELETE, KeyEvent.VK_CLEAR -> { handleBackspace() } KeyEvent.VK_ENTER -> { - if (startTime == 0L) startTime = System.currentTimeMillis() + if (startTime == 0L) { + startTime = System.currentTimeMillis() + LOG.info("Session started at $startTime (via Enter)") + } handleTyping('\n') } } @@ -154,13 +165,15 @@ class TypingDialog(private val project: Project, private val sourceCode: String) updateCursor() } else { errorCount++ + LOG.debug("Typing error at index $currentIndex: expected '$targetChar', got '$charTyped'. Total errors: $errorCount") val errorStyle = SimpleAttributeSet(wrongAttributes) StyleConstants.setBackground(errorStyle, Color.LIGHT_GRAY) textPane.styledDocument.setCharacterAttributes(currentIndex, 1, errorStyle, true) } if (currentIndex >= sourceCode.length) { - showStatistics() + LOG.info("Typing finished. Recording statistics.") + recordAndShowStatistics() close(OK_EXIT_CODE) } } @@ -221,7 +234,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) } } - private fun showStatistics() { + private fun recordAndShowStatistics() { val endTime = System.currentTimeMillis() val totalTimeSeconds = if (startTime != 0L) (endTime - startTime) / 1000.0 else 0.0 val totalTimeMinutes = totalTimeSeconds / 60.0 @@ -233,13 +246,21 @@ class TypingDialog(private val project: Project, private val sourceCode: String) } else { 0.0 } + val epm = if (totalTimeMinutes > 0) errorCount / totalTimeMinutes else 0.0 - val timeFormatted = formatTime(totalTimeSeconds) + val service = TypingService.getInstance(project) + service.addResult(wpm, epm, accuracy) + + val results = service.getResults() + val latest = results.last() + LOG.info("Session Result: Attempt #${latest.attemptNumber}, WPM: ${"%.1f".format(latest.wpm)}, EPM: ${"%.1f".format(latest.errorsPerMinute)}, Accuracy: ${"%.1f".format(latest.accuracy)}%") + val timeFormatted = formatTime(totalTimeSeconds) val statsMessage = "Typing Finished!\n\nTime: $timeFormatted\nWPM: ${"%.1f".format( wpm, )}\nAccuracy: ${"%.1f".format(accuracy)}%\nErrors: $errorCount" - Messages.showInfoMessage(project, statsMessage, "Session Summary") + + StatisticsDialog(project, results, statsMessage).show() } companion object { From 19c220c1f3317d4add4696ed3c4bdf5b48fbf5cd Mon Sep 17 00:00:00 2001 From: Yauheni Slizh Date: Tue, 3 Feb 2026 19:55:48 +0100 Subject: [PATCH 2/3] Bump version 1.0.6 --- build.gradle.kts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4295771..370611c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { id("org.jlleitschuh.gradle.ktlint") version "12.1.2" } -version = "1.0.5" +version = "1.0.6" group = "com.github.kiolk.typingplugin" @@ -38,8 +38,11 @@ intellijPlatform { name = "Typing Training" changeNotes = """ - - Fixed incorrect representation of the time on the final statistic dialog. - - Improved stability. + - Added performance chart at the end of each session. + - Implemented persistent statistics across IDE sessions. + - Unified session summary and performance chart into a single dialog. + - Added detailed logging for typing events and errors. + - Improved chart visualization with whole number attempt axis. """.trimIndent() ideaVersion { sinceBuild = "241" From 576c9ca798e907d7e80588d2117e599c1048ebf7 Mon Sep 17 00:00:00 2001 From: Yauheni Slizh Date: Tue, 3 Feb 2026 20:54:33 +0100 Subject: [PATCH 3/3] Fix lint issues --- .../kiolk/typingplugin/model/TypingResult.kt | 2 +- .../typingplugin/service/TypingService.kt | 19 +++++--- .../kiolk/typingplugin/ui/StatisticsDialog.kt | 43 ++++++++++--------- .../kiolk/typingplugin/ui/TypingDialog.kt | 24 ++++++----- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt b/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt index 55179a1..008174b 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/model/TypingResult.kt @@ -4,5 +4,5 @@ data class TypingResult( val attemptNumber: Int, val wpm: Double, val errorsPerMinute: Double, - val accuracy: Double + val accuracy: Double, ) diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt b/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt index a3b3d11..80e8781 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt @@ -1,23 +1,30 @@ package com.github.kiolk.typingplugin.service import com.github.kiolk.typingplugin.model.TypingResult -import com.intellij.openapi.components.* +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project @Service(Service.Level.PROJECT) @State( name = "TypingStatistics", - storages = [Storage("typingStatistics.xml")] + storages = [Storage("typingStatistics.xml")], ) -class TypingService(private val project: Project) : PersistentStateComponent { - +class TypingService(private val project: Project) : + PersistentStateComponent { data class State( - var results: MutableList = mutableListOf() + var results: MutableList = mutableListOf(), ) private var myState = State() - fun addResult(wpm: Double, errorsPerMinute: Double, accuracy: Double) { + fun addResult( + wpm: Double, + errorsPerMinute: Double, + accuracy: Double, + ) { val attemptNumber = myState.results.size + 1 myState.results.add(TypingResult(attemptNumber, wpm, errorsPerMinute, accuracy)) } diff --git a/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt b/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt index d4a8c7a..f8652cb 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt @@ -24,9 +24,8 @@ import javax.swing.SwingConstants class StatisticsDialog( project: Project, private val results: List, - private val currentResult: String + private val currentResult: String, ) : DialogWrapper(project) { - init { title = "Session Summary & Performance" init() @@ -40,22 +39,24 @@ class StatisticsDialog( statsPanel.layout = BoxLayout(statsPanel, BoxLayout.Y_AXIS) statsPanel.border = JBUI.Borders.empty(10) - val titleLabel = JBLabel("Typing Finished!", SwingConstants.CENTER).apply { - font = font.deriveFont(Font.BOLD, 16f) - alignmentX = JComponent.CENTER_ALIGNMENT - } + val titleLabel = + JBLabel("Typing Finished!", SwingConstants.CENTER).apply { + font = font.deriveFont(Font.BOLD, 16f) + alignmentX = JComponent.CENTER_ALIGNMENT + } statsPanel.add(titleLabel) statsPanel.add(JBUI.Panels.simplePanel(5, 5)) // Spacer currentResult.split("\n").forEach { line -> if (line.isNotBlank() && !line.contains("Finished")) { - val label = JBLabel(line, SwingConstants.CENTER).apply { - alignmentX = JComponent.CENTER_ALIGNMENT - } + val label = + JBLabel(line, SwingConstants.CENTER).apply { + alignmentX = JComponent.CENTER_ALIGNMENT + } statsPanel.add(label) } } - + mainPanel.add(statsPanel, BorderLayout.NORTH) // Chart setup @@ -69,17 +70,19 @@ class StatisticsDialog( accuracySeries.add(result.attemptNumber, result.accuracy) } - val dataset = XYSeriesCollection().apply { - addSeries(wpmSeries) - addSeries(errorsSeries) - } + val dataset = + XYSeriesCollection().apply { + addSeries(wpmSeries) + addSeries(errorsSeries) + } - val chart = ChartFactory.createXYLineChart( - "", // Title inside chart removed as it's in the dialog - "Attempt", - "WPM / Errors", - dataset - ) + val chart = + ChartFactory.createXYLineChart( + "", + "Attempt", + "WPM / Errors", + dataset, + ) val plot = chart.plot as XYPlot val domainAxis = plot.domainAxis as NumberAxis 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 418a645..3f39068 100644 --- a/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt @@ -27,7 +27,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) private var startTime: Long = 0 private val textPane = JTextPane() private val skippedIndices = mutableSetOf() - private val LOG = logger() + private val log = logger() // Styles private val ghostAttributes = @@ -53,7 +53,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) title = "Typing Training" isModal = false init() - LOG.info("TypingDialog initialized with source code length: ${sourceCode.length}") + log.info("TypingDialog initialized with source code length: ${sourceCode.length}") } override fun getDimensionServiceKey(): String? = "com.github.kiolk.typingplugin.ui.TypingDialog" @@ -101,17 +101,17 @@ class TypingDialog(private val project: Project, private val sourceCode: String) addKeyListener( object : KeyAdapter() { override fun keyTyped(e: KeyEvent) { - LOG.info("Key typed: '${e.keyChar}' (code: ${e.keyChar.code})") + log.info("Key typed: '${e.keyChar}' (code: ${e.keyChar.code})") if (e.keyChar.code < 32 || e.keyChar.code == 127) return if (startTime == 0L) { startTime = System.currentTimeMillis() - LOG.info("Session started at $startTime") + log.info("Session started at $startTime") } handleTyping(e.keyChar) } override fun keyPressed(e: KeyEvent) { - LOG.debug("Key pressed: code=${e.keyCode}") + log.debug("Key pressed: code=${e.keyCode}") when (e.keyCode) { KeyEvent.VK_BACK_SPACE, KeyEvent.VK_DELETE, KeyEvent.VK_CLEAR -> { handleBackspace() @@ -119,7 +119,7 @@ class TypingDialog(private val project: Project, private val sourceCode: String) KeyEvent.VK_ENTER -> { if (startTime == 0L) { startTime = System.currentTimeMillis() - LOG.info("Session started at $startTime (via Enter)") + log.info("Session started at $startTime (via Enter)") } handleTyping('\n') } @@ -165,14 +165,14 @@ class TypingDialog(private val project: Project, private val sourceCode: String) updateCursor() } else { errorCount++ - LOG.debug("Typing error at index $currentIndex: expected '$targetChar', got '$charTyped'. Total errors: $errorCount") + log.debug("Typing error at index $currentIndex: expected '$targetChar', got '$charTyped'. Total errors: $errorCount") val errorStyle = SimpleAttributeSet(wrongAttributes) StyleConstants.setBackground(errorStyle, Color.LIGHT_GRAY) textPane.styledDocument.setCharacterAttributes(currentIndex, 1, errorStyle, true) } if (currentIndex >= sourceCode.length) { - LOG.info("Typing finished. Recording statistics.") + log.info("Typing finished. Recording statistics.") recordAndShowStatistics() close(OK_EXIT_CODE) } @@ -250,10 +250,14 @@ class TypingDialog(private val project: Project, private val sourceCode: String) val service = TypingService.getInstance(project) service.addResult(wpm, epm, accuracy) - + val results = service.getResults() val latest = results.last() - LOG.info("Session Result: Attempt #${latest.attemptNumber}, WPM: ${"%.1f".format(latest.wpm)}, EPM: ${"%.1f".format(latest.errorsPerMinute)}, Accuracy: ${"%.1f".format(latest.accuracy)}%") + log.info( + "Session Result: Attempt #${latest.attemptNumber}, WPM: ${"%.1f".format( + latest.wpm, + )}, EPM: ${"%.1f".format(latest.errorsPerMinute)}, Accuracy: ${"%.1f".format(latest.accuracy)}%", + ) val timeFormatted = formatTime(totalTimeSeconds) val statsMessage = "Typing Finished!\n\nTime: $timeFormatted\nWPM: ${"%.1f".format(