Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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")
Expand All @@ -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"
Expand Down Expand Up @@ -81,3 +85,9 @@ ktlint {
tasks.test {
useJUnitPlatform()
}

tasks {
buildSearchableOptions {
enabled = false
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<TypingService.State> {
data class State(
var results: MutableList<TypingResult> = 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<TypingResult> = myState.results.toList()

override fun getState(): State = myState

override fun loadState(state: State) {
myState = state
}

companion object {
fun getInstance(project: Project): TypingService = project.service()
}
}
119 changes: 119 additions & 0 deletions src/main/kotlin/com/github/kiolk/typingplugin/ui/StatisticsDialog.kt
Original file line number Diff line number Diff line change
@@ -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<TypingResult>,
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
}
}
39 changes: 32 additions & 7 deletions src/main/kotlin/com/github/kiolk/typingplugin/ui/TypingDialog.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Int>()
private val log = logger<TypingDialog>()

// Styles
private val ghostAttributes =
Expand All @@ -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"
Expand Down Expand Up @@ -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')
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading