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
149 changes: 149 additions & 0 deletions src/main/kotlin/com/codingtestkit/service/TimerService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.codingtestkit.service

import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project

/**
* 카운트다운 타이머·스톱워치 상태 서비스 (이슈 #16).
* 타이머 탭, 문제 탭 하단 타이머 바, 상태바 위젯이 같은 상태를 공유·관찰한다.
* Swing Timer 기반이므로 모든 호출과 콜백은 EDT에서 일어난다.
*/
@Service(Service.Level.PROJECT)
class TimerService {

var totalMs: Long = DEFAULT_TOTAL_MS
private set
var remainingMs: Long = DEFAULT_TOTAL_MS
private set
var isRunning: Boolean = false
private set

/** 중간에 일시정지된 상태 (시작/재개 라벨, 필드 활성화 판단용) */
val isPausedMidway: Boolean
get() = !isRunning && remainingMs in 1 until totalMs

private var endTime: Long = 0
private var ticker: javax.swing.Timer? = null
private val listeners = mutableListOf<() -> Unit>()
private val timeUpListeners = mutableListOf<() -> Unit>()

/** 총 시간 설정 (남은 시간도 함께 리셋). 실행 중에는 무시. */
fun setTotal(ms: Long) {
if (isRunning) return
totalMs = ms.coerceAtLeast(0)
remainingMs = totalMs
notifyListeners()
}

/** 시작 또는 재개. 남은 시간이 없으면 총 시간부터 다시 시작. */
fun start() {
if (isRunning) return
if (remainingMs <= 0) remainingMs = totalMs
if (remainingMs <= 0) return
isRunning = true
endTime = System.currentTimeMillis() + remainingMs
ticker = javax.swing.Timer(33) {
remainingMs = (endTime - System.currentTimeMillis()).coerceAtLeast(0)
if (remainingMs <= 0) finish() else notifyListeners()
}.apply { start() }
notifyListeners()
}

fun pause() {
if (!isRunning) return
remainingMs = (endTime - System.currentTimeMillis()).coerceAtLeast(0)
stopTicker()
notifyListeners()
}

fun reset() {
stopTicker()
remainingMs = totalMs
notifyListeners()
}

private fun finish() {
stopTicker()
remainingMs = 0
notifyListeners()
timeUpListeners.toList().forEach { it() }
}

private fun stopTicker() {
ticker?.stop()
ticker = null
isRunning = false
}

// ─── 스톱워치 (스톱워치 탭과 문제 탭 하단 바가 공유) ───

var isStopwatchRunning: Boolean = false
private set

private var swStartTime: Long = 0
private var swAccumulatedMs: Long = 0
private var swTicker: javax.swing.Timer? = null

val stopwatchElapsedMs: Long
get() = if (isStopwatchRunning) swAccumulatedMs + (System.currentTimeMillis() - swStartTime)
else swAccumulatedMs

fun stopwatchStart() {
if (isStopwatchRunning) return
isStopwatchRunning = true
swStartTime = System.currentTimeMillis()
swTicker = javax.swing.Timer(33) { notifyListeners() }.apply { start() }
notifyListeners()
}

fun stopwatchPause() {
if (!isStopwatchRunning) return
swAccumulatedMs += System.currentTimeMillis() - swStartTime
stopStopwatchTicker()
notifyListeners()
}

fun stopwatchReset() {
stopStopwatchTicker()
swAccumulatedMs = 0
notifyListeners()
}

private fun stopStopwatchTicker() {
swTicker?.stop()
swTicker = null
isStopwatchRunning = false
}

// ─── 리스너 ───

fun addListener(listener: () -> Unit) { listeners.add(listener) }
fun removeListener(listener: () -> Unit) { listeners.remove(listener) }

/** 시간 종료 시 1회 호출되는 리스너 (알림음·다이얼로그 등) */
fun addTimeUpListener(listener: () -> Unit) { timeUpListeners.add(listener) }

private fun notifyListeners() { listeners.toList().forEach { it() } }

/** 남은 시간 표기: h:mm:ss 또는 mm:ss (문제 탭 바, 상태바 공용) */
fun formatRemaining(): String = formatHms(remainingMs)

/** 스톱워치 경과 시간 표기: h:mm:ss 또는 mm:ss */
fun formatStopwatch(): String = formatHms(stopwatchElapsedMs)

private fun formatHms(ms: Long): String {
val totalSec = ms / 1000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) String.format("%d:%02d:%02d", h, m, s)
else String.format("%02d:%02d", m, s)
}

