mirror of
https://codeberg.org/Kankys/doxa.git
synced 2026-11-11 06:44:36 +01:00
527 lines
14 KiB
Go
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)
|
|
}
|