diff --git a/Makefile b/Makefile index ab8e79b..8e5bdd4 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,23 @@ .PHONY: build install clean test list release-dry-run install-deps +VERSION := $(shell git describe --tags 2>/dev/null || echo "dev") +LDFLAGS := -ldflags "-X main.version=$(VERSION)" + # Display available tasks list: @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs # Build the application build: - go build + go build $(LDFLAGS) # Install the application install: - go install + go install $(LDFLAGS) # Clean the build clean: + @rm -f tiny-timer go clean -testcache # Run tests diff --git a/handlers.go b/handlers.go index 0c41082..c824847 100644 --- a/handlers.go +++ b/handlers.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" + "tiny-timer/status" ) // Top level event handler that is called each time the screen is updated @@ -31,6 +32,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.progress = progressModel.(progress.Model) return m, cmd + case status.InfoMsg, status.ClearStatusMsg: + // Handle status component updates + statusModel, cmd := m.status.Update(msg) + m.status = statusModel + return m, cmd + default: return m, nil } @@ -49,14 +56,18 @@ func updatePercent(m model) (tea.Model, tea.Cmd) { return m, tea.Batch(tickCmd(), cmd) } - percentCompleted := float64(elapsed) / float64(m.targetDuration) + // Calculate completion for further evaluation + // + // For count down mode (default), fill progress bar + // and work backwards as time elapses + percentCompleted := float64(m.targetDuration-elapsed) / float64(m.targetDuration) // Check for completion based on actual elapsed time - if percentCompleted >= 1.0 { - // Ensure progress is set to 100% for final display - m.progress.SetPercent(1.0) + if percentCompleted <= 0.0 { + // Ensure progress is set to 0% for final display + m.progress.SetPercent(0.0) - if err := sendNotification("Pomodoro CLI", "Timer has finished"); err != nil { + if err := sendNotification("tiny-timer", "Timer has finished"); err != nil { fmt.Println("Error sending notification:", err) } @@ -67,21 +78,26 @@ func updatePercent(m model) (tea.Model, tea.Cmd) { return m, nil } + // Activate normal progress bar update cmd := m.progress.SetPercent(percentCompleted) return m, tea.Batch(tickCmd(), cmd) } func updateWindowSize(m model, msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.progress.Width = msg.Width - padding*2 - 4 - if m.progress.Width > maxWidth { - m.progress.Width = maxWidth - } + m.progress.Width = min(m.progress.Width, maxWidth) m.help.Width = msg.Width - return m, nil + + // Update status component with window size + s, cmd := m.status.Update(msg) + m.status = s + + return m, cmd } func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) { m.promptActive = false + var cmds []tea.Cmd switch m.promptType { case promptLogAndReset: @@ -90,7 +106,21 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) { log.Printf("handlePromptInput: Saving session, elapsed=%d, title=%q", elapsed, msg.title) if err := saveSessionToDB(elapsed, true, msg.title); err != nil { log.Printf("handlePromptInput: Error saving session: %v", err) - fmt.Println("Error saving session to DB:", err) + // Show error status + cmds = append(cmds, func() tea.Msg { + return status.InfoMsg{ + Type: status.InfoTypeError, + Msg: fmt.Sprintf("Failed to save session: %v", err), + } + }) + } else { + // Show success status + cmds = append(cmds, func() tea.Msg { + return status.InfoMsg{ + Type: status.InfoTypeSuccess, + Msg: fmt.Sprintf("Saved: %s (%d:%02d)", msg.title, elapsed/60, elapsed%60), + } + }) } // Refresh history table if we are logging log.Printf("handlePromptInput: Building table view after save") @@ -104,7 +134,8 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) { m.startTime = time.Now().Unix() cmd := m.progress.SetPercent(0) m.title = "" - return m, tea.Batch(tickCmd(), cmd) + cmds = append(cmds, tickCmd(), cmd) + return m, tea.Batch(cmds...) case promptEditTitle: // Just update title without logging m.title = msg.title @@ -129,23 +160,24 @@ func handlePromptInput(m model, msg promptMsg) (tea.Model, tea.Cmd) { // handlePromptKeyInput handles key input when prompt is active func handlePromptKeyInput(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if msg.Type == tea.KeyEnter { + switch msg.Type { + case tea.KeyEnter: return handlePromptInput(m, promptMsg{title: m.inputBuffer, logDB: m.promptType == promptLogAndReset}) - } else if msg.Type == tea.KeyEsc { + case tea.KeyEsc: m.promptActive = false return m, nil - } else if msg.Type == tea.KeyBackspace { + case tea.KeyBackspace: if len(m.inputBuffer) > 0 { m.inputBuffer = m.inputBuffer[:len(m.inputBuffer)-1] } return m, nil - } else if msg.Type == tea.KeySpace { + case tea.KeySpace: // Only allow space for title prompts, not duration if m.promptType != promptSetDuration { m.inputBuffer += " " } return m, nil - } else if msg.Type == tea.KeyRunes { + case tea.KeyRunes: for _, r := range msg.Runes { // For duration prompts, only allow numeric characters if m.promptType == promptSetDuration { @@ -162,7 +194,7 @@ func handlePromptKeyInput(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleTableViewKey handles key input when in table view mode -func handleTableViewKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func handleTableViewKey(m model, _ tea.KeyMsg) (tea.Model, tea.Cmd) { // Any key exits table view m.mode = timerView return m, nil diff --git a/main.go b/main.go index 57b5080..259c5c4 100644 --- a/main.go +++ b/main.go @@ -16,16 +16,45 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" + "tiny-timer/status" ) +var version = "dev" + func main() { - // Parse CLI flags + title, countUp, clean, debug := parseFlags() + + configureLogging(debug) + + if clean { + handleCleanFlag() + return + } + + if err := initDB(); err != nil { + fmt.Println("Error initializing database:", err) + os.Exit(1) + } + + defer closeDBConnection() + + targetDuration := calculateTargetDuration(countUp) + keys := createKeyBindings() + m := createModel(title, countUp, targetDuration, keys) + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Oh no!", err) + os.Exit(1) + } +} + +func parseFlags() (title string, countUp bool, clean bool, debug bool) { titleFlag := flag.String("title", "", "Optional title for the timer session") countUpFlag := flag.Bool("count-up", false, "Enable count-up mode (logs task time after completion)") cleanFlag := flag.Bool("clean", false, "Delete the database and exit") debugFlag := flag.Bool("debug", false, "Enable debug logging to debug.log") + versionFlag := flag.Bool("version", false, "Print version and exit") - // Customize usage to include positional argument flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: %s [minutes] [flags]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Positional arguments:\n") @@ -35,25 +64,30 @@ func main() { flag.PrintDefaults() } - // Pre-process arguments to allow positional argument (minutes) before flags - // flag.Parse() stops at the first non-flag argument. - // We check if the first argument is a number and move it to the end if so. + preprocessArgs() + flag.Parse() + + if *versionFlag { + fmt.Println("tiny-timer version", version) + os.Exit(0) + } + + return *titleFlag, *countUpFlag, *cleanFlag, *debugFlag +} + +func preprocessArgs() { args := os.Args[1:] if len(args) > 0 { if _, err := strconv.ParseInt(args[0], 10, 64); err == nil { - // First arg is a number, move it to the end so flag.Parse() can see the flags minutes := args[0] newArgs := append(args[1:], minutes) os.Args = append([]string{os.Args[0]}, newArgs...) } } +} - flag.Parse() - - // Enable debug logging if flag is set - // tea.LogToFile configures the standard log package to write to debug.log - // All log.Printf() calls throughout the codebase will write to this file - if *debugFlag { +func configureLogging(debug bool) { + if debug { f, err := tea.LogToFile("debug.log", "debug") if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to enable debug logging: %v\n", err) @@ -61,38 +95,31 @@ func main() { defer f.Close() } } else { - // Silence all log output in normal mode log.SetOutput(io.Discard) } +} - if *cleanFlag { - dbPath, err := getDBPath() - if err != nil { - fmt.Println("Error getting database path:", err) - os.Exit(1) - } - if _, err := os.Stat(dbPath); err == nil { - if err := os.Remove(dbPath); err != nil { - fmt.Println("Error deleting database:", err) - os.Exit(1) - } - fmt.Println("Database deleted successfully.") - } else if os.IsNotExist(err) { - fmt.Println("Database does not exist.") - } else { - fmt.Println("Error checking database:", err) +func handleCleanFlag() { + dbPath, err := getDBPath() + if err != nil { + fmt.Println("Error getting database path:", err) + os.Exit(1) + } + if _, err := os.Stat(dbPath); err == nil { + if err := os.Remove(dbPath); err != nil { + fmt.Println("Error deleting database:", err) os.Exit(1) } - os.Exit(0) - } - - // Initialize database on launch - if err := initDB(); err != nil { - fmt.Println("Error initializing database:", err) + fmt.Println("Database deleted successfully.") + } else if os.IsNotExist(err) { + fmt.Println("Database does not exist.") + } else { + fmt.Println("Error checking database:", err) os.Exit(1) } +} - // Read positional arg for duration, or use default +func calculateTargetDuration(countUp bool) int64 { var targetDurationInMinutes int64 = defaultDurationInMinutes if flag.NArg() > 0 { if arg, err := strconv.ParseInt(flag.Arg(0), 10, 64); err == nil && arg > 0 { @@ -101,11 +128,15 @@ func main() { } targetDuration := targetDurationInMinutes * 60 - if *countUpFlag && flag.NArg() == 0 { + if countUp && flag.NArg() == 0 { targetDuration = defaultCountUpDuration } - keys := keyMap{ + return targetDuration +} + +func createKeyBindings() keyMap { + return keyMap{ Done: key.NewBinding( key.WithKeys("d"), key.WithHelp("d", "done"), @@ -143,22 +174,27 @@ func main() { key.WithHelp("backspace", "delete"), ), } +} + +func createModel(title string, countUp bool, targetDuration int64, keys keyMap) model { + prog := progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()) + if countUp { + prog.SetPercent(0) + } else { + prog.SetPercent(1.0) + } + + statusCmp := status.NewStatusCmp() + statusCmp.SetKeyMap(keys) - m := model{ - progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()), + return model{ + progress: prog, startTime: time.Now().Unix(), targetDuration: targetDuration, - title: *titleFlag, - countUpMode: *countUpFlag, + title: title, + countUpMode: countUp, help: newHelpModel(), keys: keys, - } - - // Ensure database connection is closed on exit - defer closeDBConnection() - - if _, err := tea.NewProgram(m).Run(); err != nil { - fmt.Println("Oh no!", err) - os.Exit(1) + status: statusCmp, } } diff --git a/model.go b/model.go index 2b920c1..fce6a00 100644 --- a/model.go +++ b/model.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "tiny-timer/status" ) type tickMsg time.Time @@ -82,6 +83,7 @@ type model struct { promptType promptType help help.Model keys keyMap + status *status.StatusCmp } // Start the event loop diff --git a/status/status.go b/status/status.go new file mode 100644 index 0000000..869f8be --- /dev/null +++ b/status/status.go @@ -0,0 +1,183 @@ +// Package status implements the StatusCmp component that displays a temporary alert. +// +// Modified slightly from https://github.com/charmbracelet/crush/blob/main/internal/tui/components/core/status/status.go +package status + +import ( + "time" + + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type InfoType int + +const ( + InfoTypeInfo InfoType = iota + InfoTypeSuccess + InfoTypeWarn + InfoTypeError + InfoTypeUpdate +) + +type InfoMsg struct { + Type InfoType + Msg string + TTL time.Duration +} + +type ClearStatusMsg struct{} + +type Theme struct { + Name string + Green lipgloss.Color + GreenDark lipgloss.Color + BgSubtle lipgloss.Color + Red lipgloss.Color + Error lipgloss.Color + White lipgloss.Color + Yellow lipgloss.Color + Warning lipgloss.Color + BgOverlay lipgloss.Color + Primary lipgloss.Color + Border lipgloss.Color +} + +func NewTheme() *Theme { + return &Theme{ + Name: "tinytimer", + Green: lipgloss.Color("#00d787"), // Julep + GreenDark: lipgloss.Color("#00a86b"), // Guac + BgSubtle: lipgloss.Color("#313244"), // Charcoal + Red: lipgloss.Color("#ff5555"), // Coral + Error: lipgloss.Color("#ff6b6b"), // Sriracha + White: lipgloss.Color("#ffffff"), // Butter + Yellow: lipgloss.Color("#ffff00"), // Mustard + Warning: lipgloss.Color("#ffd700"), // Zest + BgOverlay: lipgloss.Color("#1e1e2e"), // Iron + Primary: lipgloss.Color("#6c5ce7"), // Charple + Border: lipgloss.Color("#313244"), // Charcoal + } +} + +type Styles struct { + Base lipgloss.Style + Help help.Styles +} + +func (t *Theme) S() *Styles { + base := lipgloss.NewStyle() + return &Styles{ + Base: base, + Help: help.Styles{ + ShortKey: base.Foreground(lipgloss.Color("#a0a0a0")), + ShortDesc: base.Foreground(lipgloss.Color("#626262")), + ShortSeparator: base.Foreground(lipgloss.Color("#313244")), + }, + } +} + +type StatusCmp struct { + info InfoMsg + width int + messageTTL time.Duration + help help.Model + keyMap help.KeyMap +} + +func NewStatusCmp() *StatusCmp { + t := NewTheme() + h := help.New() + h.Styles = t.S().Help + return &StatusCmp{ + messageTTL: 3 * time.Second, + help: h, + } +} + +func (m *StatusCmp) SetKeyMap(keyMap help.KeyMap) { + m.keyMap = keyMap +} + +func (m *StatusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd { + return tea.Tick(ttl, func(time.Time) tea.Msg { + return ClearStatusMsg{} + }) +} + +func (m *StatusCmp) Update(msg tea.Msg) (*StatusCmp, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.help.Width = msg.Width - 2 + return m, nil + + case InfoMsg: + m.info = msg + ttl := msg.TTL + if ttl == 0 { + ttl = m.messageTTL + } + return m, m.clearMessageCmd(ttl) + + case ClearStatusMsg: + m.info = InfoMsg{} + return m, nil + } + return m, nil +} + +func (m *StatusCmp) View() string { + if m.info.Msg != "" { + return m.infoMsg() + } + return NewTheme().S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap)) +} + +func (m *StatusCmp) infoMsg() string { + t := NewTheme() + + // Determine styling based on info type + var infoTypeLabel string + var infoTypeStyle lipgloss.Style + var messageStyle lipgloss.Style + var messageFg lipgloss.Color + + switch m.info.Type { + case InfoTypeError: + infoTypeLabel = "ERROR" + infoTypeStyle = t.S().Base.Background(t.Red).Padding(0, 1) + messageStyle = t.S().Base.Background(t.Error).Padding(0, 1) + messageFg = t.White + case InfoTypeWarn: + infoTypeLabel = "WARNING" + infoTypeStyle = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1) + messageStyle = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1) + default: + if m.info.Type == InfoTypeUpdate { + infoTypeLabel = "HEY!" + } else { + infoTypeLabel = "OKAY!" + } + infoTypeStyle = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true) + messageStyle = t.S().Base.Background(t.GreenDark).Padding(0, 1) + messageFg = t.BgOverlay + } + + // Render info type label + infoType := infoTypeStyle.Render(infoTypeLabel) + + // Calculate available width and truncate message + widthLeft := m.width - (lipgloss.Width(infoType) + 2) + info := ansi.Truncate(m.info.Msg, widthLeft, "…") + + // Render message with calculated width + if messageFg != "" { + messageStyle = messageStyle.Foreground(messageFg) + } + message := messageStyle.Width(widthLeft + 2).Render(info) + + return ansi.Truncate(infoType+message, m.width, "…") +} diff --git a/test_helpers.go b/test_helpers.go index 850d082..47ed258 100644 --- a/test_helpers.go +++ b/test_helpers.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/progress" + "tiny-timer/status" ) // newTestModel creates a model with default test values, including help and keys @@ -50,12 +51,15 @@ func newTestModel() model { key.WithHelp("backspace", "delete"), ), } + statusCmp := status.NewStatusCmp() + statusCmp.SetKeyMap(keys) return model{ progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()), startTime: time.Now().Unix(), targetDuration: 60, help: newHelpModel(), keys: keys, + status: statusCmp, } } diff --git a/vhs/basic.tape b/vhs/basic.tape index 61fd237..2535c83 100644 --- a/vhs/basic.tape +++ b/vhs/basic.tape @@ -18,7 +18,7 @@ Enter Sleep 5s Type "d" Sleep 1s Type " and tests" Sleep 3s -Enter Sleep 5s +Enter Sleep 6s Type "h" Sleep 3s Enter Sleep 2s diff --git a/view.go b/view.go index fc06b04..b3e724e 100644 --- a/view.go +++ b/view.go @@ -67,9 +67,11 @@ func (m model) View() string { tableKeys := tableKeyMap{ Quit: m.keys.Quit, } + m.status.SetKeyMap(tableKeys) + statusView := m.status.View() return "\n" + strings.Join(paddedTable, "\n") + "\n\n" + - pad + m.help.View(tableKeys) + statusView } elapsed := time.Now().Unix() - m.startTime @@ -92,8 +94,14 @@ func (m model) View() string { titleLine = pad + m.title + "\n\n" } + // Ensure status component has the full keymap for timer view + m.status.SetKeyMap(m.keys) + + // status.View() handles displaying status messages OR help text + statusView := m.status.View() + return "\n" + titleLine + pad + m.progress.View() + fmt.Sprintf(" %s \n\n", formatDurationAsMMSS(remaining)) + - pad + m.help.View(m.keys) + statusView } diff --git a/view_test.go b/view_test.go index 0d43563..f4f9b99 100644 --- a/view_test.go +++ b/view_test.go @@ -9,15 +9,21 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" + "tiny-timer/status" ) func TestViewWithTitle(t *testing.T) { // Create a model with a title + statusCmp := status.NewStatusCmp() + statusCmp.SetKeyMap(newTestModel().keys) m := model{ progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()), startTime: time.Now().Unix(), targetDuration: 60, title: "Test Task", + status: statusCmp, + help: newHelpModel(), + keys: newTestModel().keys, } view := m.View() @@ -28,11 +34,16 @@ func TestViewWithTitle(t *testing.T) { func TestViewWithoutTitle(t *testing.T) { // Create a model without a title + statsuCmp := status.NewStatusCmp() + statsuCmp.SetKeyMap(newTestModel().keys) m := model{ progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()), startTime: time.Now().Unix(), targetDuration: 60, title: "", + status: statsuCmp, + help: newHelpModel(), + keys: newTestModel().keys, } view := m.View() @@ -76,12 +87,17 @@ func TestTableHeadersAreLeftAligned(t *testing.T) { assert.NoError(t, err) // Create a model and trigger table view + statsuCmp := status.NewStatusCmp() + statsuCmp.SetKeyMap(newTestModel().keys) m := model{ progress: progress.New(progress.WithGradient(colorMontezumaGold, colorCream), progress.WithoutPercentage()), startTime: time.Now().Unix(), targetDuration: 60, title: "Test Task", mode: timerView, + status: statsuCmp, + help: newHelpModel(), + keys: newTestModel().keys, } // Simulate pressing 't' to show table