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) + } }