From deb778a2d28834d0ad7e0302c630d89f2c462a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=94=EC=88=98?= Date: Mon, 6 Jul 2026 23:26:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20#16=20-=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=83=AD=20=ED=95=98=EB=8B=A8=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EB=B0=94=20+=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B0=94=20=EB=82=A8=EC=9D=80=20=EC=8B=9C=EA=B0=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카운트다운 상태를 CountdownPanel private 필드에서 TimerService(프로젝트 서비스)로 추출하고 세 UI가 공유: - 타이머 탭: 기존 UI 유지, 상태는 서비스에 위임. 버튼 라벨(시작/재개)· 필드 활성화를 서비스 상태에서 유도해 어디서 조작해도 일관되게 동기화 - 문제 탭 하단 타이머 바: 남은 시간 + 시작/일시정지·초기화 버튼. 문제를 보면서 시간 확인 가능 - 상태바 위젯: CTK 텍스트 옆에 남은 시간 표시. 초기 상태에서는 숨기고 실행 중이거나 중간에 멈춘/종료된 타이머만 표시 타이머는 33ms 간격으로 틱하므로 문제 탭 바와 상태바는 표시 문자열이 바뀔 때만 갱신하여 불필요한 리페인트 방지. --- .../com/codingtestkit/service/TimerService.kt | 102 ++++++++++++++++++ .../ui/CodingTestKitStatusBarFactory.kt | 25 ++++- .../kotlin/com/codingtestkit/ui/MainPanel.kt | 3 +- .../com/codingtestkit/ui/ProblemPanel.kt | 52 +++++++++ .../kotlin/com/codingtestkit/ui/TimerPanel.kt | 95 +++++++--------- src/main/resources/META-INF/plugin.xml | 1 + 6 files changed, 217 insertions(+), 61 deletions(-) create mode 100644 src/main/kotlin/com/codingtestkit/service/TimerService.kt diff --git a/src/main/kotlin/com/codingtestkit/service/TimerService.kt b/src/main/kotlin/com/codingtestkit/service/TimerService.kt new file mode 100644 index 0000000..3f6f947 --- /dev/null +++ b/src/main/kotlin/com/codingtestkit/service/TimerService.kt @@ -0,0 +1,102 @@ +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 + } + + 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 { + val totalSec = remainingMs / 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) + } +} diff --git a/src/main/kotlin/com/codingtestkit/ui/CodingTestKitStatusBarFactory.kt b/src/main/kotlin/com/codingtestkit/ui/CodingTestKitStatusBarFactory.kt index 832372c..5108a63 100644 --- a/src/main/kotlin/com/codingtestkit/ui/CodingTestKitStatusBarFactory.kt +++ b/src/main/kotlin/com/codingtestkit/ui/CodingTestKitStatusBarFactory.kt @@ -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 @@ -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 } @@ -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()) } } diff --git a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt index 304236a..92e0c4b 100644 --- a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt @@ -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 @@ -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 diff --git a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt index 2b0c47a..d60f5e1 100644 --- a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt @@ -86,6 +86,22 @@ 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)) + } + 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 var timerBarLastText = "" + private var timerBarLastRunning = false + private val problemDisplay = JEditorPane().apply { contentType = "text/html" isEditable = false @@ -243,6 +259,28 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { add(scrollPane, BorderLayout.CENTER) } + // 하단 타이머 바 — 타이머 탭·상태바와 같은 TimerService를 공유 + val timerBar = JPanel(BorderLayout()).apply { + border = JBUI.Borders.compound( + JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0), + JBUI.Borders.empty(2, 8, 2, 4) + ) + add(timerBarLabel, BorderLayout.WEST) + add(JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(2), 0)).apply { + isOpaque = false + add(timerBarToggle) + add(timerBarReset) + }, BorderLayout.EAST) + } + add(timerBar, BorderLayout.SOUTH) + + timerBarToggle.addActionListener { + if (timerService.isRunning) timerService.pause() else timerService.start() + } + timerBarReset.addActionListener { timerService.reset() } + timerService.addListener { syncTimerBar() } + syncTimerBar() + submitButton.isEnabled = false githubPushButton.isEnabled = false @@ -351,6 +389,20 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { } } + /** 타이머 바 표시 동기화 — 33ms 틱마다 불리므로 표시 문자열이 바뀔 때만 갱신 */ + private fun syncTimerBar() { + val running = timerService.isRunning + val text = timerService.formatRemaining() + if (text == timerBarLastText && running == timerBarLastRunning) return + 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() + } + private fun createComboRenderer(): ListCellRenderer { return ListCellRenderer { list, value, _, isSelected, _ -> JLabel(value?.toString() ?: "").apply { diff --git a/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt b/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt index c5dfd5b..cf0c19f 100644 --- a/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt @@ -1,6 +1,7 @@ package com.codingtestkit.ui import com.codingtestkit.service.I18n +import com.codingtestkit.service.TimerService import com.intellij.icons.AllIcons import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI @@ -13,14 +14,14 @@ import kotlin.math.cos import kotlin.math.min import kotlin.math.sin -class TimerPanel : JPanel(BorderLayout()) { +class TimerPanel(timerService: TimerService) : JPanel(BorderLayout()) { init { val tabbedPane = JTabbedPane().apply { border = JBUI.Borders.empty() } tabbedPane.addTab(I18n.t("스톱워치", "Stopwatch"), AllIcons.Debugger.Db_muted_breakpoint, StopwatchPanel()) - tabbedPane.addTab(I18n.t("타이머", "Timer"), AllIcons.Actions.StartDebugger, CountdownPanel()) + tabbedPane.addTab(I18n.t("타이머", "Timer"), AllIcons.Actions.StartDebugger, CountdownPanel(timerService)) add(tabbedPane, BorderLayout.CENTER) } @@ -354,13 +355,10 @@ class TimerPanel : JPanel(BorderLayout()) { // ─── 카운트다운 타이머 ─── - private class CountdownPanel : JPanel(BorderLayout()) { + private class CountdownPanel(private val timerService: TimerService) : JPanel(BorderLayout()) { - private var remainingMs: Long = 0 - private var totalMs: Long = 0 - private var running = false - private var timer: Timer? = null - private var endTime: Long = 0 + // 타이머 상태(남은 시간·실행 여부)는 TimerService가 소유하고, + // 이 패널은 서비스 리스너로 표시만 동기화한다 (이슈 #16). // 표시 요소 private val circularTimer = CircularTimerView() @@ -486,12 +484,17 @@ class TimerPanel : JPanel(BorderLayout()) { secField.document.addDocumentListener(docListener) startButton.addActionListener { start() } - stopButton.addActionListener { stop() } + stopButton.addActionListener { timerService.pause() } resetButton.addActionListener { reset() } - circularTimer.onCenterClick = { if (running) stop() else start() } + circularTimer.onCenterClick = { if (timerService.isRunning) timerService.pause() else start() } + + // 어느 화면(문제 탭 바, 상태바)에서 조작해도 이 패널이 따라가도록 서비스 구독 + timerService.addListener { syncFromService() } + timerService.addTimeUpListener { onTimeUp() } onTimeFieldChanged() + syncFromService() } private fun createUnitLabel(text: String): JLabel { @@ -506,13 +509,11 @@ class TimerPanel : JPanel(BorderLayout()) { } private fun onTimeFieldChanged() { - if (!running) { + if (!timerService.isRunning) { val h = getFieldValue(hourField) val m = getFieldValue(minField) val s = getFieldValue(secField) - totalMs = ((h * 3600L) + (m * 60L) + s) * 1000 - remainingMs = totalMs - updateDisplay() + timerService.setTotal(((h * 3600L) + (m * 60L) + s) * 1000) } } @@ -523,56 +524,24 @@ class TimerPanel : JPanel(BorderLayout()) { } private fun start() { - if (running) return - if (remainingMs <= 0) { + if (timerService.isRunning) return + if (timerService.remainingMs <= 0) { onTimeFieldChanged() - if (totalMs <= 0) { + if (timerService.totalMs <= 0) { JOptionPane.showMessageDialog(this, I18n.t("시간을 설정하세요.", "Please set a time."), "CodingTestKit", JOptionPane.WARNING_MESSAGE) return } } - running = true - endTime = System.currentTimeMillis() + remainingMs - startButton.isEnabled = false - stopButton.isEnabled = true - setFieldsEnabled(false) - timer = Timer(33) { - remainingMs = (endTime - System.currentTimeMillis()).coerceAtLeast(0) - if (remainingMs <= 0) timeUp() - updateDisplay() - } - timer?.start() - } - - private fun stop() { - if (!running) return - remainingMs = (endTime - System.currentTimeMillis()).coerceAtLeast(0) - running = false - timer?.stop() - startButton.isEnabled = true - startButton.text = I18n.t("재개", "Resume") - stopButton.isEnabled = false - updateDisplay() + timerService.start() } private fun reset() { - if (running) { timer?.stop(); running = false } - startButton.isEnabled = true - startButton.text = I18n.t("시작", "Start") - stopButton.isEnabled = false - setFieldsEnabled(true) digitalLabel.foreground = JBColor.foreground() onTimeFieldChanged() + timerService.reset() } - private fun timeUp() { - timer?.stop() - running = false - remainingMs = 0 - startButton.isEnabled = true - startButton.text = I18n.t("시작", "Start") - stopButton.isEnabled = false - setFieldsEnabled(true) + private fun onTimeUp() { digitalLabel.foreground = JBColor(Color.RED, Color(230, 80, 80)) Toolkit.getDefaultToolkit().beep() JOptionPane.showMessageDialog(this, I18n.t("시간이 종료되었습니다!", "Time's up!"), I18n.t("타이머 종료", "Timer Ended"), JOptionPane.INFORMATION_MESSAGE) @@ -584,16 +553,26 @@ class TimerPanel : JPanel(BorderLayout()) { secField.isEnabled = enabled } + /** 서비스 상태에서 버튼·필드·표시를 유도 — 어디서 조작해도 일관됨 */ + private fun syncFromService() { + val running = timerService.isRunning + val pausedMidway = timerService.isPausedMidway + startButton.isEnabled = !running + stopButton.isEnabled = running + startButton.text = if (pausedMidway) I18n.t("재개", "Resume") else I18n.t("시작", "Start") + setFieldsEnabled(!running && !pausedMidway) + updateDisplay() + } + private fun updateDisplay() { + val remainingMs = timerService.remainingMs + val totalMs = timerService.totalMs + val running = timerService.isRunning + circularTimer.update(remainingMs, totalMs, running) // 디지털 시계 - val totalSec = remainingMs / 1000 - val h = totalSec / 3600 - val m = (totalSec % 3600) / 60 - val s = totalSec % 60 - digitalLabel.text = if (h > 0) String.format("%d:%02d:%02d", h, m, s) - else String.format("%d:%02d", m, s) + digitalLabel.text = timerService.formatRemaining() if (remainingMs in 1..59999 && running) { digitalLabel.foreground = JBColor(Color.RED, Color(230, 80, 80)) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f437362..544f7b8 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -682,6 +682,7 @@ factoryClass="com.codingtestkit.ui.CodingTestKitToolWindowFactory"/> + From cf75ccb21dece6c4a5dc5824852875f39b80e2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=94=EC=88=98?= Date: Tue, 7 Jul 2026 00:02:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=ED=95=98=EB=8B=A8=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EB=B0=94=EC=97=90=20=EC=8A=A4=ED=86=B1?= =?UTF-8?q?=EC=9B=8C=EC=B9=98=C2=B7=ED=94=84=EB=A1=9C=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스톱워치 상태(경과 시간·실행 여부)도 TimerService로 추출하고 StopwatchPanel은 표시·랩 기록만 담당하도록 변경 - 문제 탭 하단 바를 왼쪽 타이머 그룹 + 오른쪽 스톱워치 그룹으로 확장, 각각 시작/일시정지·초기화 버튼 제공 - 바 상단에 남은 시간 비율을 보여주는 3px 프로그레스 바 추가 (int 0~1000 단위라 같은 값이면 setValue가 no-op이므로 별도 스로틀 불필요) --- .../com/codingtestkit/service/TimerService.kt | 55 +++++++++++- .../com/codingtestkit/ui/ProblemPanel.kt | 86 +++++++++++++++---- .../kotlin/com/codingtestkit/ui/TimerPanel.kt | 66 +++++--------- 3 files changed, 139 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/com/codingtestkit/service/TimerService.kt b/src/main/kotlin/com/codingtestkit/service/TimerService.kt index 3f6f947..5e7e94f 100644 --- a/src/main/kotlin/com/codingtestkit/service/TimerService.kt +++ b/src/main/kotlin/com/codingtestkit/service/TimerService.kt @@ -4,8 +4,8 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project /** - * 카운트다운 타이머 상태 서비스 (이슈 #16). - * 타이머 탭, 문제 탭 하단 타이머 바, 상태바 위젯이 같은 타이머를 공유·관찰한다. + * 카운트다운 타이머·스톱워치 상태 서비스 (이슈 #16). + * 타이머 탭, 문제 탭 하단 타이머 바, 상태바 위젯이 같은 상태를 공유·관찰한다. * Swing Timer 기반이므로 모든 호출과 콜백은 EDT에서 일어난다. */ @Service(Service.Level.PROJECT) @@ -75,6 +75,48 @@ class TimerService { 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) } @@ -84,8 +126,13 @@ class TimerService { private fun notifyListeners() { listeners.toList().forEach { it() } } /** 남은 시간 표기: h:mm:ss 또는 mm:ss (문제 탭 바, 상태바 공용) */ - fun formatRemaining(): String { - val totalSec = remainingMs / 1000 + 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 diff --git a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt index d60f5e1..e68c74b 100644 --- a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt @@ -86,10 +86,11 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { font = font.deriveFont(JBUI.scaleFontSize(11f).toFloat()) iconTextGap = 0 } - // ─── 하단 타이머 바 (이슈 #16): 문제를 보면서 남은 시간 확인·조작 ─── + // ─── 하단 타이머 바 (이슈 #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") @@ -99,8 +100,29 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { 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" @@ -259,18 +281,27 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { add(scrollPane, BorderLayout.CENTER) } - // 하단 타이머 바 — 타이머 탭·상태바와 같은 TimerService를 공유 + // 하단 타이머 바 — 타이머 탭·상태바와 같은 TimerService를 공유. + // 상단에 얇은 프로그레스 바, 왼쪽에 타이머 그룹, 오른쪽에 스톱워치 그룹. val timerBar = JPanel(BorderLayout()).apply { - border = JBUI.Borders.compound( - JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0), - JBUI.Borders.empty(2, 8, 2, 4) - ) - add(timerBarLabel, BorderLayout.WEST) - add(JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(2), 0)).apply { + border = JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0) + add(timerBarProgress, BorderLayout.NORTH) + add(JPanel(BorderLayout()).apply { isOpaque = false - add(timerBarToggle) - add(timerBarReset) - }, BorderLayout.EAST) + 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) @@ -278,6 +309,10 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { 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() @@ -391,16 +426,29 @@ 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) return - 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() + 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 { diff --git a/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt b/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt index cf0c19f..c95addd 100644 --- a/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/TimerPanel.kt @@ -20,7 +20,7 @@ class TimerPanel(timerService: TimerService) : JPanel(BorderLayout()) { val tabbedPane = JTabbedPane().apply { border = JBUI.Borders.empty() } - tabbedPane.addTab(I18n.t("스톱워치", "Stopwatch"), AllIcons.Debugger.Db_muted_breakpoint, StopwatchPanel()) + tabbedPane.addTab(I18n.t("스톱워치", "Stopwatch"), AllIcons.Debugger.Db_muted_breakpoint, StopwatchPanel(timerService)) tabbedPane.addTab(I18n.t("타이머", "Timer"), AllIcons.Actions.StartDebugger, CountdownPanel(timerService)) add(tabbedPane, BorderLayout.CENTER) } @@ -206,12 +206,10 @@ class TimerPanel(timerService: TimerService) : JPanel(BorderLayout()) { // ─── 스톱워치 ─── - private class StopwatchPanel : JPanel(BorderLayout()) { + private class StopwatchPanel(private val timerService: TimerService) : JPanel(BorderLayout()) { - private var startTime: Long = 0 - private var accumulatedMs: Long = 0 - private var running = false - private var timer: Timer? = null + // 스톱워치 상태(경과 시간·실행 여부)는 TimerService가 소유하고, + // 이 패널은 서비스 리스너로 표시만 동기화한다 (이슈 #16). private var lapCount = 0 private var lastLapMs: Long = 0 @@ -293,54 +291,25 @@ class TimerPanel(timerService: TimerService) : JPanel(BorderLayout()) { } add(lapScroll, BorderLayout.CENTER) - startButton.addActionListener { start() } - stopButton.addActionListener { stop() } + startButton.addActionListener { timerService.stopwatchStart() } + stopButton.addActionListener { timerService.stopwatchPause() } resetButton.addActionListener { reset() } lapButton.addActionListener { lap() } - } - private fun getElapsedMs(): Long { - return if (running) accumulatedMs + (System.currentTimeMillis() - startTime) - else accumulatedMs - } - - private fun start() { - if (running) return - running = true - startTime = System.currentTimeMillis() - startButton.isEnabled = false - stopButton.isEnabled = true - lapButton.isEnabled = true - timer = Timer(33) { updateDisplay() } - timer?.start() - } - - private fun stop() { - if (!running) return - accumulatedMs += System.currentTimeMillis() - startTime - running = false - timer?.stop() - startButton.isEnabled = true - startButton.text = I18n.t("재개", "Resume") - stopButton.isEnabled = false - lapButton.isEnabled = false - updateDisplay() + // 문제 탭 하단 바에서 조작해도 이 패널이 따라가도록 서비스 구독 + timerService.addListener { syncFromService() } + syncFromService() } private fun reset() { - if (running) { timer?.stop(); running = false } - accumulatedMs = 0; lapCount = 0; lastLapMs = 0 + lapCount = 0; lastLapMs = 0 lapRecords.clear() lapTableModel.fireTableDataChanged() - startButton.isEnabled = true - startButton.text = I18n.t("시작", "Start") - stopButton.isEnabled = false - lapButton.isEnabled = false - updateDisplay() + timerService.stopwatchReset() } private fun lap() { - val elapsed = getElapsedMs() + val elapsed = timerService.stopwatchElapsedMs lapCount++ val diff = elapsed - lastLapMs lastLapMs = elapsed @@ -348,8 +317,15 @@ class TimerPanel(timerService: TimerService) : JPanel(BorderLayout()) { lapTableModel.fireTableRowsInserted(lapRecords.size - 1, lapRecords.size - 1) } - private fun updateDisplay() { - timeLabel.text = formatTime(getElapsedMs()) + /** 서비스 상태에서 버튼·표시를 유도 — 어디서 조작해도 일관됨 */ + private fun syncFromService() { + val running = timerService.isStopwatchRunning + val pausedMidway = !running && timerService.stopwatchElapsedMs > 0 + startButton.isEnabled = !running + stopButton.isEnabled = running + lapButton.isEnabled = running + startButton.text = if (pausedMidway) I18n.t("재개", "Resume") else I18n.t("시작", "Start") + timeLabel.text = formatTime(timerService.stopwatchElapsedMs) } }