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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/Animated.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface Animated {
fun onOccasion()
fun onStartObserving()
fun onCursorMove(isOnLeftSide: Boolean)
fun onIndexingStart()
fun onIndexingFinish()

data class Params(
val atlasPath: String,
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/AnimatedStatusBarWidget.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package dev.stillya.vpet

import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Computable
import com.intellij.openapi.wm.IconWidgetPresentation
import com.intellij.openapi.wm.StatusBar
import com.intellij.openapi.wm.StatusBarWidgetFactory
Expand Down Expand Up @@ -152,6 +155,13 @@ class AnimatedStatusBarWidget(
)

curFrames = iconRenderer.render()

// Project listeners don't fire retroactively, so catch an already running indexing pass
val indexing = ApplicationManager.getApplication()
.runReadAction(Computable { DumbService.isDumb(project) })
if (indexing) {
animation.onIndexingStart()
}
}

private fun startCursorTracking() {
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/AnimationEventListener.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface AnimationEventListener {
FAIL,
SUCCESS,
PROGRESS,
INDEXING_START,
INDEXING_FINISH,
}

companion object {
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/IconRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ interface IconRenderer {
): AnimationContext

fun setFlipped(flipped: Boolean)
fun lockFrame()
fun unlockFrame()
}
65 changes: 54 additions & 11 deletions src/main/kotlin/dev/stillya/vpet/graphics/DefaultIconRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dev.stillya.vpet.IconRenderer
import dev.stillya.vpet.animation.Animation
import dev.stillya.vpet.animation.AnimationState
import dev.stillya.vpet.animation.INFINITE
import dev.stillya.vpet.graphics.effect.SleepEffect
import dev.stillya.vpet.graphics.effect.SnowflakeEffect
import dev.stillya.vpet.settings.VPetSettings
import java.awt.Image
Expand All @@ -29,11 +30,15 @@ class DefaultIconRenderer(project: Project) : IconRenderer {

@Volatile
private var isFlipped: Boolean = false

@Volatile
private var locked: Boolean = false
private val currentLoopCount: AtomicInteger = AtomicInteger(0)

private val scaleValue: Double = 1.2
private val verticalOffset: Int = -8
private var effect: Effect? = null
private var snowEffect: Effect? = null
private var sleepEffect: Effect? = null
private val epochManager = AnimationEpochManager()
private val renderCache = mutableMapOf<String, List<Icon>>()

Expand All @@ -58,6 +63,29 @@ class DefaultIconRenderer(project: Project) : IconRenderer {
}

override fun render(): List<Icon> {
val frames = if (locked) lockedFrame() else renderInternal()
if (activeEffect == EffectKind.NONE) return frames

// An effect animates inside paintIcon, and the widget's icon flow goes through
// distinctUntilChanged, so only a distinct instance triggers the next repaint
return frames.map { object : Icon by it {} }
}

private fun lockedFrame(): List<Icon> {
val frame = currentAnimation?.let { doRender(it).firstOrNull() }
?: renderInternal().firstOrNull()
return frame?.let { listOf(it) } ?: emptyList()
}

override fun lockFrame() {
locked = true
}

override fun unlockFrame() {
locked = false
}

private fun renderInternal(): List<Icon> {
currentAnimation?.let { current ->
if (!validateAnimation(current)) {
log.trace("Animation '${current.name}' no longer valid, finding next")
Expand Down Expand Up @@ -198,18 +226,33 @@ class DefaultIconRenderer(project: Project) : IconRenderer {
x: Int,
y: Int
) {
if (settings.xmasModeEnabled) {
if (effect == null) {
effect = SnowflakeEffect(scaledWidth, scaledHeight)
}
val g2d = g.create() as java.awt.Graphics2D
g2d.translate(x, y)
val animState = currentAnimation?.state ?: AnimationState.IDLE
effect?.apply(g2d, animState)
g2d.dispose()
}
val effect = resolveEffect(scaledWidth, scaledHeight)
if (effect != null && !effect.overSprite) paintEffect(effect, g, x, y)
super.paintIcon(c, g, x, y + verticalOffset)
if (effect != null && effect.overSprite) paintEffect(effect, g, x, y)
}
}
}

private enum class EffectKind { NONE, SLEEP, SNOW }

private val activeEffect: EffectKind
get() = when {
locked -> EffectKind.SLEEP
settings.xmasModeEnabled -> EffectKind.SNOW
else -> EffectKind.NONE
}

private fun resolveEffect(width: Int, height: Int): Effect? = when (activeEffect) {
EffectKind.SLEEP -> sleepEffect ?: SleepEffect(width, height).also { sleepEffect = it }
EffectKind.SNOW -> snowEffect ?: SnowflakeEffect(width, height).also { snowEffect = it }
EffectKind.NONE -> null
}

private fun paintEffect(effect: Effect, g: java.awt.Graphics, x: Int, y: Int) {
val g2d = g.create() as java.awt.Graphics2D
g2d.translate(x, y)
effect.apply(g2d, currentAnimation?.state ?: AnimationState.IDLE)
g2d.dispose()
}
}
2 changes: 2 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/graphics/Effect.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ import java.awt.Graphics2D

interface Effect {
fun apply(g: Graphics2D, state: AnimationState)

val overSprite: Boolean get() = false
}
64 changes: 64 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/graphics/effect/SleepEffect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package dev.stillya.vpet.graphics.effect

import com.intellij.ui.JBColor
import dev.stillya.vpet.animation.AnimationState
import dev.stillya.vpet.graphics.Effect
import java.awt.Color
import java.awt.Font
import java.awt.Graphics2D
import java.awt.RenderingHints
import kotlin.math.roundToInt

class SleepEffect(
private val width: Int,
private val height: Int,
private val clock: () -> Long = System::currentTimeMillis
) : Effect {
override val overSprite = true

private val startMs = clock()

companion object {
internal const val CYCLE_MS = 2700L
private const val COUNT = 3

private const val BASE_X = 0.48f
private const val DRIFT_X = 0.24f
private const val BASE_Y = 0.68f
private const val TOP_Y = 0.18f
private const val MIN_SIZE = 0.16f
private const val MAX_SIZE = 0.26f

private const val MAX_ALPHA = 230f
private const val FADE_IN = 0.2f
private const val FADE_OUT = 0.45f

private val Z_LIGHT = Color(0x33, 0x33, 0x4D)
private val Z_DARK = Color(0xE0, 0xE0, 0xF0)
}

override fun apply(g: Graphics2D, state: AnimationState) {
val elapsed = ((clock() - startMs) % CYCLE_MS) / CYCLE_MS.toFloat()
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)

for (i in 0 until COUNT) {
drawZ(g, (elapsed + i.toFloat() / COUNT) % 1f)
}
}

private fun drawZ(g: Graphics2D, phase: Float) {
val fade = minOf(phase / FADE_IN, (1f - phase) / FADE_OUT, 1f)
if (fade <= 0f) return

val alpha = (MAX_ALPHA * fade).roundToInt()
g.color = JBColor(withAlpha(Z_LIGHT, alpha), withAlpha(Z_DARK, alpha))
g.font = Font(Font.SANS_SERIF, Font.BOLD, (height * (MIN_SIZE + (MAX_SIZE - MIN_SIZE) * phase)).roundToInt())
g.drawString(
"z",
(width * (BASE_X + DRIFT_X * phase)).roundToInt(),
(height * (BASE_Y - (BASE_Y - TOP_Y) * phase)).roundToInt()
)
}

private fun withAlpha(color: Color, alpha: Int) = Color(color.red, color.green, color.blue, alpha)
}
15 changes: 15 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/listener/IndexingEventListener.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.stillya.vpet.listener

import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.Project
import dev.stillya.vpet.AnimationEventListener

class IndexingEventListener(private val project: Project) : DumbService.DumbModeListener {

override fun enteredDumbMode() = publish(AnimationEventListener.AnimationEvent.INDEXING_START)

override fun exitDumbMode() = publish(AnimationEventListener.AnimationEvent.INDEXING_FINISH)

private fun publish(event: AnimationEventListener.AnimationEvent) =
project.messageBus.syncPublisher(AnimationEventListener.TOPIC).onEvent(event)
}
28 changes: 28 additions & 0 deletions src/main/kotlin/dev/stillya/vpet/pet/PetAnimated.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class PetAnimated(
private var animationPlayer: AnimationPlayer = AnimationPlayer(bridges)

private var isObserving = AtomicBoolean(false)
private var isSleeping = AtomicBoolean(false)

private var cachedAnimationKey: Pair<String, Int>? = null
private var cachedAnimation: Animation? = null
Expand Down Expand Up @@ -81,6 +82,8 @@ class PetAnimated(
?: throw IllegalArgumentException("Atlas not found")
image = loadImage(params.imgPath)

wakeUp()

val context = renderer.createAnimationContext(AnimationTrigger.IDLE_BEHAVIOR)
log.trace("Starting initial transition to IDLE state")
val idleSequence = transitionMatrix.transitionTo(currentState, AnimationState.IDLE)
Expand Down Expand Up @@ -170,6 +173,7 @@ class PetAnimated(

override fun onFail() {
log.trace("BUILD FAILED - Transitioning to FAILED")
wakeUp()
exitObservingMode()
val sequence = transitionMatrix.transitionTo(currentState, AnimationState.FAILED)
if (sequence.first.steps.isNotEmpty()) {
Expand All @@ -182,6 +186,7 @@ class PetAnimated(

override fun onSuccess() {
log.trace("BUILD SUCCESS - Transitioning to CELEBRATING")
wakeUp()
exitObservingMode()
val sequence = transitionMatrix.transitionTo(currentState, AnimationState.CELEBRATING)
if (sequence.first.steps.isNotEmpty()) {
Expand All @@ -194,6 +199,7 @@ class PetAnimated(

override fun onProgress() {
log.trace("BUILD START - Transitioning to RUNNING")
wakeUp()
exitObservingMode()
val sequence = transitionMatrix.transitionTo(currentState, AnimationState.RUNNING)
if (sequence.first.steps.isNotEmpty()) {
Expand All @@ -206,6 +212,7 @@ class PetAnimated(

override fun onCompleted() {
log.trace("BUILD COMPLETED - Transitioning to CELEBRATING")
wakeUp()
exitObservingMode()
val sequence = transitionMatrix.transitionTo(currentState, AnimationState.CELEBRATING)
if (sequence.first.steps.isNotEmpty()) {
Expand All @@ -218,6 +225,7 @@ class PetAnimated(

override fun onOccasion() {
log.trace("USER CLICK - Transitioning to OCCASION")
wakeUp()
exitObservingMode()
val sequence = transitionMatrix.transitionTo(currentState, AnimationState.OCCASION)
if (sequence.first.steps.isNotEmpty()) {
Expand All @@ -228,6 +236,22 @@ class PetAnimated(
}
}

override fun onIndexingStart() {
if (isSleeping.compareAndSet(false, true)) {
log.trace("INDEXING STARTED - locking frame")
renderer.lockFrame()
}
}

override fun onIndexingFinish() = wakeUp()

private fun wakeUp() {
if (isSleeping.compareAndSet(true, false)) {
log.trace("Waking up - unlocking frame")
renderer.unlockFrame()
}
}

private fun exitObservingMode() {
if (isObserving.compareAndSet(true, false)) {
log.trace("Exiting OBSERVING mode")
Expand All @@ -238,6 +262,10 @@ class PetAnimated(
}

override fun onStartObserving() {
if (isSleeping.get()) {
return
}

if (currentState == AnimationState.IDLE && isObserving.compareAndSet(false, true)) {
log.trace("INACTIVITY - Starting OBSERVING mode")
observingStartTimeMs = System.currentTimeMillis()
Expand Down
23 changes: 19 additions & 4 deletions src/main/kotlin/dev/stillya/vpet/service/AnimationEventService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,28 @@ class AnimationEventService(private val project: Project) : AnimationEventListen
get() = project.service<Animated>()

override fun onEvent(event: AnimationEventListener.AnimationEvent) {
ActivityTracker.getInstance(project).notifyActivity()
when (event) {
AnimationEventListener.AnimationEvent.FAIL -> animated.onFail()
AnimationEventListener.AnimationEvent.SUCCESS -> animated.onSuccess()
AnimationEventListener.AnimationEvent.PROGRESS -> animated.onProgress()
AnimationEventListener.AnimationEvent.FAIL -> {
notifyActivity()
animated.onFail()
}

AnimationEventListener.AnimationEvent.SUCCESS -> {
notifyActivity()
animated.onSuccess()
}

AnimationEventListener.AnimationEvent.PROGRESS -> {
notifyActivity()
animated.onProgress()
}

AnimationEventListener.AnimationEvent.INDEXING_START -> animated.onIndexingStart()
AnimationEventListener.AnimationEvent.INDEXING_FINISH -> animated.onIndexingFinish()
}
}

private fun notifyActivity() = ActivityTracker.getInstance(project).notifyActivity()
}

@Service(Service.Level.PROJECT)
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
<listener class="dev.stillya.vpet.listener.BuildEventListener"
topic="com.intellij.execution.ExecutionListener"/>
</projectListeners>
<projectListeners>
<listener class="dev.stillya.vpet.listener.IndexingEventListener"
topic="com.intellij.openapi.project.DumbService$DumbModeListener"/>
</projectListeners>
<projectListeners>
<listener class="dev.stillya.vpet.service.AnimationEventService"
topic="dev.stillya.vpet.AnimationEventListener"/>
Expand Down
Loading
Loading