Files
2026-08-16 21:13:50 +02:00

527 lines
14 KiB
Go

package main
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ─── Styles ──────────────────────────────────────────────────────────────────
var (
appStyle = lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1).
MarginBottom(1)
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("205"))
timerFocusStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
timerPauseStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("205"))
timerBreakStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#2ECC71"))
timerDoneStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#F39C12"))
labelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
valueStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA"))
settingActiveStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// ─── State ────────────────────────────────────────────────────────────────────
type AppState int
const (
StateStartup AppState = iota
StateIdle
StateRunning
StatePaused
StateBreak
StateLongBreak
StateSettings
StateStats
)
// ─── Messages ─────────────────────────────────────────────────────────────────
type tickMsg time.Time
func tickCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// ─── Model ────────────────────────────────────────────────────────────────────
type model struct {
state AppState
stats TimerStats
lang string
width int
height int
statusMsg string
// Timer countdown
remaining int // seconds remaining
totalSeconds int // total seconds for current session
// Settings edit fields (temporary values while editing)
editFocus int
editShort int
editLong int
editAfter int
settingCursor int // 0=focus,1=short,2=long,3=after
}
func NewModel() model {
stats := loadStats()
return model{
state: StateStartup,
stats: stats,
lang: "cs",
remaining: stats.FocusDuration * 60,
totalSeconds: stats.FocusDuration * 60,
editFocus: stats.FocusDuration,
editShort: stats.ShortBreak,
editLong: stats.LongBreak,
editAfter: stats.LongBreakAfter,
}
}
func (m model) Init() tea.Cmd {
return tea.EnterAltScreen
}
// ─── Update ───────────────────────────────────────────────────────────────────
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
tr := Translations[m.lang]
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tickMsg:
if m.state == StateRunning || m.state == StateBreak || m.state == StateLongBreak {
m.remaining--
if m.remaining <= 0 {
return m.handleTimerComplete()
}
return m, tickCmd()
}
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "q", "esc":
switch m.state {
case StateStartup:
return m, tea.Quit
case StateIdle:
return m, tea.Quit
case StateRunning, StatePaused:
return m, tea.Quit
case StateSettings, StateStats:
m.state = StateIdle
m.statusMsg = ""
return m, nil
default:
return m, tea.Quit
}
case "l":
if m.state != StateSettings {
if m.lang == "cs" {
m.lang = "en"
} else {
m.lang = "cs"
}
}
return m, nil
case "enter":
switch m.state {
case StateStartup:
m.state = StateIdle
m.remaining = m.stats.FocusDuration * 60
m.totalSeconds = m.stats.FocusDuration * 60
return m, nil
case StateSettings:
// Save settings
m.stats.FocusDuration = m.editFocus
m.stats.ShortBreak = m.editShort
m.stats.LongBreak = m.editLong
m.stats.LongBreakAfter = m.editAfter
saveStats(m.stats)
m.state = StateIdle
m.remaining = m.stats.FocusDuration * 60
m.totalSeconds = m.stats.FocusDuration * 60
m.statusMsg = ""
return m, nil
}
case " ":
switch m.state {
case StateIdle:
m.state = StateRunning
m.statusMsg = ""
return m, tickCmd()
case StateRunning:
m.state = StatePaused
return m, nil
case StatePaused:
m.state = StateRunning
return m, tickCmd()
case StateBreak, StateLongBreak:
m.state = StateRunning
return m, tickCmd()
}
case "r":
if m.state == StateRunning || m.state == StatePaused || m.state == StateIdle {
m.state = StateIdle
m.remaining = m.stats.FocusDuration * 60
m.totalSeconds = m.stats.FocusDuration * 60
m.statusMsg = ""
return m, nil
}
if m.state == StateBreak || m.state == StateLongBreak {
m.state = StateIdle
m.remaining = m.stats.FocusDuration * 60
m.totalSeconds = m.stats.FocusDuration * 60
m.statusMsg = ""
return m, nil
}
case "s":
if m.state == StateIdle || m.state == StatePaused {
m.state = StateSettings
m.editFocus = m.stats.FocusDuration
m.editShort = m.stats.ShortBreak
m.editLong = m.stats.LongBreak
m.editAfter = m.stats.LongBreakAfter
m.settingCursor = 0
return m, nil
}
case "t":
if m.state == StateIdle || m.state == StatePaused {
m.state = StateStats
return m, nil
}
// Settings navigation
case "up", "k":
if m.state == StateSettings {
m.settingCursor--
if m.settingCursor < 0 {
m.settingCursor = 3
}
}
case "down", "j":
if m.state == StateSettings {
m.settingCursor++
if m.settingCursor > 3 {
m.settingCursor = 0
}
}
case "+", "=":
if m.state == StateSettings {
switch m.settingCursor {
case 0:
if m.editFocus < 60 { m.editFocus++ }
case 1:
if m.editShort < 30 { m.editShort++ }
case 2:
if m.editLong < 60 { m.editLong++ }
case 3:
if m.editAfter < 10 { m.editAfter++ }
}
}
case "-":
if m.state == StateSettings {
switch m.settingCursor {
case 0:
if m.editFocus > 1 { m.editFocus-- }
case 1:
if m.editShort > 1 { m.editShort-- }
case 2:
if m.editLong > 1 { m.editLong-- }
case 3:
if m.editAfter > 1 { m.editAfter-- }
}
}
}
_ = tr
}
return m, nil
}
func (m model) handleTimerComplete() (tea.Model, tea.Cmd) {
tr := Translations[m.lang]
if m.state == StateRunning {
// Pomodoro complete
m.stats.TotalPomodoros++
m.stats.TodayPomodoros++
m.stats.LastDate = time.Now().Format("2006-01-02")
saveStats(m.stats)
m.statusMsg = tr.SessionComplete
// Decide: long break or short break
if m.stats.TodayPomodoros%m.stats.LongBreakAfter == 0 {
m.state = StateLongBreak
m.remaining = m.stats.LongBreak * 60
m.totalSeconds = m.stats.LongBreak * 60
} else {
m.state = StateBreak
m.remaining = m.stats.ShortBreak * 60
m.totalSeconds = m.stats.ShortBreak * 60
}
return m, tickCmd()
}
// Break complete → go idle
m.statusMsg = tr.BreakComplete
m.state = StateIdle
m.remaining = m.stats.FocusDuration * 60
m.totalSeconds = m.stats.FocusDuration * 60
return m, nil
}
// ─── View ─────────────────────────────────────────────────────────────────────
func (m model) View() string {
tr := Translations[m.lang]
var b strings.Builder
b.WriteString(titleStyle.Render(tr.Title) + "\n")
if m.statusMsg != "" {
b.WriteString(statusStyle.Render(m.statusMsg) + "\n\n")
}
switch m.state {
case StateStartup:
eagle := `
_
( ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("`") + `
\ ` + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("\\") + `
/ / _
__ / / ( \
( "---" / / /
\ / / /
\ /-----' /
\ / /
\ / /
'-------'
`
b.WriteString(lipgloss.Place(m.width-12, 14, lipgloss.Center, lipgloss.Center,
eagle+"\n"+
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4")).Render("DOXA-TIMER 🦅")+"\n\n"+
"Local-First Pomodoro Timer\n\n"+
"Enter: Start | q: Quit\n\n"+
"License: MIT | © 2026 Kankys"))
case StateIdle, StateRunning, StatePaused, StateBreak, StateLongBreak:
// State label
stateLabel := m.getStateLabel()
b.WriteString(lipgloss.NewStyle().Bold(true).Render(stateLabel) + "\n\n")
// Big timer
timerStr := formatDuration(m.remaining)
timerRendered := m.renderTimer(timerStr)
b.WriteString(lipgloss.PlaceHorizontal(m.width-10, lipgloss.Center, timerRendered) + "\n\n")
// Progress bar
progressBar := renderProgressBar(m.remaining, m.totalSeconds, 40)
b.WriteString(lipgloss.PlaceHorizontal(m.width-10, lipgloss.Center, progressBar) + "\n\n")
// Stats row
pomLine := fmt.Sprintf(" %s %s %s %s",
labelStyle.Render(tr.LabelTodayCount),
valueStyle.Render(fmt.Sprintf("%d", m.stats.TodayPomodoros)),
labelStyle.Render(tr.LabelTotalCount),
valueStyle.Render(fmt.Sprintf("%d", m.stats.TotalPomodoros)),
)
b.WriteString(pomLine + "\n\n")
// Next long break
remaining := m.stats.LongBreakAfter - (m.stats.TodayPomodoros % m.stats.LongBreakAfter)
if remaining == m.stats.LongBreakAfter {
remaining = 0
}
if remaining > 0 {
b.WriteString(labelStyle.Render(fmt.Sprintf(" %s %d pomodoro(s)", tr.LabelNextBreak, remaining)) + "\n\n")
}
// Help
helpText := tr.HelpIdle
if m.state == StateRunning {
helpText = tr.HelpRunning
} else if m.state == StatePaused {
helpText = tr.HelpIdle
}
b.WriteString(helpStyle.Render(helpText))
case StateSettings:
b.WriteString(lipgloss.NewStyle().Bold(true).Render(tr.SettingsTitle) + "\n\n")
settings := []struct {
label string
value int
unit string
}{
{tr.LabelFocus, m.editFocus, tr.MinutesShort},
{tr.LabelShortBreak, m.editShort, tr.MinutesShort},
{tr.LabelLongBreak, m.editLong, tr.MinutesShort},
{tr.LabelLongAfter, m.editAfter, "pomodorů"},
}
if m.lang == "en" {
settings[3].unit = "pomodoros"
}
for i, s := range settings {
cursor := " "
lStyle := labelStyle
vStyle := valueStyle
if i == m.settingCursor {
cursor = "> "
lStyle = settingActiveStyle
vStyle = settingActiveStyle
}
b.WriteString(fmt.Sprintf("%s%-22s %s %s\n",
cursor,
lStyle.Render(s.label),
vStyle.Render(fmt.Sprintf("%d", s.value)),
labelStyle.Render(s.unit),
))
}
b.WriteString("\n" + helpStyle.Render(tr.HelpSettings))
case StateStats:
b.WriteString(lipgloss.NewStyle().Bold(true).Render(tr.StatsTitle) + "\n\n")
b.WriteString(fmt.Sprintf(" %-28s %s\n",
labelStyle.Render(tr.PomodorosToday),
valueStyle.Render(fmt.Sprintf("%d 🍅", m.stats.TodayPomodoros)),
))
b.WriteString(fmt.Sprintf(" %-28s %s\n",
labelStyle.Render(tr.PomodorosTotal),
valueStyle.Render(fmt.Sprintf("%d 🍅", m.stats.TotalPomodoros)),
))
b.WriteString(fmt.Sprintf("\n %-28s %d %s\n",
labelStyle.Render(tr.LabelFocus),
m.stats.FocusDuration,
tr.MinutesShort,
))
b.WriteString(fmt.Sprintf(" %-28s %d %s\n",
labelStyle.Render(tr.LabelShortBreak),
m.stats.ShortBreak,
tr.MinutesShort,
))
b.WriteString(fmt.Sprintf(" %-28s %d %s\n",
labelStyle.Render(tr.LabelLongBreak),
m.stats.LongBreak,
tr.MinutesShort,
))
b.WriteString("\n" + helpStyle.Render(tr.HelpStats))
}
content := b.String()
if m.width > 0 && m.height > 0 {
return appStyle.Width(m.width - 6).Height(m.height - 4).Render(content)
}
return appStyle.Render(content)
}
func (m model) getStateLabel() string {
tr := Translations[m.lang]
switch m.state {
case StateIdle:
return tr.StateIdle
case StateRunning:
return tr.StateRunning
case StatePaused:
return tr.StatePaused
case StateBreak:
return tr.StateBreak
case StateLongBreak:
return tr.StateLongBreak
}
return ""
}
func (m model) renderTimer(s string) string {
large := lipgloss.NewStyle().Bold(true).PaddingLeft(1).PaddingRight(1)
switch m.state {
case StateRunning:
return timerFocusStyle.Copy().Inherit(large).Render(s)
case StatePaused:
return timerPauseStyle.Copy().Inherit(large).Render(s)
case StateBreak, StateLongBreak:
return timerBreakStyle.Copy().Inherit(large).Render(s)
default:
return timerPauseStyle.Copy().Inherit(large).Render(s)
}
}
// renderProgressBar renders an ASCII progress bar
func renderProgressBar(remaining, total, width int) string {
if total == 0 {
return ""
}
elapsed := total - remaining
filled := int(float64(elapsed) / float64(total) * float64(width))
if filled > width {
filled = width
}
bar := "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]"
pct := int(float64(elapsed) / float64(total) * 100)
return fmt.Sprintf("%s %3d%%", bar, pct)
}
// formatDuration formats seconds as MM:SS
func formatDuration(secs int) string {
if secs < 0 {
secs = 0
}
m := secs / 60
s := secs % 60
return fmt.Sprintf("%02d:%02d", m, s)
}