companion object {
private const val DEFAULT_TOTAL_MS = 30 * 60 * 1000L

fun getInstance(project: Project): TimerService =
project.getService(TimerService::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codingtestkit.ui

import com.codingtestkit.service.CodingTestKitActionService
import com.codingtestkit.service.TimerService
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.CustomStatusBarWidget
import com.intellij.openapi.wm.StatusBar
Expand Down Expand Up @@ -36,16 +37,23 @@ class CodingTestKitStatusBarWidget(private val project: Project) : CustomStatusB

override fun ID() = CodingTestKitStatusBarFactory.WIDGET_ID

// 타이머는 33ms 간격으로 틱하므로 표시 문자열이 바뀔 때만 위젯 갱신
private var lastText = ""

private val timerListener: () -> Unit = { updateText() }

override fun install(statusBar: StatusBar) {
this.statusBar = statusBar
CodingTestKitActionService.getInstance(project).onStatusChanged = {
updateText()
statusBar.updateWidget(ID())
}
// 타이머 남은 시간을 어떤 탭/레이아웃에서든 볼 수 있게 상태바에 표시 (이슈 #16)
TimerService.getInstance(project).addListener(timerListener)
updateText()
}

override fun dispose() {
TimerService.getInstance(project).removeListener(timerListener)
statusBar = null
}

Expand All @@ -56,15 +64,28 @@ class CodingTestKitStatusBarWidget(private val project: Project) : CustomStatusB
val platform = service.currentPlatform
val id = service.currentProblemId

component.text = when {
val base = when {
platform == null -> "CTK"
id == null -> "CTK: $platform"
else -> "CTK: $platform #$id"
}

// 실행 중이거나 중간에 멈춘/종료된 타이머만 표시 (초기 상태에서는 숨김)
val timer = TimerService.getInstance(project)
val timerText = if (timer.isRunning || (timer.totalMs > 0 && timer.remainingMs < timer.totalMs)) {
" ⏱ ${timer.formatRemaining()}"
} else ""

val text = base + timerText
if (text == lastText) return
lastText = text

component.text = text
component.toolTipText = when {
platform == null -> "CodingTestKit"
id == null -> "CodingTestKit: $platform"
else -> "CodingTestKit: $platform #$id"
}
statusBar?.updateWidget(ID())
}
}
3 changes: 2 additions & 1 deletion src/main/kotlin/com/codingtestkit/ui/MainPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.codingtestkit.ui
import com.codingtestkit.service.CodingTestKitActionService
import com.codingtestkit.service.I18n
import com.codingtestkit.service.ProblemFileManager
import com.codingtestkit.service.TimerService
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.Disposable
Expand All @@ -22,7 +23,7 @@ class MainPanel(private val project: Project) : JPanel(BorderLayout()), Disposab
private val problemPanel = ProblemPanel(project)
private val testPanel = TestPanel(project)
private val templatePanel = TemplatePanel(project).also { Disposer.register(this, it) }
private val timerPanel = TimerPanel()
private val timerPanel = TimerPanel(TimerService.getInstance(project))
private val referencePanel = ReferencePanel()
private val settingsPanel = SettingsPanel(project)
private var lastLoadedFolder: String? = null
Expand Down
100 changes: 100 additions & 0 deletions src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,44 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) {
font = font.deriveFont(JBUI.scaleFontSize(11f).toFloat())
iconTextGap = 0
}
// ─── 하단 타이머 바 (이슈 #16): 문제를 보면서 타이머·스톱워치 확인·조작 ───
private val timerService = TimerService.getInstance(project)
private val timerBarLabel = JLabel("30:00", AllIcons.Vcs.History, SwingConstants.LEFT).apply {
font = Font("JetBrains Mono", Font.BOLD, JBUI.scale(13))
toolTipText = I18n.t("타이머", "Timer")
}
private val timerBarToggle = JButton(AllIcons.Actions.Execute).apply {
toolTipText = I18n.t("타이머 시작/일시정지", "Start/pause timer")
preferredSize = Dimension(JBUI.scale(26), JBUI.scale(24))
}
private val timerBarReset = JButton(AllIcons.Actions.Restart).apply {
toolTipText = I18n.t("타이머 초기화", "Reset timer")
preferredSize = Dimension(JBUI.scale(26), JBUI.scale(24))
}
// 남은 시간 비율을 보여주는 얇은 프로그레스 바 (바 상단에 배치)
private val timerBarProgress = JProgressBar(0, 1000).apply {
isStringPainted = false
border = JBUI.Borders.empty()
preferredSize = Dimension(0, JBUI.scale(3))
value = 1000
}
private val swBarLabel = JLabel("00:00", AllIcons.Debugger.Db_muted_breakpoint, SwingConstants.LEFT).apply {
font = Font("JetBrains Mono", Font.BOLD, JBUI.scale(13))
toolTipText = I18n.t("스톱워치", "Stopwatch")
}
private val swBarToggle = JButton(AllIcons.Actions.Execute).apply {
toolTipText = I18n.t("스톱워치 시작/일시정지", "Start/pause stopwatch")
preferredSize = Dimension(JBUI.scale(26), JBUI.scale(24))
}
private val swBarReset = JButton(AllIcons.Actions.Restart).apply {
toolTipText = I18n.t("스톱워치 초기화", "Reset stopwatch")
preferredSize = Dimension(JBUI.scale(26), JBUI.scale(24))
}
private var timerBarLastText = ""
private var timerBarLastRunning = false
private var swBarLastText = ""
private var swBarLastRunning = false

