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) }