diff --git a/.gitignore b/.gitignore
index 8dc361f..fd49d82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,15 @@ local.properties
.env.*
!.env.example
+# Local AI assistant files
+.codex/
+.claude/
+.cursor/
+.windsurf/
+.continue/
+.aider*
+.ai/
+
secrets/
.codexp-signing/
*.pem
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5b9fdec..9095587 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,6 +29,40 @@ Before opening a pull request:
- Update `CHANGELOG.md` when the change affects users, compatibility, build, or release behavior.
- Do not include private certificates, Marketplace tokens, or local signing files.
+## Kotlin Documentation
+
+Use KDoc for Kotlin documentation comments. KDoc comments use `/** ... */` and should be attached directly to declarations such as classes, interfaces, objects, functions, properties, and enum entries.
+
+Write KDoc when it explains a public or internal contract, a non-obvious domain rule, lifecycle behavior, persistence compatibility, threading expectations, or an extension point. Avoid comments that only repeat the declaration name or restate obvious implementation details.
+
+Recommended format:
+
+```kotlin
+/**
+ * Records an IDE event and updates user progress.
+ *
+ * The returned result describes every UI notification that should be emitted
+ * after the state mutation is complete.
+ *
+ * @param state Persistent CodeXP state to mutate.
+ * @param event IDE event to record.
+ * @return Progress changes produced by the event.
+ */
+fun recordEvent(state: CodeXPState, event: Event): CodeXPProgressResult
+```
+
+KDoc guidelines:
+
+- Start with a short summary sentence.
+- Add a blank line before longer details.
+- Use `@param` for parameters whose meaning is not obvious from the name.
+- Use `@return` when the returned value carries meaningful behavior or state.
+- Use `@property` for primary-constructor properties when documenting data classes.
+- Use KDoc links like `[CodeXPState]`, `[CodeXPService]`, or `[Event.TYPING]` when referencing code symbols.
+- Prefer documenting why a rule exists, what callers can rely on, and what must remain compatible.
+- Keep implementation comments rare; when a block needs explanation, prefer extracting a named function and documenting that function with KDoc.
+- Do not use KDoc to preserve stale history. Update or remove comments when behavior changes.
+
## Branch Workflow
- Use `develop` as the integration branch for normal work.
diff --git a/README.md b/README.md
index deb0d95..83ca466 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ The CodeXP plugin is designed to gamify your coding experience in IntelliJ IDEs.
- Other actions
- Provides periodic challenges to keep the coding experience engaging.
- Displays your level based on the accumulated xp, providing a fun and engaging way to track your coding activities.
+
+Starting with version 2.1.0, CodeXP is being improved with AI-assisted development. This means the project will gradually refine its internal architecture and prepare for future features such as richer effects, configurable XP rules, cross-IDE progress continuity, and account-based synchronization.
diff --git a/docs/release-process.md b/docs/release-process.md
new file mode 100644
index 0000000..a4cc88c
--- /dev/null
+++ b/docs/release-process.md
@@ -0,0 +1,169 @@
+# Release Process
+
+CodeXP uses `develop` for day-to-day work and `main` for stable, release-ready code.
+
+## Branch Model
+
+- `main` is the stable branch that represents published or release-ready code.
+- `develop` is the integration branch for normal work.
+- Feature, fix, documentation, maintenance, and dependency branches should target `develop`.
+- `main` should receive changes through pull requests from `develop` when those changes are ready to be part of the stable branch.
+- Hotfixes may branch from `main`, merge back into `main`, and then be reconciled with `develop`.
+
+## Branch Naming
+
+Use short, descriptive branch names with one of these prefixes:
+
+- `feature/` for user-facing features.
+- `fix/` for bug fixes.
+- `docs/` for documentation-only changes.
+- `chore/` for maintenance, build, dependency, or repository hygiene changes.
+- `release/` for release preparation branches.
+- `hotfix/` for urgent fixes based on `main`.
+
+Prefer lowercase kebab-case after the prefix:
+
+```text
+feature/custom-xp-rules
+fix/idea-compatibility
+docs/release-process
+chore/update-github-actions
+release/2.2.0
+hotfix/2.1.1
+```
+
+## Pull Request Flow
+
+Normal development:
+
+```text
+feature/* -> develop
+fix/* -> develop
+docs/* -> develop
+chore/* -> develop
+dependabot/* -> develop
+```
+
+Stable branch sync:
+
+```text
+develop -> main
+```
+
+Use `develop -> main` for release preparation or stable maintenance syncs. A stable maintenance sync should not imply Marketplace publishing.
+
+## Pull Request Format
+
+Use concise titles that describe the purpose of the change:
+
+```text
+feat: Add custom XP rules
+fix: Restore compatibility with newer IDE builds
+docs: Update release process
+chore: Update GitHub Actions dependencies
+Release CodeXP v2.2.0
+```
+
+For normal work, keep the body short:
+
+```md
+Briefly explain why this change is needed.
+
+## Changes
+- Add the main change.
+- Note compatibility or maintenance impact when relevant.
+
+Please check the build workflows pass before merging.
+```
+
+For `develop -> main` release PRs, use the existing release style:
+
+```md
+## Description
+This PR merges the `develop` branch into `main` for the release of version X.Y.Z
+
+
+
+## Features
+- User-facing feature or compatibility change.
+
+
+
+## Fixed
+- User-facing bug fix.
+
+
+
+Please check the build workflows pass before merging.
+```
+
+For stable maintenance syncs that are not plugin releases, state that clearly in the PR body.
+
+## CI Expectations
+
+The required CI job is `Build, test, and verify`.
+
+It can take several minutes because it builds the plugin and runs IntelliJ Plugin Verifier across multiple IDE versions.
+
+When waiting on a pull request:
+
+- Wait for `Build, test, and verify` to complete before merging.
+- If a branch is out of date with `main`, update the branch and wait for CI again.
+- Cancelled runs can be normal when newer pushes supersede older runs on the same branch.
+- A stable maintenance sync to `main` should not create a release draft or publish the plugin.
+
+## Release Flow
+
+Normal merges do not publish the plugin.
+
+1. Merge release-ready changes into `main`.
+2. Run and verify CI on `main`.
+3. A maintainer prepares the release notes and GitHub Release.
+4. Publishing is performed only by a maintainer.
+5. Confirm the version is listed on JetBrains Marketplace.
+
+Release draft creation must be an explicit maintainer action.
+
+## Version And Compatibility Checks
+
+Before publishing a release, verify:
+
+- `pluginVersion` has the intended version.
+- `pluginSinceBuild` and `pluginUntilBuild` match the intended IDE compatibility range.
+- `./gradlew check koverXmlReport buildPlugin verifyPlugin` passes.
+
+## Release Notes
+
+Release notes are for plugin users. They should explain what changed from the user's perspective.
+
+Include:
+
+- IDE compatibility changes.
+- User-visible features.
+- Bug fixes.
+- Stability, performance, installation, or behavior changes that affect users.
+- Migration or update notes that affect users.
+
+Exclude or minimize:
+
+- CI, branch protection, or repository policy changes.
+- Dependency maintenance that does not affect users.
+- Contributor-only docs, templates, CODEOWNERS, and internal tests.
+- Build tool updates unless they change compatibility, installation, or runtime behavior.
+
+Example user-facing release note:
+
+```md
+### Changed
+- Added support for IntelliJ IDEA 2025.2 and newer IDE builds up to 2026.1.
+
+### Fixed
+- Fixed compatibility issues that could prevent the plugin from loading on newer IntelliJ IDE versions.
+```
+
+GitHub Release drafts should use:
+
+- Title: `vX.Y.Z`
+- Tag: `vX.Y.Z`
+- Target: the intended `main` commit
+- Notes: user-facing release notes only
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/activities/CodeXPStartupActivity.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/activities/CodeXPStartupActivity.kt
index 8722070..c320999 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/activities/CodeXPStartupActivity.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/activities/CodeXPStartupActivity.kt
@@ -1,14 +1,14 @@
package com.github.ilovegamecoding.intellijcodexp.activities
-import com.github.ilovegamecoding.intellijcodexp.managers.CodeXPUIManager
-import com.github.ilovegamecoding.intellijcodexp.services.CodeXPService
-import com.intellij.openapi.application.ApplicationManager
+import com.github.ilovegamecoding.intellijcodexp.presentation.overlay.CodeXPOverlayController
import com.intellij.openapi.project.Project
-import com.intellij.openapi.startup.StartupActivity
+import com.intellij.openapi.startup.ProjectActivity
-class CodeXPStartupActivity : StartupActivity {
- override fun runActivity(project: Project) {
- ApplicationManager.getApplication().getService(CodeXPService::class.java)
- CodeXPUIManager.createDialogArea()
+/**
+ * Initializes project-window CodeXP UI controllers after a project opens.
+ */
+class CodeXPStartupActivity : ProjectActivity {
+ override suspend fun execute(project: Project) {
+ project.getService(CodeXPOverlayController::class.java)
}
}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/domain/CodeXPProgressEngine.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/domain/CodeXPProgressEngine.kt
new file mode 100644
index 0000000..6af02c9
--- /dev/null
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/domain/CodeXPProgressEngine.kt
@@ -0,0 +1,107 @@
+package com.github.ilovegamecoding.intellijcodexp.domain
+
+import com.github.ilovegamecoding.intellijcodexp.enums.Event
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallengeFactory
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPState
+
+/**
+ * Handles CodeXP progress rules without depending on IntelliJ platform APIs or Swing.
+ */
+class CodeXPProgressEngine(
+ private val defaultChallenges: () -> List = CodeXPChallengeFactory::createEventDefaultChallenges,
+) {
+ fun initialize(state: CodeXPState) {
+ if (!state.hasExecuted) {
+ state.hasExecuted = true
+ }
+
+ Event.entries.forEach { event ->
+ if (!state.eventCounts.containsKey(event)) {
+ state.eventCounts[event] = 0
+ }
+ }
+
+ defaultChallenges().forEach { challenge ->
+ if (!state.challenges.containsKey(challenge.event)) {
+ state.challenges[challenge.event] = challenge
+ }
+ }
+ }
+
+ fun recordEvent(
+ state: CodeXPState,
+ event: Event,
+ ): CodeXPProgressResult {
+ state.eventCounts[event] = state.eventCounts.getOrDefault(event, 0) + 1
+
+ val xpChanges = mutableListOf()
+ xpChanges += increaseXP(state, event.xpValue)
+
+ val challenge = state.challenges[event] ?: return CodeXPProgressResult(event, xpChanges = xpChanges)
+ challenge.progress += 1
+
+ if (challenge.progress < challenge.goal) {
+ return CodeXPProgressResult(
+ event = event,
+ updatedChallenge = challenge,
+ xpChanges = xpChanges,
+ )
+ }
+
+ xpChanges += increaseXP(state, challenge.rewardXP)
+ val newChallenge = createNextChallenge(challenge)
+ state.completedChallenges.add(challenge)
+ state.challenges[event] = newChallenge
+
+ return CodeXPProgressResult(
+ event = event,
+ updatedChallenge = challenge,
+ completedChallenge = challenge,
+ newChallenge = newChallenge,
+ xpChanges = xpChanges,
+ )
+ }
+
+ private fun increaseXP(
+ state: CodeXPState,
+ incrementAmount: Long,
+ ): CodeXPChange {
+ val beforeLevelInfo = CodeXPLevel.createLevelInfo(state.xp)
+ state.xp += incrementAmount
+ val currentLevelInfo = CodeXPLevel.createLevelInfo(state.xp)
+
+ return CodeXPChange(
+ beforeLevelInfo = beforeLevelInfo,
+ currentLevelInfo = currentLevelInfo,
+ )
+ }
+
+ private fun createNextChallenge(completedChallenge: CodeXPChallenge): CodeXPChallenge =
+ CodeXPChallenge(
+ event = completedChallenge.event,
+ name = completedChallenge.name,
+ description = completedChallenge.description,
+ rewardXP = completedChallenge.rewardXP + completedChallenge.rewardXPIncrement,
+ rewardXPIncrement = completedChallenge.rewardXPIncrement,
+ progress = 0,
+ goal = completedChallenge.goal + completedChallenge.goalIncrement,
+ goalIncrement = completedChallenge.goalIncrement,
+ )
+}
+
+data class CodeXPProgressResult(
+ val event: Event,
+ val updatedChallenge: CodeXPChallenge? = null,
+ val completedChallenge: CodeXPChallenge? = null,
+ val newChallenge: CodeXPChallenge? = null,
+ val xpChanges: List = emptyList(),
+)
+
+data class CodeXPChange(
+ val beforeLevelInfo: CodeXPLevel,
+ val currentLevelInfo: CodeXPLevel,
+) {
+ val isLevelUp: Boolean = beforeLevelInfo.level != currentLevelInfo.level && beforeLevelInfo.level != 0
+}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/CodeXPListener.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/CodeXPListener.kt
index e187fe9..772e540 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/CodeXPListener.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/CodeXPListener.kt
@@ -3,6 +3,7 @@ package com.github.ilovegamecoding.intellijcodexp.listeners
import com.github.ilovegamecoding.intellijcodexp.enums.Event
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
+import com.intellij.openapi.actionSystem.DataContext
import com.intellij.util.messages.Topic
/**
@@ -30,7 +31,10 @@ interface CodeXPListener {
*
* @param levelInfo Level info.
*/
- fun levelUp(levelInfo: CodeXPLevel)
+ fun levelUp(
+ levelInfo: CodeXPLevel,
+ dataContext: DataContext? = null,
+ )
/**
* Function that is called when challenge updated.
@@ -51,5 +55,6 @@ interface CodeXPListener {
fun challengeCompleted(
event: Event,
challenge: CodeXPChallenge,
+ dataContext: DataContext? = null,
)
}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/IdeEventListener.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/IdeEventListener.kt
index f8152ce..0c29b78 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/IdeEventListener.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/listeners/IdeEventListener.kt
@@ -31,18 +31,18 @@ internal class IdeEventListener : AnActionListener {
super.afterActionPerformed(action, event, result)
when (action.templateText) {
- "Run" -> fireEvent(Event.RUN)
- "Save All" -> fireEvent(Event.SAVE)
- "Debug" -> fireEvent(Event.DEBUG)
- "Build Project" -> fireEvent(Event.BUILD)
- "Rebuild Project" -> fireEvent(Event.BUILD)
+ "Run" -> fireEvent(Event.RUN, event.dataContext)
+ "Save All" -> fireEvent(Event.SAVE, event.dataContext)
+ "Debug" -> fireEvent(Event.DEBUG, event.dataContext)
+ "Build Project" -> fireEvent(Event.BUILD, event.dataContext)
+ "Rebuild Project" -> fireEvent(Event.BUILD, event.dataContext)
"Cut" -> fireEvent(Event.CUT, event.dataContext)
"Copy" -> fireEvent(Event.COPY, event.dataContext)
"Paste" -> fireEvent(Event.PASTE, event.dataContext)
"Backspace" -> fireEvent(Event.BACKSPACE, event.dataContext)
"Tab" -> fireEvent(Event.TAB, event.dataContext)
"Enter" -> fireEvent(Event.ENTER, event.dataContext)
- else -> fireEvent(Event.ACTION)
+ else -> fireEvent(Event.ACTION, event.dataContext)
}
}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPUIManager.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPUIManager.kt
deleted file mode 100644
index 1b357e0..0000000
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPUIManager.kt
+++ /dev/null
@@ -1,326 +0,0 @@
-package com.github.ilovegamecoding.intellijcodexp.managers
-
-import com.github.ilovegamecoding.intellijcodexp.enums.Event
-import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPEventListener
-import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPListener
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPConfiguration
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
-import com.github.ilovegamecoding.intellijcodexp.services.CodeXPService
-import com.github.ilovegamecoding.intellijcodexp.utils.StringUtil
-import com.github.ilovegamecoding.intellijcodexp.views.CodeXPDialog
-import com.intellij.openapi.actionSystem.CommonDataKeys
-import com.intellij.openapi.actionSystem.DataContext
-import com.intellij.openapi.application.ApplicationManager
-import com.intellij.openapi.diagnostic.thisLogger
-import com.intellij.openapi.project.ProjectManager
-import com.intellij.openapi.wm.WindowManager
-import com.intellij.ui.JBColor
-import java.awt.Color
-import java.awt.Dimension
-import java.awt.Font
-import java.awt.Point
-import java.awt.event.MouseAdapter
-import java.awt.event.MouseMotionAdapter
-import javax.swing.BoxLayout
-import javax.swing.JComponent
-import javax.swing.JLabel
-import javax.swing.JLayeredPane
-import javax.swing.JPanel
-import javax.swing.SwingUtilities
-import javax.swing.Timer
-import kotlin.math.max
-
-/**
- * CodeXPUIManager class
- *
- * This class manages the UI of the CodeXP plugin.
- */
-object CodeXPUIManager : CodeXPEventListener, CodeXPListener {
- /**
- * Fading labels in each swing component.
- */
- private val fadingLabels: MutableMap = mutableMapOf()
-
- /**
- * Current value of the fading label.
- */
- private var currentXPGainValue: Int = 0
-
- /**
- * The message bus for the plugin.
- */
- private val messageBus = ApplicationManager.getApplication().messageBus
-
- /**
- * The connection to the message bus.
- */
- private val connection = messageBus.connect()
-
- /**
- * The IDE frame.
- */
- private lateinit var ide: JLayeredPane
-
- /**
- * Dialog area for displaying dialogs in the IDE.
- */
- private lateinit var dialogArea: JPanel
-
- /**
- * Timers for each dialog.
- */
- private val dialogTimers: MutableMap = mutableMapOf()
-
- /**
- * Dialog duration.
- */
- private val dialogDuration: Int = 4000
-
- init {
- connection.subscribe(CodeXPEventListener.CODEXP_EVENT, this)
- connection.subscribe(CodeXPListener.CODEXP, this)
- }
-
- override fun eventOccurred(
- event: Event,
- dataContext: DataContext?,
- ) {
- displayXPLabel(event, dataContext)
- }
-
- override fun xpUpdated(levelInfo: CodeXPLevel) {
- }
-
- override fun levelUp(levelInfo: CodeXPLevel) {
- showDialog(
- CodeXPDialog.createDialog(
- "Level Up!",
- "Congratulations! You are now level ${StringUtil.numberToStringWithCommas(levelInfo.level.toLong())}!",
- "XP to next level: ${StringUtil.numberToStringWithCommas(levelInfo.totalXPForNextLevel)} xp",
- ),
- )
- }
-
- override fun challengeUpdated(
- event: Event,
- challenge: CodeXPChallenge,
- newChallenge: CodeXPChallenge?,
- ) {
- }
-
- override fun challengeCompleted(
- event: Event,
- challenge: CodeXPChallenge,
- ) {
- showDialog(
- CodeXPDialog.createDialog(
- "Challenge Completed!",
- "Congratulations! You have completed ${challenge.name.lowercase()}!",
- "XP earned: ${StringUtil.numberToStringWithCommas(challenge.rewardXP)} xp",
- ),
- )
- }
-
- /**
- * Display XP label at the caret position.
- *
- * @param event The event to fire.
- * @param dataContext The data context of the event.
- */
- private fun displayXPLabel(
- event: Event,
- dataContext: DataContext?,
- ) {
- dataContext ?: return
-
- val codeXPConfiguration =
- ApplicationManager
- .getApplication()
- .getService(CodeXPService::class.java)
- .state.codeXPConfiguration
-
- if (!codeXPConfiguration.showGainedXP) {
- return
- }
-
- val editor = CommonDataKeys.EDITOR.getData(dataContext) ?: return
- val caretModel = editor.caretModel
- val fadingLabelPosition = editor.visualPositionToXY(caretModel.visualPosition)
-
- val component = editor.contentComponent
-
- fadingLabels[component]?.let { fadingLabel ->
- fadingLabel.cancelFadeOut()
- currentXPGainValue = fadingLabel.value
- with(component) {
- remove(fadingLabel)
- revalidate()
- repaint()
- }
- }
-
- currentXPGainValue += event.xpValue.toInt()
-
- val newFadingLabel =
- FadingLabel(currentXPGainValue).apply {
- font = Font(font.fontName, Font.BOLD, editor.colorsScheme.editorFontSize)
- size = preferredSize
- val caretHeight = editor.lineHeight
- location =
- calculateLabelLocation(codeXPConfiguration, fadingLabelPosition, caretHeight, preferredSize.width)
- startFadeOut()
- }
-
- fadingLabels[component] = newFadingLabel
-
- with(component) {
- add(newFadingLabel)
- revalidate()
- repaint()
- }
- }
-
- private fun calculateLabelLocation(
- config: CodeXPConfiguration,
- point: Point,
- caretHeight: Int,
- labelWidth: Int,
- ): Point {
- with(config.positionToDisplayGainedXP) {
- val xOffset =
- when {
- name.contains("LEFT") -> -labelWidth
- name.contains("RIGHT") -> 0
- else -> -labelWidth / 2
- }
- point.translate((x * 4) + xOffset, y * (caretHeight / 2))
- }
- return point
- }
-
- /**
- * Fading label class for displaying XP gain.
- */
- internal class FadingLabel(
- initialValue: Int,
- ) : JLabel(if (initialValue == 0) "0 xp" else "+$initialValue XP") {
- private lateinit var timer: Timer
-
- /**
- * Start fade out animation.
- */
- fun startFadeOut() {
- timer = Timer(100, null)
- timer.addActionListener {
- val newAlpha = max(foreground.alpha - 255 / 10, 0)
- if (newAlpha <= 0) {
- timer.stop()
- value = 0
- } else {
- foreground =
- JBColor(
- Color(
- JBColor.foreground().red,
- JBColor.foreground().green,
- JBColor.foreground().blue,
- newAlpha,
- ),
- Color(
- JBColor.foreground().red,
- JBColor.foreground().green,
- JBColor.foreground().blue,
- newAlpha,
- ),
- )
- }
- }
- timer.start()
- }
-
- /**
- * Cancel fade out animation.
- */
- fun cancelFadeOut() {
- timer.stop()
- }
-
- /**
- * Current value of the fading label.
- */
- var value: Int = initialValue
- set(newValue) {
- field = newValue
- text = if (newValue == 0) "0 xp" else "+$newValue xp"
- if (newValue == 0) {
- timer.stop()
- }
- }
- }
-
- /**
- * Create a dialog.
- */
- fun createDialogArea() {
- dialogArea = JPanel()
- with(dialogArea) {
- layout = BoxLayout(this, BoxLayout.Y_AXIS)
- isVisible = false
- background = JBColor(Color(255, 255, 255, 0), Color(255, 255, 255, 0))
- isOpaque = false
-
- addMouseListener(object : MouseAdapter() {})
- addMouseMotionListener(object : MouseMotionAdapter() {})
- }
-
- WindowManager
- .getInstance()
- .getIdeFrame(ProjectManager.getInstance().openProjects.firstOrNull())
- ?.component
- ?.rootPane
- ?.layeredPane
- ?.let {
- ide = it
- ide.add(dialogArea, JLayeredPane.POPUP_LAYER, 0)
- } ?: run {
- thisLogger().warn("Could not find IDE frame.")
- }
- }
-
- /**
- * Show the progress window for 3 seconds.
- */
- private fun showDialog(dialog: CodeXPDialog) {
- dialogTimers[dialog] = Timer(dialogDuration) { hideDialog(dialog) }
- dialogTimers[dialog]?.start()
- dialog.show()
-
- with(dialogArea) {
- add(dialog.frame, 0)
- size = Dimension(480, preferredSize.height)
- location = Point(ide.width / 2 - width / 2, (ide.height * 0.05).toInt())
- revalidate()
- repaint()
- isVisible = true
- }
- }
-
- /**
- * Hide the progress window immediately.
- */
- private fun hideDialog(dialog: CodeXPDialog) {
- SwingUtilities.invokeLater {
- dialogTimers[dialog]?.stop()
- dialogTimers.remove(dialog)
-
- with(dialogArea) {
- remove(dialog.frame)
- size = Dimension(480, preferredSize.height)
- }
-
- if (dialogArea.components.isEmpty()) {
- dialogArea.isVisible = false
- }
- }
- }
-}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPToolWindowFactory.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/dashboard/CodeXPToolWindowFactory.kt
similarity index 77%
rename from src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPToolWindowFactory.kt
rename to src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/dashboard/CodeXPToolWindowFactory.kt
index a7af766..30268db 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPToolWindowFactory.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/dashboard/CodeXPToolWindowFactory.kt
@@ -1,4 +1,4 @@
-package com.github.ilovegamecoding.intellijcodexp.toolWindow
+package com.github.ilovegamecoding.intellijcodexp.presentation.dashboard
import com.github.ilovegamecoding.intellijcodexp.enums.Event
import com.github.ilovegamecoding.intellijcodexp.form.CodeXPChallengeForm
@@ -12,6 +12,7 @@ import com.github.ilovegamecoding.intellijcodexp.utils.StringUtil
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.components.JBScrollPane
@@ -47,13 +48,15 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
project: Project,
toolWindow: ToolWindow,
) {
+ val dashboardDisposable = Disposer.newDisposable("CodeXP dashboard")
+
// Get the CodeXP service
codeXPService = ApplicationManager.getApplication().getService(CodeXPService::class.java)
// Create the dashboard
codeXPDashboardForm = CodeXPDashboardForm()
- initializeUI()
+ initializeUI(dashboardDisposable)
// Add the dashboard to the tool window
val contentFactory = ContentFactory.getInstance()
@@ -61,20 +64,21 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
val rootPanel = JPanel(BorderLayout())
rootPanel.add(BorderLayout.CENTER, scrollPane)
val content = contentFactory.createContent(rootPanel, null, false)
+ content.setDisposer(dashboardDisposable)
toolWindow.contentManager.addContent(content)
}
/**
* Initializes the UI of the dashboard.
*/
- private fun initializeUI() {
+ private fun initializeUI(dashboardDisposable: com.intellij.openapi.Disposable) {
val eventStaticForms = HashMap()
val challengeForms = HashMap()
initializeNickname()
initializeEventStatisticsAndChallenges(eventStaticForms, challengeForms)
initializeCompletedChallenges()
- initializeConnection(eventStaticForms, challengeForms)
+ initializeConnection(dashboardDisposable, eventStaticForms, challengeForms)
updateXPInfo(CodeXPLevel.createLevelInfo(codeXPService.state.xp))
}
@@ -113,7 +117,7 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
gridBagConstraints.weightx = 1.0
gridBagConstraints.fill = GridBagConstraints.HORIZONTAL
- Event.values().forEachIndexed { index, event ->
+ Event.entries.forEachIndexed { index, event ->
// Add ui for each event
if (event != Event.NONE) { // Ignore the NONE event type
// Initialize event statistics
@@ -169,10 +173,10 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
codeXPDashboardForm.cbShowCompletedChallenges.isSelected = codeXPService.state.showCompletedChallenges
codeXPDashboardForm.cbShowCompletedChallenges.addItemListener { e ->
if (e.stateChange == ItemEvent.SELECTED) {
- codeXPService.state.showCompletedChallenges = true
+ codeXPService.setCompletedChallengesVisible(true)
updateCompletedChallenges()
} else {
- codeXPService.state.showCompletedChallenges = false
+ codeXPService.setCompletedChallengesVisible(false)
codeXPDashboardForm.pCompletedChallenges.removeAll()
}
codeXPDashboardForm.pCompletedChallenges.revalidate()
@@ -187,6 +191,7 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
* @param challengeForms Challenge forms.
*/
private fun initializeConnection(
+ dashboardDisposable: com.intellij.openapi.Disposable,
eventStaticForms: HashMap,
challengeForms: HashMap,
) {
@@ -195,76 +200,78 @@ class CodeXPToolWindowFactory : ToolWindowFactory {
gridBagConstraints.fill = GridBagConstraints.HORIZONTAL
// Update the dashboard when events occur
- ApplicationManager
- .getApplication()
- .messageBus
- .connect()
- .subscribe(
- CodeXPEventListener.CODEXP_EVENT,
- object : CodeXPEventListener {
- override fun eventOccurred(
- event: Event,
- dataContext: DataContext?,
- ) {
- (eventStaticForms[event]!!.getComponent(2) as JLabel).text =
- StringUtil.numberToStringWithCommas(codeXPService.state.getEventCount(event))
- }
- },
- )
+ val connection =
+ ApplicationManager
+ .getApplication()
+ .messageBus
+ .connect(dashboardDisposable)
+
+ connection.subscribe(
+ CodeXPEventListener.CODEXP_EVENT,
+ object : CodeXPEventListener {
+ override fun eventOccurred(
+ event: Event,
+ dataContext: DataContext?,
+ ) {
+ (eventStaticForms[event]!!.getComponent(2) as JLabel).text =
+ StringUtil.numberToStringWithCommas(codeXPService.state.getEventCount(event))
+ }
+ },
+ )
- ApplicationManager
- .getApplication()
- .messageBus
- .connect()
- .subscribe(
- CodeXPListener.CODEXP,
- object : CodeXPListener {
- override fun xpUpdated(levelInfo: CodeXPLevel) {
- updateXPInfo(levelInfo)
- }
+ connection.subscribe(
+ CodeXPListener.CODEXP,
+ object : CodeXPListener {
+ override fun xpUpdated(levelInfo: CodeXPLevel) {
+ updateXPInfo(levelInfo)
+ }
- override fun levelUp(levelInfo: CodeXPLevel) {
- }
+ override fun levelUp(
+ levelInfo: CodeXPLevel,
+ dataContext: DataContext?,
+ ) {
+ }
- override fun challengeUpdated(
- event: Event,
- challenge: CodeXPChallenge,
- newChallenge: CodeXPChallenge?,
- ) {
- if (newChallenge != null) {
- codeXPDashboardForm.lblCompletedChallengesCount.text =
- StringUtil.numberToStringWithCommas(
- codeXPService.state.completedChallenges.size
- .toLong(),
- )
-
- if (codeXPService.state.showCompletedChallenges) {
- gridBagConstraints.gridy = codeXPDashboardForm.pCompletedChallenges.componentCount
- codeXPDashboardForm.pCompletedChallenges.add(
- createOrUpdateChallengeForm(challenge).pChallenge,
- gridBagConstraints,
- )
- }
- createOrUpdateChallengeForm(newChallenge, challengeForms[event]!!)
- } else {
- updateChallengeProgress(challenge, challengeForms[event]!!)
+ override fun challengeUpdated(
+ event: Event,
+ challenge: CodeXPChallenge,
+ newChallenge: CodeXPChallenge?,
+ ) {
+ if (newChallenge != null) {
+ codeXPDashboardForm.lblCompletedChallengesCount.text =
+ StringUtil.numberToStringWithCommas(
+ codeXPService.state.completedChallenges.size
+ .toLong(),
+ )
+
+ if (codeXPService.state.showCompletedChallenges) {
+ gridBagConstraints.gridy = codeXPDashboardForm.pCompletedChallenges.componentCount
+ codeXPDashboardForm.pCompletedChallenges.add(
+ createOrUpdateChallengeForm(challenge).pChallenge,
+ gridBagConstraints,
+ )
}
+ createOrUpdateChallengeForm(newChallenge, challengeForms[event]!!)
+ } else {
+ updateChallengeProgress(challenge, challengeForms[event]!!)
}
+ }
- override fun challengeCompleted(
- event: Event,
- challenge: CodeXPChallenge,
- ) {
- }
- },
- )
+ override fun challengeCompleted(
+ event: Event,
+ challenge: CodeXPChallenge,
+ dataContext: DataContext?,
+ ) {
+ }
+ },
+ )
}
/**
* Updates user nickname on the dashboard.
*/
private fun updateNickname() {
- codeXPService.state.nickname = codeXPDashboardForm.tfNickname.text
+ codeXPService.updateNickname(codeXPDashboardForm.tfNickname.text)
}
/**
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPNotificationManager.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/notification/CodeXPNotificationNotifier.kt
similarity index 90%
rename from src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPNotificationManager.kt
rename to src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/notification/CodeXPNotificationNotifier.kt
index 01ff1df..cd17fa4 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/managers/CodeXPNotificationManager.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/notification/CodeXPNotificationNotifier.kt
@@ -1,11 +1,11 @@
-package com.github.ilovegamecoding.intellijcodexp.managers
+package com.github.ilovegamecoding.intellijcodexp.presentation.notification
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
import com.intellij.notification.NotificationGroup
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
-object CodeXPNotificationManager {
+object CodeXPNotificationNotifier {
private val notificationGroup: NotificationGroup =
NotificationGroupManager.getInstance().getNotificationGroup("CodeXP")
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayController.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayController.kt
new file mode 100644
index 0000000..5247fa0
--- /dev/null
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayController.kt
@@ -0,0 +1,360 @@
+package com.github.ilovegamecoding.intellijcodexp.presentation.overlay
+
+import com.github.ilovegamecoding.intellijcodexp.enums.Event
+import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPEventListener
+import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPListener
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPConfiguration
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
+import com.github.ilovegamecoding.intellijcodexp.services.CodeXPService
+import com.github.ilovegamecoding.intellijcodexp.utils.StringUtil
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.actionSystem.CommonDataKeys
+import com.intellij.openapi.actionSystem.DataContext
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.diagnostic.thisLogger
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.wm.WindowManager
+import com.intellij.ui.JBColor
+import java.awt.Color
+import java.awt.Dimension
+import java.awt.Font
+import java.awt.Point
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseMotionAdapter
+import javax.swing.BoxLayout
+import javax.swing.JComponent
+import javax.swing.JLabel
+import javax.swing.JLayeredPane
+import javax.swing.JPanel
+import javax.swing.SwingUtilities
+import javax.swing.Timer
+import kotlin.math.max
+
+/**
+ * Displays CodeXP overlay effects for a single project window.
+ */
+@Service(Service.Level.PROJECT)
+class CodeXPOverlayController(
+ private val project: Project,
+) : CodeXPEventListener,
+ CodeXPListener,
+ Disposable {
+ private val codeXPService: CodeXPService =
+ ApplicationManager
+ .getApplication()
+ .getService(CodeXPService::class.java)
+
+ private val fadingLabels: MutableMap = mutableMapOf()
+ private val dialogTimers: MutableMap = mutableMapOf()
+ private val dialogDuration: Int = 4000
+ private var ide: JLayeredPane? = null
+ private var dialogArea: JPanel? = null
+
+ init {
+ val connection = ApplicationManager.getApplication().messageBus.connect(this)
+ connection.subscribe(CodeXPEventListener.CODEXP_EVENT, this)
+ connection.subscribe(CodeXPListener.CODEXP, this)
+
+ SwingUtilities.invokeLater {
+ if (!project.isDisposed) {
+ createDialogArea()
+ }
+ }
+ }
+
+ override fun eventOccurred(
+ event: Event,
+ dataContext: DataContext?,
+ ) {
+ if (!belongsToProject(dataContext)) {
+ return
+ }
+
+ displayXPLabel(event, dataContext)
+ }
+
+ override fun xpUpdated(levelInfo: CodeXPLevel) {
+ }
+
+ override fun levelUp(
+ levelInfo: CodeXPLevel,
+ dataContext: DataContext?,
+ ) {
+ if (!belongsToProject(dataContext)) {
+ return
+ }
+
+ showDialog(
+ CodeXPOverlayDialog.createDialog(
+ "Level Up!",
+ "Congratulations! You are now level ${StringUtil.numberToStringWithCommas(levelInfo.level.toLong())}!",
+ "XP to next level: ${StringUtil.numberToStringWithCommas(levelInfo.totalXPForNextLevel)} xp",
+ ),
+ )
+ }
+
+ override fun challengeUpdated(
+ event: Event,
+ challenge: CodeXPChallenge,
+ newChallenge: CodeXPChallenge?,
+ ) {
+ }
+
+ override fun challengeCompleted(
+ event: Event,
+ challenge: CodeXPChallenge,
+ dataContext: DataContext?,
+ ) {
+ if (!belongsToProject(dataContext)) {
+ return
+ }
+
+ showDialog(
+ CodeXPOverlayDialog.createDialog(
+ "Challenge Completed!",
+ "Congratulations! You have completed ${challenge.name.lowercase()}!",
+ "XP earned: ${StringUtil.numberToStringWithCommas(challenge.rewardXP)} xp",
+ ),
+ )
+ }
+
+ private fun belongsToProject(dataContext: DataContext?): Boolean {
+ dataContext ?: return false
+ return CommonDataKeys.PROJECT.getData(dataContext) == project
+ }
+
+ override fun dispose() {
+ dialogTimers.forEach { (dialog, timer) ->
+ timer.stop()
+ dialog.dispose()
+ }
+ dialogTimers.clear()
+
+ if (SwingUtilities.isEventDispatchThread()) {
+ disposeUi()
+ } else {
+ SwingUtilities.invokeLater {
+ disposeUi()
+ }
+ }
+ }
+
+ private fun disposeUi() {
+ fadingLabels.forEach { (component, fadingLabel) ->
+ fadingLabel.cancelFadeOut()
+ component.remove(fadingLabel)
+ component.revalidate()
+ component.repaint()
+ }
+ fadingLabels.clear()
+ dialogArea?.let { area ->
+ area.removeAll()
+ ide?.remove(area)
+ ide?.revalidate()
+ ide?.repaint()
+ }
+ dialogArea = null
+ ide = null
+ }
+
+ private fun displayXPLabel(
+ event: Event,
+ dataContext: DataContext?,
+ ) {
+ dataContext ?: return
+
+ val codeXPConfiguration = codeXPService.state.codeXPConfiguration
+ if (!codeXPConfiguration.showGainedXP) {
+ return
+ }
+
+ val editor = CommonDataKeys.EDITOR.getData(dataContext) ?: return
+ val component = editor.contentComponent
+ val previousValue =
+ fadingLabels.remove(component)?.let { fadingLabel ->
+ fadingLabel.cancelFadeOut()
+ component.remove(fadingLabel)
+ component.revalidate()
+ component.repaint()
+ fadingLabel.value
+ } ?: 0
+
+ val caretModel = editor.caretModel
+ val fadingLabelPosition = editor.visualPositionToXY(caretModel.visualPosition)
+ val caretHeight = editor.lineHeight
+ val newFadingLabel =
+ FadingLabel(previousValue + event.xpValue.toInt()).apply {
+ font = Font(font.fontName, Font.BOLD, editor.colorsScheme.editorFontSize)
+ size = preferredSize
+ location =
+ calculateLabelLocation(
+ config = codeXPConfiguration,
+ point = fadingLabelPosition,
+ caretHeight = caretHeight,
+ labelWidth = preferredSize.width,
+ )
+ startFadeOut()
+ }
+
+ fadingLabels[component] = newFadingLabel
+
+ with(component) {
+ add(newFadingLabel)
+ revalidate()
+ repaint()
+ }
+ }
+
+ private fun calculateLabelLocation(
+ config: CodeXPConfiguration,
+ point: Point,
+ caretHeight: Int,
+ labelWidth: Int,
+ ): Point {
+ val location = Point(point)
+ with(config.positionToDisplayGainedXP) {
+ val xOffset =
+ when {
+ name.contains("LEFT") -> -labelWidth
+ name.contains("RIGHT") -> 0
+ else -> -labelWidth / 2
+ }
+ location.translate((x * 4) + xOffset, y * (caretHeight / 2))
+ }
+ return location
+ }
+
+ private fun createDialogArea() {
+ val layeredPane =
+ WindowManager
+ .getInstance()
+ .getIdeFrame(project)
+ ?.component
+ ?.rootPane
+ ?.layeredPane
+
+ if (layeredPane == null) {
+ thisLogger().warn("Could not find IDE frame for project ${project.name}.")
+ return
+ }
+
+ val area =
+ JPanel().apply {
+ layout = BoxLayout(this, BoxLayout.Y_AXIS)
+ isVisible = false
+ background = JBColor(Color(255, 255, 255, 0), Color(255, 255, 255, 0))
+ isOpaque = false
+
+ addMouseListener(object : MouseAdapter() {})
+ addMouseMotionListener(object : MouseMotionAdapter() {})
+ }
+
+ ide = layeredPane
+ dialogArea = area
+ layeredPane.add(area, JLayeredPane.POPUP_LAYER, 0)
+ }
+
+ private fun showDialog(dialog: CodeXPOverlayDialog) {
+ val area = dialogArea ?: return
+ val layeredPane = ide ?: return
+
+ dialogTimers[dialog] =
+ Timer(dialogDuration) {
+ hideDialog(dialog)
+ }.apply {
+ isRepeats = false
+ start()
+ }
+ dialog.show()
+
+ with(area) {
+ add(dialog.frame, 0)
+ size = Dimension(480, preferredSize.height)
+ location = Point(layeredPane.width / 2 - width / 2, (layeredPane.height * 0.05).toInt())
+ revalidate()
+ repaint()
+ isVisible = true
+ }
+ }
+
+ private fun hideDialog(dialog: CodeXPOverlayDialog) {
+ SwingUtilities.invokeLater {
+ val area = dialogArea ?: return@invokeLater
+ dialogTimers.remove(dialog)?.stop()
+ dialog.dispose()
+
+ with(area) {
+ remove(dialog.frame)
+ size = Dimension(480, preferredSize.height)
+ }
+
+ if (area.components.isEmpty()) {
+ area.isVisible = false
+ }
+ }
+ }
+
+ /**
+ * Label that fades out after displaying gained XP beside an editor caret.
+ */
+ internal class FadingLabel(
+ initialValue: Int,
+ ) : JLabel(if (initialValue == 0) "0 xp" else "+$initialValue XP") {
+ private var timer: Timer? = null
+
+ /**
+ * Current XP value displayed by this label.
+ */
+ var value: Int = initialValue
+ set(newValue) {
+ field = newValue
+ text = if (newValue == 0) "0 xp" else "+$newValue xp"
+ if (newValue == 0) {
+ timer?.stop()
+ }
+ }
+
+ /**
+ * Starts the fade-out animation.
+ */
+ fun startFadeOut() {
+ timer =
+ Timer(100, null).apply {
+ addActionListener {
+ val newAlpha = max(foreground.alpha - 255 / 10, 0)
+ if (newAlpha <= 0) {
+ stop()
+ value = 0
+ } else {
+ foreground =
+ JBColor(
+ Color(
+ JBColor.foreground().red,
+ JBColor.foreground().green,
+ JBColor.foreground().blue,
+ newAlpha,
+ ),
+ Color(
+ JBColor.foreground().red,
+ JBColor.foreground().green,
+ JBColor.foreground().blue,
+ newAlpha,
+ ),
+ )
+ }
+ }
+ start()
+ }
+ }
+
+ /**
+ * Stops the fade-out animation.
+ */
+ fun cancelFadeOut() {
+ timer?.stop()
+ timer = null
+ }
+ }
+}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/views/CodeXPDialog.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayDialog.kt
similarity index 61%
rename from src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/views/CodeXPDialog.kt
rename to src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayDialog.kt
index 82bcccf..ef87f77 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/views/CodeXPDialog.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/overlay/CodeXPOverlayDialog.kt
@@ -1,4 +1,4 @@
-package com.github.ilovegamecoding.intellijcodexp.views
+package com.github.ilovegamecoding.intellijcodexp.presentation.overlay
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
@@ -16,33 +16,35 @@ import javax.swing.SwingUtilities
import javax.swing.Timer
/**
- * CodeXPDialog class
+ * CodeXPOverlayDialog class
*
- * This class is used to create a dialog that is shown in the dialog area.
+ * This class is used to create a dialog that is shown in the overlay area.
*/
-class CodeXPDialog(
+class CodeXPOverlayDialog(
/**
* Dialog title.
*/
- private var title: String = "",
+ title: String = "",
/**
* Dialog main description.
*/
- private var mainDescription: String = "",
+ mainDescription: String = "",
/**
* Dialog sub description.
*/
- private var subDescription: String = "",
+ subDescription: String = "",
) {
+ private val timers: MutableList = mutableListOf()
+
/**
* Dialog frame.
*/
- var frame: JPanel = JPanel()
+ val frame: JPanel = JPanel()
/**
* Dialog content.
*/
- private var content: JPanel
+ private val content: JPanel
/**
* Show dialog duration.
@@ -98,6 +100,7 @@ class CodeXPDialog(
content =
object : JPanel() {
override fun paintComponent(g: Graphics) {
+ super.paintComponent(g)
val g2d = g.create() as Graphics2D
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2d.color = background
@@ -140,7 +143,7 @@ class CodeXPDialog(
title: String = "",
mainDescription: String = "",
subDescription: String = "",
- ): CodeXPDialog = CodeXPDialog(title, mainDescription, subDescription)
+ ): CodeXPOverlayDialog = CodeXPOverlayDialog(title, mainDescription, subDescription)
}
/**
@@ -148,28 +151,24 @@ class CodeXPDialog(
*/
fun show() {
SwingUtilities.invokeLater {
- Timer(stepMillis) { event ->
- val alpha = ((content.background as JBColor).alpha + fadeStep).coerceIn(0.0, 192.0).toInt()
-
- with(content) {
- background = JBColor(Color(0, 0, 0, alpha), Color(0, 0, 0, alpha))
- components.forEach {
- if (it is JLabel) {
- it.foreground = JBColor(Color(255, 255, 255, alpha), Color(255, 255, 255, alpha))
- }
- }
- }
-
- frame.repaint()
-
- if (alpha >= 192) {
- (event.source as Timer).stop()
- Timer(showDuration) { hide() }.apply {
- isRepeats = false
- start()
+ registerTimer(
+ Timer(stepMillis) { event ->
+ val alpha = calculateNextAlpha(fadeStep)
+ updateContentAlpha(alpha)
+ frame.repaint()
+
+ if (alpha >= 192) {
+ stopTimer(event.source as Timer)
+ registerTimer(
+ Timer(showDuration) {
+ hide()
+ }.apply {
+ isRepeats = false
+ },
+ )
}
- }
- }.start()
+ },
+ )
frame.isVisible = true
}
@@ -180,25 +179,56 @@ class CodeXPDialog(
*/
private fun hide() {
SwingUtilities.invokeLater {
- Timer(stepMillis) { event ->
- val alpha = ((content.background as JBColor).alpha - fadeStep).coerceIn(0.0, 192.0).toInt()
-
- with(content) {
- background = JBColor(Color(0, 0, 0, alpha), Color(0, 0, 0, alpha))
- components.forEach {
- if (it is JLabel) {
- it.foreground = JBColor(Color(255, 255, 255, alpha), Color(255, 255, 255, alpha))
- }
+ registerTimer(
+ Timer(stepMillis) { event ->
+ val alpha = calculateNextAlpha(-fadeStep)
+ updateContentAlpha(alpha)
+ frame.repaint()
+
+ if (alpha <= 0) {
+ frame.isVisible = false
+ stopTimer(event.source as Timer)
}
- }
+ },
+ )
+ }
+ }
- frame.repaint()
+ /**
+ * Stops all dialog animation timers and detaches the frame from its parent.
+ */
+ fun dispose() {
+ timers.toList().forEach(::stopTimer)
+ frame.parent?.let { parent ->
+ parent.remove(frame)
+ parent.revalidate()
+ parent.repaint()
+ }
+ }
- if (alpha <= 0) {
- frame.isVisible = false
- (event.source as Timer).stop()
+ private fun calculateNextAlpha(alphaChange: Double): Int =
+ ((content.background as JBColor).alpha + alphaChange)
+ .coerceIn(0.0, 192.0)
+ .toInt()
+
+ private fun updateContentAlpha(alpha: Int) {
+ with(content) {
+ background = JBColor(Color(0, 0, 0, alpha), Color(0, 0, 0, alpha))
+ components.forEach {
+ if (it is JLabel) {
+ it.foreground = JBColor(Color(255, 255, 255, alpha), Color(255, 255, 255, alpha))
}
- }.start()
+ }
}
}
+
+ private fun registerTimer(timer: Timer) {
+ timers.add(timer)
+ timer.start()
+ }
+
+ private fun stopTimer(timer: Timer) {
+ timer.stop()
+ timers.remove(timer)
+ }
}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPConfigurable.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/settings/CodeXPConfigurable.kt
similarity index 74%
rename from src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPConfigurable.kt
rename to src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/settings/CodeXPConfigurable.kt
index c858ca1..697403e 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/toolWindow/CodeXPConfigurable.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/presentation/settings/CodeXPConfigurable.kt
@@ -1,7 +1,9 @@
-package com.github.ilovegamecoding.intellijcodexp.toolWindow
+package com.github.ilovegamecoding.intellijcodexp.presentation.settings
+import com.github.ilovegamecoding.intellijcodexp.CodeXPBundle
import com.github.ilovegamecoding.intellijcodexp.enums.PositionToDisplayGainedXP
import com.github.ilovegamecoding.intellijcodexp.form.CodeXPConfigurationForm
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPConfiguration
import com.github.ilovegamecoding.intellijcodexp.services.CodeXPService
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.Configurable
@@ -21,13 +23,14 @@ class CodeXPConfigurable : Configurable {
/**
* CodeXP service.
*/
- private val config =
+ private val codeXPService =
ApplicationManager
.getApplication()
.getService(CodeXPService::class.java)
- .state.codeXPConfiguration
override fun createComponent(): JComponent? {
+ val config = codeXPService.state.codeXPConfiguration
+
// Create the configuration form and set the values to the current configuration.
codeXPConfigurationForm =
CodeXPConfigurationForm().apply {
@@ -43,8 +46,7 @@ class CodeXPConfigurable : Configurable {
cbShowLevelUpNotification.isSelected = config.showLevelUpNotification
cbShowCompleteChallengeNotification.isSelected = config.showCompleteChallengeNotification
cbShowGainedXP.isSelected = config.showGainedXP
- PositionToDisplayGainedXP
- .values()
+ PositionToDisplayGainedXP.entries
.map { it.name }
.forEach { cbPositionToDisplayGainedXP.addItem(it) }
cbPositionToDisplayGainedXP.isEnabled = config.showGainedXP
@@ -55,6 +57,8 @@ class CodeXPConfigurable : Configurable {
override fun isModified(): Boolean =
with(codeXPConfigurationForm) {
+ val config = codeXPService.state.codeXPConfiguration
+
if (cbNotificationType.selectedItem == "IntelliJ Notification") {
lblTypeDescription.text =
"Default notification will appear in the bottom-right of the IDE and IDE notification tool window."
@@ -71,14 +75,18 @@ class CodeXPConfigurable : Configurable {
override fun apply() {
with(codeXPConfigurationForm) {
- config.notificationType = cbNotificationType.selectedItem as String
- config.showLevelUpNotification = cbShowLevelUpNotification.isSelected
- config.showCompleteChallengeNotification = cbShowCompleteChallengeNotification.isSelected
- config.showGainedXP = cbShowGainedXP.isSelected
- config.positionToDisplayGainedXP =
- PositionToDisplayGainedXP.valueOf(cbPositionToDisplayGainedXP.selectedItem as String)
+ codeXPService.updateConfiguration(
+ CodeXPConfiguration(
+ notificationType = cbNotificationType.selectedItem as String,
+ showLevelUpNotification = cbShowLevelUpNotification.isSelected,
+ showCompleteChallengeNotification = cbShowCompleteChallengeNotification.isSelected,
+ showGainedXP = cbShowGainedXP.isSelected,
+ positionToDisplayGainedXP =
+ PositionToDisplayGainedXP.valueOf(cbPositionToDisplayGainedXP.selectedItem as String),
+ ),
+ )
}
}
- override fun getDisplayName(): String = "CodeXP"
+ override fun getDisplayName(): String = CodeXPBundle.message("configurable.codexp.display.name")
}
diff --git a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/services/CodeXPService.kt b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/services/CodeXPService.kt
index 17c5f76..7c8190f 100644
--- a/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/services/CodeXPService.kt
+++ b/src/main/kotlin/com/github/ilovegamecoding/intellijcodexp/services/CodeXPService.kt
@@ -1,14 +1,14 @@
package com.github.ilovegamecoding.intellijcodexp.services
+import com.github.ilovegamecoding.intellijcodexp.domain.CodeXPProgressEngine
+import com.github.ilovegamecoding.intellijcodexp.domain.CodeXPProgressResult
import com.github.ilovegamecoding.intellijcodexp.enums.Event
import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPEventListener
import com.github.ilovegamecoding.intellijcodexp.listeners.CodeXPListener
-import com.github.ilovegamecoding.intellijcodexp.managers.CodeXPNotificationManager
-import com.github.ilovegamecoding.intellijcodexp.managers.CodeXPUIManager
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallenge
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallengeFactory
-import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPConfiguration
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPState
+import com.github.ilovegamecoding.intellijcodexp.presentation.notification.CodeXPNotificationNotifier
+import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.PersistentStateComponent
@@ -16,7 +16,6 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.util.messages.MessageBus
-import com.intellij.util.messages.MessageBusConnection
/**
* CodeXPService class
@@ -31,7 +30,8 @@ import com.intellij.util.messages.MessageBusConnection
)
class CodeXPService :
PersistentStateComponent,
- CodeXPEventListener {
+ CodeXPEventListener,
+ Disposable {
/**
* The state of the CodeXP plugin
*/
@@ -45,13 +45,11 @@ class CodeXPService :
/**
* The connection to the message bus
*/
- private var connection: MessageBusConnection = messageBus.connect()
+ private var connection = messageBus.connect(this)
- init {
- // Call manager to register the UI and notification managers
- CodeXPUIManager
- CodeXPNotificationManager
+ private val progressEngine = CodeXPProgressEngine()
+ init {
// Connect to the application message bus
connection.subscribe(CodeXPEventListener.CODEXP_EVENT, this)
}
@@ -68,12 +66,26 @@ class CodeXPService :
initialize { }
}
+ override fun dispose() {
+ }
+
override fun eventOccurred(
event: Event,
dataContext: DataContext?,
) {
- increaseEventCount(event)
- increaseChallengeProgress(event)
+ handleProgressResult(progressEngine.recordEvent(codeXPState, event), dataContext)
+ }
+
+ fun updateNickname(nickname: String) {
+ codeXPState.nickname = nickname
+ }
+
+ fun setCompletedChallengesVisible(isVisible: Boolean) {
+ codeXPState.showCompletedChallenges = isVisible
+ }
+
+ fun updateConfiguration(configuration: CodeXPConfiguration) {
+ codeXPState.codeXPConfiguration = configuration
}
/**
@@ -82,144 +94,59 @@ class CodeXPService :
* @param initializeCallback The callback to execute when the plugin is initialized.
*/
private fun initialize(initializeCallback: () -> Unit) {
- if (!codeXPState.hasExecuted) {
- initializeCallback()
- codeXPState.hasExecuted = true
- }
+ val shouldRunCallback = !codeXPState.hasExecuted
- Event.values().forEach { event ->
- if (!codeXPState.eventCounts.containsKey(event)) {
- codeXPState.eventCounts[event] = 0
- }
- }
-
- CodeXPChallengeFactory.createEventDefaultChallenges().forEach { challenge ->
- addChallenge(challenge)
+ if (shouldRunCallback) {
+ initializeCallback()
}
- }
- /**
- * Add a challenge to the list of challenges.
- *
- * @param challenge The challenge to add.
- */
- private fun addChallenge(challenge: CodeXPChallenge) {
- if (!codeXPState.challenges.containsKey(challenge.event)) {
- codeXPState.challenges[challenge.event] = challenge
- }
+ progressEngine.initialize(codeXPState)
}
- /**
- * Increase the event count for a specific event.
- *
- * @param event The event to increase the count for.
- * @param incrementValue The amount to increase the count by.
- */
- private fun increaseEventCount(
- event: Event,
- incrementValue: Long = 1,
+ private fun handleProgressResult(
+ result: CodeXPProgressResult,
+ dataContext: DataContext?,
) {
- codeXPState.eventCounts[event] = codeXPState.eventCounts.getOrDefault(event, 0) + incrementValue
- increaseXP(event.xpValue)
- }
-
- /**
- * Increase the user's XP by a specific amount.
- *
- * @param incrementAmount The amount to increase the user's XP by.
- */
- private fun increaseXP(incrementAmount: Long) {
- val beforeLevelInfo = CodeXPLevel.createLevelInfo(codeXPState.xp)
- codeXPState.xp += incrementAmount
- val currentLevelInfo = CodeXPLevel.createLevelInfo(codeXPState.xp)
-
- if (beforeLevelInfo.level != currentLevelInfo.level && beforeLevelInfo.level != 0 && codeXPState.codeXPConfiguration.showLevelUpNotification) {
- if (codeXPState.codeXPConfiguration.showLevelUpNotification) {
+ result.xpChanges.forEach { change ->
+ if (change.isLevelUp && codeXPState.codeXPConfiguration.showLevelUpNotification) {
when (codeXPState.codeXPConfiguration.notificationType) {
"IntelliJ Notification" -> {
- CodeXPNotificationManager.notifyLevelUp(
+ CodeXPNotificationNotifier.notifyLevelUp(
codeXPState.nickname,
- currentLevelInfo.level,
- currentLevelInfo.totalXPForNextLevel,
+ change.currentLevelInfo.level,
+ change.currentLevelInfo.totalXPForNextLevel,
)
}
"CodeXP Notification" -> {
- messageBus.syncPublisher(CodeXPListener.CODEXP).levelUp(currentLevelInfo)
+ messageBus.syncPublisher(CodeXPListener.CODEXP).levelUp(change.currentLevelInfo, dataContext)
}
}
}
+
+ messageBus.syncPublisher(CodeXPListener.CODEXP).xpUpdated(change.currentLevelInfo)
}
- messageBus.syncPublisher(CodeXPListener.CODEXP).xpUpdated(currentLevelInfo)
- }
+ result.completedChallenge?.let { challenge ->
+ if (codeXPState.codeXPConfiguration.showCompleteChallengeNotification) {
+ when (codeXPState.codeXPConfiguration.notificationType) {
+ "IntelliJ Notification" -> {
+ CodeXPNotificationNotifier.notifyChallengeComplete(
+ challenge,
+ )
+ }
- /**
- * Increase the progress of a challenge.
- *
- * @param event The type of the challenge.
- */
- private fun increaseChallengeProgress(event: Event) {
- codeXPState.challenges[event]?.let { challenge ->
- challenge.progress += 1
-
- if (challenge.progress >= challenge.goal) {
- increaseXP(challenge.rewardXP)
- replaceChallengeWithNew(challenge, event)
-
- if (codeXPState.codeXPConfiguration.showCompleteChallengeNotification) {
- when (codeXPState.codeXPConfiguration.notificationType) {
- "IntelliJ Notification" -> {
- CodeXPNotificationManager.notifyChallengeComplete(
- challenge,
- )
- }
-
- "CodeXP Notification" -> {
- messageBus.syncPublisher(CodeXPListener.CODEXP).challengeCompleted(event, challenge)
- }
+ "CodeXP Notification" -> {
+ messageBus.syncPublisher(CodeXPListener.CODEXP).challengeCompleted(result.event, challenge, dataContext)
}
}
- messageBus
- .syncPublisher(CodeXPListener.CODEXP)
- .challengeUpdated(event, challenge, state.challenges[event])
- } else {
- messageBus.syncPublisher(CodeXPListener.CODEXP).challengeUpdated(event, challenge, null)
}
}
- }
- /**
- * Replace a completed challenge with a new challenge with an increased goal.
- *
- * @param completedChallenge The completed challenge.
- * @param event The type of the completed challenge.
- */
- private fun replaceChallengeWithNew(
- completedChallenge: CodeXPChallenge,
- event: Event,
- ) {
- codeXPState.completedChallenges.add(completedChallenge)
- codeXPState.challenges[event] =
- createNextChallenge(
- completedChallenge,
- )
+ result.updatedChallenge?.let { challenge ->
+ messageBus
+ .syncPublisher(CodeXPListener.CODEXP)
+ .challengeUpdated(result.event, challenge, result.newChallenge)
+ }
}
-
- /**
- * Create a new challenge with an increased goal.
- *
- * @param completedChallenge The completed challenge.
- */
- private fun createNextChallenge(completedChallenge: CodeXPChallenge): CodeXPChallenge =
- CodeXPChallenge(
- event = completedChallenge.event,
- name = completedChallenge.name,
- description = completedChallenge.description,
- rewardXP = completedChallenge.rewardXP + completedChallenge.rewardXPIncrement,
- rewardXPIncrement = completedChallenge.rewardXPIncrement,
- progress = 0,
- goal = completedChallenge.goal + completedChallenge.goalIncrement,
- goalIncrement = completedChallenge.goalIncrement,
- )
}
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 10cfd2b..c6f27ce 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -11,13 +11,16 @@
-
-
-
+ id="CodeXP Dashboard" icon="/icons/toolWindowIcon.svg"/>
+
+ instance="com.github.ilovegamecoding.intellijcodexp.presentation.settings.CodeXPConfigurable"
+ bundle="messages.CodeXPBundle"
+ key="configurable.codexp.display.name"/>
diff --git a/src/main/resources/messages/CodeXPBundle.properties b/src/main/resources/messages/CodeXPBundle.properties
index 58b5379..5b912db 100644
--- a/src/main/resources/messages/CodeXPBundle.properties
+++ b/src/main/resources/messages/CodeXPBundle.properties
@@ -1,4 +1,8 @@
+# suppress inspection "UnusedProperty" for whole file
name=CodeXP
+configurable.codexp.display.name=CodeXP
+notification.group.codexp=CodeXP
+toolwindow.stripe.CodeXP_Dashboard=CodeXP Dashboard
TEXT_%=%
TEXT_+=+
TEXT_CHALLENGES=Challenges
@@ -18,4 +22,4 @@ TEXT_SHOW_GAINED_XP=Show gained xp
TEXT_SHOW_GAINED_XP_DESCRIPTION=Gained XP is only displayed for typing-related tasks (characters, Tab, Backspace, Enter, etc.).
It is not displayed for other tasks such as Build, Run, Debug.
TEXT_CARETS=Caret's
TEXT_EFFECT=Effect
-TEXT_TYPE=Type
\ No newline at end of file
+TEXT_TYPE=Type
diff --git a/src/test/kotlin/com/github/ilovegamecoding/intellijcodexp/CodeXPDomainTest.kt b/src/test/kotlin/com/github/ilovegamecoding/intellijcodexp/CodeXPDomainTest.kt
index a834383..019fa23 100644
--- a/src/test/kotlin/com/github/ilovegamecoding/intellijcodexp/CodeXPDomainTest.kt
+++ b/src/test/kotlin/com/github/ilovegamecoding/intellijcodexp/CodeXPDomainTest.kt
@@ -1,8 +1,10 @@
package com.github.ilovegamecoding.intellijcodexp
+import com.github.ilovegamecoding.intellijcodexp.domain.CodeXPProgressEngine
import com.github.ilovegamecoding.intellijcodexp.enums.Event
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPChallengeFactory
import com.github.ilovegamecoding.intellijcodexp.models.CodeXPLevel
+import com.github.ilovegamecoding.intellijcodexp.models.CodeXPState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
@@ -33,7 +35,7 @@ class CodeXPDomainTest {
fun `default challenges cover all xp events`() {
val challenges = CodeXPChallengeFactory.createEventDefaultChallenges()
val challengeEvents = challenges.map { it.event }.toSet()
- val xpEvents = Event.values().filterNot { it == Event.NONE }.toSet()
+ val xpEvents = Event.entries.filterNot { it == Event.NONE }.toSet()
assertEquals(xpEvents, challengeEvents)
assertEquals(xpEvents.size, challenges.size)
@@ -53,4 +55,59 @@ class CodeXPDomainTest {
assertTrue(challenge.rewardXP > 0)
}
}
+
+ @Test
+ fun `progress engine initializes event counts and active challenges`() {
+ val state = CodeXPState()
+
+ CodeXPProgressEngine().initialize(state)
+
+ assertTrue(state.hasExecuted)
+ Event.entries.forEach { event ->
+ assertEquals(0, state.getEventCount(event))
+ }
+ assertEquals(Event.entries.filterNot { it == Event.NONE }.size, state.challenges.size)
+ }
+
+ @Test
+ fun `progress engine records event xp and challenge progress`() {
+ val state = CodeXPState()
+ val engine = CodeXPProgressEngine()
+ engine.initialize(state)
+
+ val result = engine.recordEvent(state, Event.TYPING)
+
+ assertEquals(2, state.xp)
+ assertEquals(1, state.getEventCount(Event.TYPING))
+ assertEquals(1, state.challenges.getValue(Event.TYPING).progress)
+ assertEquals(Event.TYPING, result.event)
+ assertEquals(1, result.xpChanges.size)
+ assertEquals(
+ 2,
+ result.xpChanges
+ .single()
+ .currentLevelInfo.xpIntoCurrentLevel,
+ )
+ }
+
+ @Test
+ fun `progress engine completes challenge and creates next challenge`() {
+ val state = CodeXPState()
+ val engine = CodeXPProgressEngine()
+ engine.initialize(state)
+ val challenge = state.challenges.getValue(Event.CUT)
+ challenge.progress = challenge.goal - 1
+
+ val result = engine.recordEvent(state, Event.CUT)
+
+ assertEquals(101, state.xp)
+ assertEquals(1, state.completedChallenges.size)
+ assertEquals(challenge.id, state.completedChallenges.single().id)
+ assertEquals(0, state.challenges.getValue(Event.CUT).progress)
+ assertEquals(challenge.goal + challenge.goalIncrement, state.challenges.getValue(Event.CUT).goal)
+ assertEquals(challenge.rewardXP + challenge.rewardXPIncrement, state.challenges.getValue(Event.CUT).rewardXP)
+ assertEquals(challenge.id, result.completedChallenge?.id)
+ assertEquals(state.challenges.getValue(Event.CUT).id, result.newChallenge?.id)
+ assertEquals(2, result.xpChanges.size)
+ }
}