背景
经常沉迷于写代码,几个小时一动不动对身体不好。
有一个插件是超越妹妹定时提醒该休息啦,但是只是展示超越妹妹的照片,没有强制效果。
于是写了一个IDEA吸猫插件,使用番茄工作法,工作一段时间后,强制休息一会儿。
休息期间不能继续使用IDEA,除非直接杀死IDEA进程后重新启动IDEA。
介绍
https://plugins.jetbrains.com/plugin/18706-kitty
The cute kitty remind you to take a break after long time work.
- In the Tools menu, open Kitty Plugin Setting dialog.
- Set work time and rest time, and enable it.
- When the work time elapsed, you have to take a break.
可爱的喵咪提醒你休息一会儿。
- 在Tools菜单中,打开吸猫插件设置对话框。
- 设置工作时间、休息时间,并启用功能。
- 工作时间结束后,你必须休息一会儿。
项目地址:https://github.com/power721/kitty-idea-plugin
开发过程
项目创建
从JetBrain官方仓库的plugin template创建项目。
https://github.com/JetBrains/intellij-platform-plugin-template
点击右上角的Use this template按钮,GitHub自动fork创建plugin项目。
git clone代码到本地,导入项目到IDEA。
配置plugin.xml
<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html -->
<idea-plugin>
<id>cn.har01d.plugin.kitty</id> <!-- group ID -->
<name>Kitty</name> <!-- 插件名称 -->
<vendor url="https://har01d.cn" email="power0721@gmail.com">Har01d</vendor> <!-- 开发者信息,必须包含邮箱 -->
<version>0.0.1</version> <!-- 版本 -->
<depends>com.intellij.modules.platform</depends>
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="cn.har01d.plugin.kitty.services.KittyApplicationService"/> <!-- 定义一个全局的服务 -->
<notificationGroup id="Kitty Notifications" displayType="BALLOON"/> <!-- 声明插件的通知组 -->
</extensions>
<actions>
<action id="kitty_setting_id" class="cn.har01d.plugin.kitty.actions.KittySettingAction"
description="Kitty setting" icon="/icons/kitty.svg"> <!-- 定义一个动作,用来打开配置对话框 -->
<synonym text="Cat Work Time"/> <!-- 定义额外的搜索关键词,通过search anywhere可以搜到此动作 -->
<add-to-group group-id="ToolsMenu" anchor="last"/> <!-- 将加入到Tools菜单栏 -->
<add-to-group group-id="MainToolBar" anchor="last"/> <!-- 加入到工具栏,上面定义了图标 -->
</action>
</actions>
</idea-plugin>
Action类
package cn.har01d.plugin.kitty.actions
import cn.har01d.plugin.kitty.MyBundle.message
import cn.har01d.plugin.kitty.services.KittyApplicationService
import cn.har01d.plugin.kitty.ui.SettingDialog
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
class KittySettingAction : AnAction(message("name")) {
override fun actionPerformed(e: AnActionEvent) {
// 首先取出全局应用服务
val applicationService = ApplicationManager.getApplication().getService(KittyApplicationService::class.java)
// 创建配置对话框
val dialog = SettingDialog(applicationService)
// 显示对话框,并等待用户点击确定按钮
if (dialog.showAndGet()) {
// 保存配置
applicationService.saveSettings()
// 根据配置开始工作
applicationService.start()
}
}
}
配置数据类
package cn.har01d.plugin.kitty.model
import com.intellij.ide.util.PropertyName
data class SettingData(
// 注解@PropertyName是必须的!否则无法保存配置。
@PropertyName("kitty.enabled") var enabled: Boolean = false, // 是否开启功能
@PropertyName("kitty.workTime") var workTime: Int = 60, // 工作时间,默认60分钟
@PropertyName("kitty.restTime") var restTime: Int = 5, // 休息时间,默认5分钟
@PropertyName("kitty.remoteImages") var remoteImages: Boolean = false, // 是否使用网络图片
@PropertyName("kitty.imageApi") var imageApi: String = "https://cataas.com/cat" // 网络图片API地址
)
全局应用服务类
package cn.har01d.plugin.kitty.services
import cn.har01d.plugin.kitty.MyBundle.message
import cn.har01d.plugin.kitty.model.SettingData
import cn.har01d.plugin.kitty.ui.KittyDialog
import com.intellij.ide.util.PropertiesComponent
import com.intellij.notification.NotificationBuilder
import com.intellij.notification.NotificationType
import com.intellij.notification.Notifications
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
class KittyApplicationService {
// PropertiesComponent用来存储插件的配置
private val propertiesComponent: PropertiesComponent = PropertiesComponent.getInstance()
// Notification Group Name定义在plugin.xml文件中
private val notificationGroup: NotificationGroup =
NotificationGroupManager.getInstance().getNotificationGroup("Kitty Notifications")
// 使用定时器
private val scheduler = Executors.newScheduledThreadPool(2)
// 配置信息
private val setting: SettingData = SettingData()
// 休息对话框
private val dialog: KittyDialog = KittyDialog(this)
// 运行状态
var status: Status = Status.IDLE
// 用于取消工作定时任务
private var workFuture: ScheduledFuture<*>? = null
// 用于取消休息倒计时定时任务
private var restFuture: ScheduledFuture<*>? = null
// 上次开始工作时间,用于计算下次休息时间
private var lastWorkTime: Long = 0
// 休息倒计时
private var countdown: Int = 60
init {
// 读取插件配置
propertiesComponent.loadFields(setting)
// 启动任务
start()
}
fun getSettings(): SettingData = setting
fun saveSettings() {
// 保存插件配置
propertiesComponent.saveFields(setting)
}
fun start() {
// 取消前面的定时任务
restFuture?.cancel(true)
workFuture?.cancel(true)
status = Status.IDLE
if (setting.enabled) {
work()
}
}
// 获取下一次的休息时间
fun getNextRestTime(): LocalDateTime {
return if (lastWorkTime > 0) {
val time = lastWorkTime + TimeUnit.MINUTES.toMillis(setting.workTime.toLong())
val instant = Instant.ofEpochSecond(time / 1000)
LocalDateTime.ofInstant(instant, ZoneOffset.systemDefault())
} else {
LocalDateTime.now().plusMinutes(setting.workTime.toLong())
}
}
// 工作阶段,只是启动定时任务
private fun work() {
status = Status.WORK
// 在IDEA中发布通知消息
notify(message("message.start.work") + " ${LocalDateTime.now()}")
// 启动定时任务
workFuture = scheduler.schedule(this::rest, getDelay(), TimeUnit.MILLISECONDS)
}
// 休息阶段,显示图片,开启倒计时
private fun rest() {
status = Status.REST
notify(message("message.rest.now") + " ${LocalDateTime.now()}")
countdown = setting.restTime * 60
// 每秒一次倒计时
restFuture = scheduler.scheduleAtFixedRate(this::countdown, 0, 1, TimeUnit.SECONDS)
// 显示倒计时
dialog.setTime(countdownToString(countdown))
// 刷新图片并显示对话框
dialog.refreshAndShow()
}
private fun countdown() {
countdown--
dialog.setTime(countdownToString(countdown))
if (countdown <= 0) { // 倒计时结束
// 关闭对话框
dialog.dispose()
// 取消倒计时任务
restFuture?.cancel(true)
// 开启下一轮工作
work()
}
}
// 在IDEA中发布通知消息
private fun notify(message: String) {
val notification: Notification = notificationGroup.createNotification(message, NotificationType.INFORMATION)
notification.notify(null)
}
// 获取延迟时间
private fun getDelay(): Long {
val now = System.currentTimeMillis()
val delay = TimeUnit.MINUTES.toMillis(setting.workTime.toLong())
val targetTime = lastWorkTime + delay
return if (targetTime > now) {
targetTime - now
} else {
lastWorkTime = now
delay
}
}
private fun countdownToString(countdown: Int): String {
val minutes = countdown / 60
val seconds = countdown % 60
return String.format("%02d:%02d", minutes, seconds)
}
}
状态枚举类
enum class Status {
IDLE, // 未开启功能,空闲中
WORK, // 工作中
REST // 休息中
}
配置对话框
package cn.har01d.plugin.kitty.ui
import cn.har01d.plugin.kitty.MyBundle.message
import cn.har01d.plugin.kitty.model.SettingData
import cn.har01d.plugin.kitty.services.KittyApplicationService
import cn.har01d.plugin.kitty.services.Status
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.layout.CellBuilder
import com.intellij.ui.layout.panel
import com.intellij.ui.layout.selected
class SettingDialog(private val service: KittyApplicationService) : DialogWrapper(true) {
private val setting: SettingData = service.getSettings()
private lateinit var remoteCheckbox: CellBuilder<JBCheckBox>
init {
title = message("setting.dialog.name")
setSize(400, 260)
init()
}
// 使用IDEA的UI框架创建Dialog Panel
override fun createCenterPanel() = panel {
row {
checkBox(message("setting.enabled"), setting::enabled, comment = message("setting.enabled.tip"))
}
titledRow(message("setting.time")) {
row(message("setting.work.time")) {
cell {
spinner(setting::workTime, minValue = 5, maxValue = 120, step = 5)
label(message("setting.minute"))
}
}
row(message("setting.rest.time")) {
cell {
spinner(setting::restTime, minValue = 1, maxValue = 20, step = 1)
label(message("setting.minute"))
}
}
}
titledRow(message("setting.image.source")) {
row {
comment(message("setting.image.source.tip"))
}
row {
remoteCheckbox =
checkBox(
message("setting.remote.image"),
setting::remoteImages,
comment = message("setting.remote.image.tip")
)
}
row(message("setting.images.url")) {
textField(setting::imageApi).enableIf(remoteCheckbox.selected)
}
}
if (service.status == Status.WORK) {
row {
label(message("message.rest.time") + service.getNextRestTime().toString())
}
}
}
}
图片显示
package cn.har01d.plugin.kitty.ui
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.image.BufferedImage
import javax.swing.JPanel
class ImagePanel(private var image: BufferedImage) : JPanel() {
init {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
fun setImage(image: BufferedImage) {
this.image = image
invalidate()
repaint()
}
override fun getPreferredSize(): Dimension {
return Dimension(this.image.getWidth(this), this.image.getHeight(this))
}
override fun paint(g: Graphics) {
g.drawImage(image, 0, 0, this)
}
}
休息对话框
package cn.har01d.plugin.kitty.ui
import cn.har01d.plugin.kitty.MyBundle.message
import cn.har01d.plugin.kitty.model.SettingData
import cn.har01d.plugin.kitty.services.KittyApplicationService
import cn.har01d.plugin.kitty.services.Status
import com.intellij.ui.layout.CellBuilder
import com.intellij.ui.layout.panel
import java.awt.event.*
import java.awt.image.BufferedImage
import java.net.URL
import java.util.concurrent.ThreadLocalRandom
import javax.imageio.ImageIO
import javax.swing.JComponent
import javax.swing.JDialog
import javax.swing.JLabel
import javax.swing.KeyStroke
class KittyDialog(private val service: KittyApplicationService) : JDialog() {
private val setting: SettingData = service.getSettings()
private lateinit var timerLabel: CellBuilder<JLabel>
private lateinit var remindLabel: CellBuilder<JLabel>
private lateinit var imagePanel: ImagePanel
init {
title = message("kitty.dialog.name")
isModal = true
defaultCloseOperation = DO_NOTHING_ON_CLOSE
setLocation(500, 400)
contentPane = panel {
row {
placeholder().withLargeLeftGap()
label(message("message.remind"))
remindLabel = label(getMessage())
}
row {
val image = getImage()
resize(image)
imagePanel = ImagePanel(image)
component(imagePanel)
}
row {
placeholder().withLargeLeftGap()
label(message("setting.rest.time"))
timerLabel = label("05:00")
}
}
addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) {
onCancel()
}
})
imagePanel.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent?) {
refresh()
}
})
rootPane.registerKeyboardAction(
{ onCancel() },
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT
)
}
fun setTime(text: String) {
timerLabel.component.text = text
}
fun refresh() {
val image = getImage()
imagePanel.setImage(image)
resize(image)
}
fun refreshAndShow() {
refresh()
remindLabel.component.text = getMessage()
isVisible = true
}
private fun resize(image: BufferedImage) {
setSize(image.width, image.height + 120)
}
private fun getImage(): BufferedImage {
return if (setting.remoteImages && setting.imageApi.isNotEmpty()) {
// 通过URL读取网络图片
ImageIO.read(URL(setting.imageApi))
} else {
val id = ThreadLocalRandom.current().nextInt(20) + 1
val input = KittyDialog::class.java.classLoader.getResourceAsStream("images/cat$id.jpeg")
// 读取本地classpath里面的图片
ImageIO.read(input)
}
}
private fun getMessage(): String {
return when (ThreadLocalRandom.current().nextInt(4)) {
1 -> message("message.move")
2 -> message("message.drink")
3 -> message("message.breathe")
else -> message("message.rest")
}
}
private fun onCancel() {
// 休息中不允许关闭对话框
if (service.status != Status.REST) {
dispose()
}
}
}
测试插件
项目目录.run下面创建了Gradle任务,其中有Run IDE with Plugin.run.xml可以启动一个新的IDEA,并安装了开发的插件。
构建插件
./gradlew buildPlugin
生成的插件文件在build/distributions/目录下。
发布插件
官方推荐对插件进行签名,但是是可选的,暂时忽略。
使用JetBrain账号登录 https://plugins.jetbrains.com/
右上角菜单项点击Upload Plugin。
选择插件zip文件,选择License和Tag即可。
自动发布插件
首先去JetBrains网站生成Token。
https://plugins.jetbrains.com/author/me/tokens
然后在GitHub仓库配置环境变量。
在仓库配置页面 https://github.com/username/idea-plugin-name/settings/secrets/actions
添加一个Repository secret。
名称是PUBLISH_TOKEN
值是上面生成的Token。