diff --git a/build.gradle.kts b/build.gradle.kts index 44b9bd4..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" @@ -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") @@ -37,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" @@ -81,3 +85,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..008174b --- /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..80e8781 --- /dev/null +++ b/src/main/kotlin/com/github/kiolk/typingplugin/service/TypingService.kt @@ -0,0 +1,43 @@ +package com.github.kiolk.typingplugin.service + +import com.github.kiolk.typingplugin.model.TypingResult +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")], +) +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..f8652cb --- /dev/null +++ b/src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt @@ -0,0 +1,119 @@ +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( + "", + "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..3f39068 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,25 @@ 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 {