private val problemDisplay = JEditorPane().apply {
contentType = "text/html"
isEditable = false
Expand Down Expand Up @@ -243,6 +281,41 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) {
add(scrollPane, BorderLayout.CENTER)
}

// 하단 타이머 바 — 타이머 탭·상태바와 같은 TimerService를 공유.
// 상단에 얇은 프로그레스 바, 왼쪽에 타이머 그룹, 오른쪽에 스톱워치 그룹.
val timerBar = JPanel(BorderLayout()).apply {
border = JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0)
add(timerBarProgress, BorderLayout.NORTH)
add(JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(2, 8, 2, 4)
add(JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(2), 0)).apply {
isOpaque = false
add(timerBarLabel)
add(timerBarToggle)
add(timerBarReset)
}, BorderLayout.WEST)
add(JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(2), 0)).apply {
isOpaque = false
add(swBarLabel)
add(swBarToggle)
add(swBarReset)
}, BorderLayout.EAST)
}, BorderLayout.CENTER)
}
add(timerBar, BorderLayout.SOUTH)

timerBarToggle.addActionListener {
if (timerService.isRunning) timerService.pause() else timerService.start()
}
timerBarReset.addActionListener { timerService.reset() }
swBarToggle.addActionListener {
if (timerService.isStopwatchRunning) timerService.stopwatchPause() else timerService.stopwatchStart()
}
swBarReset.addActionListener { timerService.stopwatchReset() }
timerService.addListener { syncTimerBar() }
syncTimerBar()

submitButton.isEnabled = false
githubPushButton.isEnabled = false

Expand Down Expand Up @@ -351,6 +424,33 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) {
}
}

/** 타이머 바 표시 동기화 — 33ms 틱마다 불리므로 표시 문자열이 바뀔 때만 갱신 */
private fun syncTimerBar() {
// 프로그레스는 int(0~1000) 단위라 같은 값이면 setValue가 no-op
timerBarProgress.value = if (timerService.totalMs > 0)
((timerService.remainingMs.toDouble() / timerService.totalMs) * 1000).toInt() else 1000

val running = timerService.isRunning
val text = timerService.formatRemaining()
if (text != timerBarLastText || running != timerBarLastRunning) {
timerBarLastText = text
timerBarLastRunning = running
timerBarLabel.text = text
timerBarToggle.icon = if (running) AllIcons.Actions.Suspend else AllIcons.Actions.Execute
timerBarLabel.foreground = if (timerService.remainingMs in 1..59999 && running)
JBColor(Color.RED, Color(230, 80, 80)) else JBColor.foreground()
}

val swRunning = timerService.isStopwatchRunning
val swText = timerService.formatStopwatch()
if (swText != swBarLastText || swRunning != swBarLastRunning) {
swBarLastText = swText
swBarLastRunning = swRunning
swBarLabel.text = swText
swBarToggle.icon = if (swRunning) AllIcons.Actions.Suspend else AllIcons.Actions.Execute
}
}

private fun createComboRenderer(): ListCellRenderer<Any?> {
return ListCellRenderer { list, value, _, isSelected, _ ->
JLabel(value?.toString() ?: "").apply {
Expand Down
Loading
Loading