From 2f9c88552d1958c7bb447ea70053b050c21e1129 Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:05:29 +0300 Subject: [PATCH 1/8] Add interactive TUI mode using Bubble Tea v2 Launch interactive mode when no flags are provided. Includes a unified menu for mode selection and settings, live-updating results, and custom text input implementation compatible with bubbletea v2. --- go.mod | 30 ++- go.sum | 51 +++++ internal/tui/tui.go | 470 ++++++++++++++++++++++++++++++++++++++++++++ main.go | 25 ++- 4 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 internal/tui/tui.go diff --git a/go.mod b/go.mod index c568190..aa6b30b 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,32 @@ module github.com/TyostoKarry/sleepycli go 1.26.1 -require github.com/spf13/pflag v1.0.10 +require ( + charm.land/bubbletea/v2 v2.0.2 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/pflag v1.0.10 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum index 8ec1276..43c953d 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,53 @@ +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..4e4e4d6 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,470 @@ +package tui + +import ( + "fmt" + "strings" + "time" + "unicode" + + tea "charm.land/bubbletea/v2" + "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/validate" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("75")) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + resultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120")) + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + separatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + sectionHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")) + cursorStyle = lipgloss.NewStyle().Reverse(true) + inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + placeholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) +) + +type inputKind int + +const ( + kindTime inputKind = iota + kindNum +) + +type textInput struct { + value string + placeholder string + editing bool + maxLength int + kind inputKind +} + +func newTimeInput(placeholder string) textInput { + return textInput{placeholder: placeholder, maxLength: 5, kind: kindTime} +} + +func newNumInput(placeholder string) textInput { + return textInput{placeholder: placeholder, maxLength: 3, kind: kindNum} +} + +func (t *textInput) handleKey(msg tea.KeyPressMsg) { + key := msg.String() + switch key { + case "backspace": + if len(t.value) > 0 { + r := []rune(t.value) + t.value = string(r[:len(r)-1]) + } + default: + if len(key) != 1 { + return + } + ch := rune(key[0]) + if !unicode.IsDigit(ch) { + return + } + if len([]rune(t.value)) >= t.maxLength { + return + } + t.value += string(ch) + if t.kind == kindTime && len([]rune(t.value)) == 2 { + t.value += ":" + } + } +} + +func (t textInput) view() string { + if !t.editing && t.value == "" { + return placeholderStyle.Render(t.placeholder) + } + display := inputStyle.Render(t.value) + if t.editing { + display += cursorStyle.Render(" ") + } + return display +} + +// Menu items +const ( + rowNow = 0 + rowWake = 1 + rowSleep = 2 + rowWindow = 3 + rowBuffer = 4 + rowCycleMin = 5 + rowCycleMax = 6 + menuRows = 7 +) + +type Model struct { + // menuCursor is the highlighted row (0–6) + menuCursor int + // selectedMode is the active sleep mode (rows 0–3) + selectedMode int + // editing is true when the cursor is on a settings/time row and the user pressed enter to type into it + editing bool + + // time inputs. Only used when a mode that needs input is selected + inputPrimary textInput // wake / sleep / from + inputSecondary textInput // to (window only) + // settings inputs + inputBuffer textInput + inputCyclesMin textInput + inputCyclesMax textInput + + // timeField tracks which time input is active (0 = primary, 1 = secondary) + timeField int + + // PrintResult holds the result to print to stdout after the TUI exits (empty = don't print) + PrintResult string +} + +func InitialModel() Model { + return Model{ + menuCursor: rowNow, + selectedMode: rowNow, + inputPrimary: newTimeInput("HH:MM"), + inputSecondary: newTimeInput("HH:MM"), + inputBuffer: newNumInput("15"), + inputCyclesMin: newNumInput("4"), + inputCyclesMax: newNumInput("6"), + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyPressMsg) (Model, tea.Cmd) { + key := msg.String() + + if key == "ctrl+c" || key == "q" { + return m, tea.Quit + } + + // Editing time input + if m.editing && m.menuCursor < rowBuffer { + switch key { + case "esc": + m.editing = false + m.inputPrimary.editing = false + m.inputSecondary.editing = false + case "tab", "enter": + if m.selectedMode == rowWindow && m.timeField == 0 { + m.inputPrimary.editing = false + m.timeField = 1 + m.inputSecondary.editing = true + } else { + m.editing = false + m.inputPrimary.editing = false + m.inputSecondary.editing = false + m.timeField = 0 + hasInput := m.inputPrimary.value != "" + if m.selectedMode == rowWindow { + hasInput = hasInput && m.inputSecondary.value != "" + } + if hasInput { + m.PrintResult = m.computeResult() + return m, tea.Quit + } + } + default: + if m.timeField == 1 { + m.inputSecondary.handleKey(msg) + } else { + m.inputPrimary.handleKey(msg) + } + } + return m, nil + } + + // Editing settings input + if m.editing && m.menuCursor >= rowBuffer { + switch key { + case "esc", "enter": + m.editing = false + activeSettingsInput(&m).editing = false + default: + activeSettingsInput(&m).handleKey(msg) + } + return m, nil + } + + // Menu navigation + switch key { + case "up": + m.menuCursor-- + if m.menuCursor < 0 { + m.menuCursor = menuRows - 1 + } + case "down": + m.menuCursor++ + if m.menuCursor >= menuRows { + m.menuCursor = 0 + } + case "enter": + if m.menuCursor <= rowWindow { + if m.menuCursor == rowNow { + m.selectedMode = rowNow + m.PrintResult = m.computeResult() + return m, tea.Quit + } + // Mode selection + if m.selectedMode != m.menuCursor { + m.selectedMode = m.menuCursor + // reset inputs when switching modes + m.inputPrimary.value = "" + m.inputSecondary.value = "" + m.timeField = 0 + } + m.editing = true + m.inputPrimary.editing = true + } else { + // Settings editing + m.editing = true + activeSettingsInput(&m).editing = true + } + } + + return m, nil +} + +func activeSettingsInput(m *Model) *textInput { + switch m.menuCursor { + case rowBuffer: + return &m.inputBuffer + case rowCycleMin: + return &m.inputCyclesMin + case rowCycleMax: + return &m.inputCyclesMax + default: + return nil + } +} + +func (m Model) bufferMinutes() int { + if m.inputBuffer.value == "" { + return 15 + } + var n int + fmt.Sscanf(m.inputBuffer.value, "%d", &n) + return n +} + +func (m Model) cyclesMin() int { + if m.inputCyclesMin.value == "" { + return 4 + } + var n int + fmt.Sscanf(m.inputCyclesMin.value, "%d", &n) + return n +} + +func (m Model) cyclesMax() int { + if m.inputCyclesMax.value == "" { + return 6 + } + var n int + fmt.Sscanf(m.inputCyclesMax.value, "%d", &n) + return n +} + +func (m Model) computeResult() string { + buffer := time.Duration(m.bufferMinutes()) * time.Minute + minCycles := m.cyclesMin() + maxCycles := m.cyclesMax() + + if minCycles < 0 || maxCycles < 0 || maxCycles < minCycles || buffer < 0 { + return errorStyle.Render("Invalid Settings") + } + + switch m.selectedMode { + case rowNow: + return m.renderWakeTimes(time.Now(), buffer, minCycles, maxCycles, + fmt.Sprintf("Sleeping now at %s", time.Now().Format("15:04"))) + + case rowWake: + raw := m.inputPrimary.value + if raw == "" { + return dimStyle.Render("Enter a wake time to see results") + } + wakeTime, err := time.Parse("15:04", validate.NormalizeHour(raw)) + if err != nil { + return errorStyle.Render("Invalid time, use HH:MM") + } + return m.renderBedtimes(wakeTime, buffer, minCycles, maxCycles, + fmt.Sprintf("To wake up at %s", raw)) + + case rowSleep: + raw := m.inputPrimary.value + if raw == "" { + return dimStyle.Render("Enter a sleep time to see results") + } + sleepTime, err := time.Parse("15:04", validate.NormalizeHour(raw)) + if err != nil { + return errorStyle.Render("Invalid time, use HH:MM") + } + return m.renderWakeTimes(sleepTime, buffer, minCycles, maxCycles, + fmt.Sprintf("Sleeping at %s", raw)) + + case rowWindow: + rawFrom := m.inputPrimary.value + rawTo := m.inputSecondary.value + if rawFrom == "" || rawTo == "" { + return dimStyle.Render("Enter both sleep and wake times to see results") + } + fromTime, err := time.Parse("15:04", validate.NormalizeHour(rawFrom)) + if err != nil { + return errorStyle.Render("from: Invalid time, use HH:MM") + } + toTime, err := time.Parse("15:04", validate.NormalizeHour(rawTo)) + if err != nil { + return errorStyle.Render("to: Invalid time, use HH:MM") + } + cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) + var sb strings.Builder + sb.WriteString(resultStyle.Render(fmt.Sprintf("Between %s and %s", rawFrom, rawTo)) + "\n") + sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") + sb.WriteString(fmt.Sprintf(" %s complete cycles %s\n", + resultStyle.Render(fmt.Sprintf("%d", cycles)), + dimStyle.Render("("+formatDuration(cycles)+")"))) + sb.WriteString(fmt.Sprintf(" %s minutes remaining\n", + resultStyle.Render(fmt.Sprintf("%d", int(remainder.Minutes()))))) + return sb.String() + } + return "" +} + +func (m Model) renderWakeTimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { + wakeTimes := cycle.CalculateWakeTimes(base, buffer, minCycles, maxCycles) + var sb strings.Builder + sb.WriteString(resultStyle.Render(header) + "\n") + sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") + for i := len(wakeTimes) - 1; i >= 0; i-- { + c := minCycles + i + sb.WriteString(fmt.Sprintf(" %s cycles → wake at %s %s\n", + resultStyle.Render(fmt.Sprintf("%d", c)), + resultStyle.Render(wakeTimes[i].Format("15:04")), + dimStyle.Render("("+formatDuration(c)+")"))) + } + return sb.String() +} + +func (m Model) renderBedtimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { + bedTimes := cycle.CalculateBedtimes(base, buffer, minCycles, maxCycles) + var sb strings.Builder + sb.WriteString(resultStyle.Render(header) + "\n") + sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") + for i := len(bedTimes) - 1; i >= 0; i-- { + c := minCycles + i + sb.WriteString(fmt.Sprintf(" %s cycles → sleep at %s %s\n", + resultStyle.Render(fmt.Sprintf("%d", c)), + resultStyle.Render(bedTimes[i].Format("15:04")), + dimStyle.Render("("+formatDuration(c)+")"))) + } + return sb.String() +} + +func formatDuration(cycleCount int) string { + duration := time.Duration(cycleCount) * cycle.CycleDuration + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + return fmt.Sprintf("%dh %02dm", hours, minutes) +} + +func (m Model) View() tea.View { + var sb strings.Builder + + sb.WriteString(titleStyle.Render("SleepyCLI") + "\n\n") + + // Mode rows (0-3) + modeLabels := []string{"now", "wake", "sleep", "from/to"} + modeDescriptions := []string{ + "wake times starting from right now", + "bedtimes for a target wake time", + "wake times for a target sleep time", + "cycles that fit in a sleep window", + } + for i, label := range modeLabels { + cursor := " " + if m.menuCursor == i { + cursor = selectedStyle.Render("▶ ") + } + text := fmt.Sprintf("%-8s", label) + desc := " " + modeDescriptions[i] + if m.selectedMode == i { + sb.WriteString(cursor + selectedStyle.Render(text) + dimStyle.Render(desc) + "\n") + } else { + sb.WriteString(cursor + dimStyle.Render(text+desc) + "\n") + } + } + + // Blank separator and section header between modes and settings + sb.WriteString("\n") + sb.WriteString(sectionHeaderStyle.Render(" settings") + "\n") + + // Settings rows (4–6) + settingsRows := []struct { + label string + input textInput + row int + }{ + {" buffer (min): ", m.inputBuffer, rowBuffer}, + {" cycles min: ", m.inputCyclesMin, rowCycleMin}, + {" cycles max: ", m.inputCyclesMax, rowCycleMax}, + } + for _, s := range settingsRows { + cursor := " " + label := dimStyle.Render(s.label) + if m.menuCursor == s.row { + cursor = selectedStyle.Render("▶ ") + label = selectedStyle.Render(s.label) + } + sb.WriteString(cursor + label + s.input.view() + "\n") + } + + // Time input area + if m.selectedMode != rowNow { + sb.WriteString("\n") + switch m.selectedMode { + case rowWake: + sb.WriteString(labelStyle.Render(" Wake time: ") + m.inputPrimary.view() + "\n") + case rowSleep: + sb.WriteString(labelStyle.Render(" Sleep time: ") + m.inputPrimary.view() + "\n") + case rowWindow: + sb.WriteString(labelStyle.Render(" From: ") + m.inputPrimary.view() + "\n") + sb.WriteString(labelStyle.Render(" To: ") + m.inputSecondary.view() + "\n") + } + } + + // Result + sb.WriteString("\n") + sb.WriteString(m.computeResult()) + sb.WriteString("\n\n") + + // Help bar + var helpItems []string + if m.editing { + helpItems = []string{"type value", "enter confirm", "esc cancel"} + } else { + helpItems = []string{"↑↓ navigate", "enter select/edit", "q quit"} + } + sb.WriteString(dimStyle.Render(strings.Join(helpItems, " · "))) + + v := tea.NewView(sb.String()) + v.AltScreen = true + return v +} diff --git a/main.go b/main.go index 5a6e572..e88ca33 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,14 @@ import ( "fmt" "os" + tea "charm.land/bubbletea/v2" "github.com/TyostoKarry/sleepycli/internal/goodnight" "github.com/TyostoKarry/sleepycli/internal/help" + "github.com/TyostoKarry/sleepycli/internal/tui" "github.com/spf13/pflag" ) -const version = "0.1.0" +const version = "0.2.0" func main() { help.SetupCustomHelp() @@ -40,6 +42,19 @@ func main() { pflag.Parse() + if !anyFlagSet() { + p := tea.NewProgram(tui.InitialModel()) + m, err := p.Run() + if err != nil { + fmt.Fprintln(os.Stderr, "Error running TUI:", err) + os.Exit(1) + } + if model, ok := m.(tui.Model); ok && model.PrintResult != "" { + fmt.Println(model.PrintResult) + } + return + } + if versionFlag { fmt.Println("sleepycli v" + version) os.Exit(0) @@ -56,3 +71,11 @@ func main() { os.Exit(1) } } + +func anyFlagSet() bool { + found := false + pflag.Visit(func(f *pflag.Flag) { + found = true + }) + return found +} From 9221efa95e607a0bf106b30e401e113f41b1a069 Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:26:26 +0300 Subject: [PATCH 2/8] Update non-interactive mode result display styles Add lipgloss styles to non-interactive result display --- internal/styles/styles.go | 10 ++++++++ internal/tui/tui.go | 9 ++++--- modes.go | 52 ++++++++++++++++++++++++++------------- 3 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 internal/styles/styles.go diff --git a/internal/styles/styles.go b/internal/styles/styles.go new file mode 100644 index 0000000..c8fd691 --- /dev/null +++ b/internal/styles/styles.go @@ -0,0 +1,10 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var ( + Result = lipgloss.NewStyle().Foreground(lipgloss.Color("120")) + Dim = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + Separator = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + Error = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4e4e4d6..c0ac917 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/styles" "github.com/TyostoKarry/sleepycli/internal/validate" "github.com/charmbracelet/lipgloss" ) @@ -15,11 +16,11 @@ import ( var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("75")) selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) - resultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120")) + dimStyle = styles.Dim + errorStyle = styles.Error + resultStyle = styles.Result labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - separatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + separatorStyle = styles.Separator sectionHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")) cursorStyle = lipgloss.NewStyle().Reverse(true) inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) diff --git a/modes.go b/modes.go index d0aeaf5..4ac76ad 100644 --- a/modes.go +++ b/modes.go @@ -2,9 +2,11 @@ package main import ( "fmt" + "strings" "time" "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/styles" "github.com/TyostoKarry/sleepycli/internal/validate" ) @@ -48,12 +50,16 @@ func runNowMode(buffer time.Duration, minCycles, maxCycles int) error { now := time.Now() wakeTimes := cycle.CalculateWakeTimes(now, buffer, minCycles, maxCycles) - fmt.Printf("If you go to sleep now at %s:\n", now.Format("15:04")) - fmt.Println("────────────────────────────────") - fmt.Printf("Assuming %d min to fall asleep\n\n", int(buffer.Minutes())) + fmt.Println(styles.Result.Render(fmt.Sprintf("Sleeping now at %s", now.Format("15:04")))) + fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) + fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) + fmt.Println() for i := len(wakeTimes) - 1; i >= 0; i-- { cycleCount := minCycles + i - fmt.Printf(" - %d cycles, wake up at %s (%s)\n", cycleCount, wakeTimes[i].Format("15:04"), formatDuration(cycleCount)) + fmt.Printf(" %s cycles → wake at %s %s\n", + styles.Result.Render(fmt.Sprintf("%d", cycleCount)), + styles.Result.Render(wakeTimes[i].Format("15:04")), + styles.Dim.Render("("+formatDuration(cycleCount)+")")) } return nil } @@ -69,11 +75,15 @@ func runWindowMode(from, to string, buffer time.Duration) error { } cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) - fmt.Printf("Between %s and %s:\n", from, to) - fmt.Println("───────────────────────") - fmt.Printf("Assuming %d min to fall asleep\n\n", int(buffer.Minutes())) - fmt.Printf(" - %d complete cycles (%s)\n", cycles, formatDuration(cycles)) - fmt.Printf(" - %d minutes remaining\n", int(remainder.Minutes())) + fmt.Println(styles.Result.Render(fmt.Sprintf("Between %s and %s", from, to))) + fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) + fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) + fmt.Println() + fmt.Printf(" %s complete cycles %s\n", + styles.Result.Render(fmt.Sprintf("%d", cycles)), + styles.Dim.Render("("+formatDuration(cycles)+")")) + fmt.Printf(" %s minutes remaining\n", + styles.Result.Render(fmt.Sprintf("%d", int(remainder.Minutes())))) return nil } @@ -84,12 +94,16 @@ func runWakeMode(wake string, buffer time.Duration, minCycles, maxCycles int) er } bedtimes := cycle.CalculateBedtimes(wakeTime, buffer, minCycles, maxCycles) - fmt.Printf("To wake up at %s:\n", wake) - fmt.Println("───────────────────") - fmt.Printf("Assuming %d min to fall asleep\n\n", int(buffer.Minutes())) + fmt.Println(styles.Result.Render(fmt.Sprintf("To wake up at %s", wake))) + fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) + fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) + fmt.Println() for i := len(bedtimes) - 1; i >= 0; i-- { cycleCount := minCycles + i - fmt.Printf(" - %d cycles, go to sleep at %s (%s)\n", cycleCount, bedtimes[i].Format("15:04"), formatDuration(cycleCount)) + fmt.Printf(" %s cycles → sleep at %s %s\n", + styles.Result.Render(fmt.Sprintf("%d", cycleCount)), + styles.Result.Render(bedtimes[i].Format("15:04")), + styles.Dim.Render("("+formatDuration(cycleCount)+")")) } return nil } @@ -101,12 +115,16 @@ func runSleepMode(sleep string, buffer time.Duration, minCycles, maxCycles int) } wakeTimes := cycle.CalculateWakeTimes(sleepTime, buffer, minCycles, maxCycles) - fmt.Printf("If you go to sleep at %s:\n", sleep) - fmt.Println("────────────────────────────") - fmt.Printf("Assuming %d min to fall asleep\n\n", int(buffer.Minutes())) + fmt.Println(styles.Result.Render(fmt.Sprintf("Sleeping at %s", sleep))) + fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) + fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) + fmt.Println() for i := len(wakeTimes) - 1; i >= 0; i-- { cycleCount := minCycles + i - fmt.Printf(" - %d cycles, wake up at %s (%s)\n", cycleCount, wakeTimes[i].Format("15:04"), formatDuration(cycleCount)) + fmt.Printf(" %s cycles → wake at %s %s\n", + styles.Result.Render(fmt.Sprintf("%d", cycleCount)), + styles.Result.Render(wakeTimes[i].Format("15:04")), + styles.Dim.Render("("+formatDuration(cycleCount)+")")) } return nil } From 1f52be2153382df5bef48a37e0b5dd31f6de7c63 Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:54:11 +0300 Subject: [PATCH 3/8] Unify the result render between interactive and non-interactive modes - Created render package for displaying the outout - Replaced direct inline rendering with the new package to display same output on both interactive and non-interactive modes --- internal/render/render.go | 64 +++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 64 ++++--------------------------------- modes.go | 67 +++++---------------------------------- 3 files changed, 79 insertions(+), 116 deletions(-) create mode 100644 internal/render/render.go diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..aeb58a6 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,64 @@ +package render + +import ( + "fmt" + "strings" + "time" + + "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/styles" +) + +func FormatDuration(cycleCount int) string { + duration := time.Duration(cycleCount) * cycle.CycleDuration + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + return fmt.Sprintf("%dh %02dm", hours, minutes) +} + +func WakeTimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { + wakeTimes := cycle.CalculateWakeTimes(base, buffer, minCycles, maxCycles) + var sb strings.Builder + sb.WriteString(styles.Result.Render(header) + "\n") + sb.WriteString(styles.Separator.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes()))) + "\n\n") + for i := len(wakeTimes) - 1; i >= 0; i-- { + c := minCycles + i + sb.WriteString(fmt.Sprintf(" %s cycles → wake at %s %s\n", + styles.Result.Render(fmt.Sprintf("%d", c)), + styles.Result.Render(wakeTimes[i].Format("15:04")), + styles.Dim.Render("("+FormatDuration(c)+")"))) + } + return sb.String() +} + +func Bedtimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { + bedTimes := cycle.CalculateBedtimes(base, buffer, minCycles, maxCycles) + var sb strings.Builder + sb.WriteString(styles.Result.Render(header) + "\n") + sb.WriteString(styles.Separator.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes()))) + "\n\n") + for i := len(bedTimes) - 1; i >= 0; i-- { + c := minCycles + i + sb.WriteString(fmt.Sprintf(" %s cycles → sleep at %s %s\n", + styles.Result.Render(fmt.Sprintf("%d", c)), + styles.Result.Render(bedTimes[i].Format("15:04")), + styles.Dim.Render("("+FormatDuration(c)+")"))) + } + return sb.String() +} + +func Window(from, to string, fromTime, toTime time.Time, bufferMinutes int) string { + buffer := time.Duration(bufferMinutes) * time.Minute + cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) + var sb strings.Builder + sb.WriteString(styles.Result.Render(fmt.Sprintf("Between %s and %s", from, to)) + "\n") + sb.WriteString(styles.Separator.Render(strings.Repeat("─", 30)) + "\n") + sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", bufferMinutes)) + "\n\n") + sb.WriteString(fmt.Sprintf(" %s complete cycles %s\n", + styles.Result.Render(fmt.Sprintf("%d", cycles)), + styles.Dim.Render("("+FormatDuration(cycles)+")"))) + sb.WriteString(fmt.Sprintf(" %s minutes remaining\n", + styles.Result.Render(fmt.Sprintf("%d", int(remainder.Minutes()))))) + return sb.String() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c0ac917..f87284d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,7 +7,7 @@ import ( "unicode" tea "charm.land/bubbletea/v2" - "github.com/TyostoKarry/sleepycli/internal/cycle" + "github.com/TyostoKarry/sleepycli/internal/render" "github.com/TyostoKarry/sleepycli/internal/styles" "github.com/TyostoKarry/sleepycli/internal/validate" "github.com/charmbracelet/lipgloss" @@ -18,9 +18,7 @@ var ( selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) dimStyle = styles.Dim errorStyle = styles.Error - resultStyle = styles.Result labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - separatorStyle = styles.Separator sectionHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("67")) cursorStyle = lipgloss.NewStyle().Reverse(true) inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) @@ -291,8 +289,9 @@ func (m Model) computeResult() string { switch m.selectedMode { case rowNow: - return m.renderWakeTimes(time.Now(), buffer, minCycles, maxCycles, - fmt.Sprintf("Sleeping now at %s", time.Now().Format("15:04"))) + now := time.Now() + return render.WakeTimes(now, buffer, minCycles, maxCycles, + fmt.Sprintf("Sleeping now at %s", now.Format("15:04"))) case rowWake: raw := m.inputPrimary.value @@ -303,7 +302,7 @@ func (m Model) computeResult() string { if err != nil { return errorStyle.Render("Invalid time, use HH:MM") } - return m.renderBedtimes(wakeTime, buffer, minCycles, maxCycles, + return render.Bedtimes(wakeTime, buffer, minCycles, maxCycles, fmt.Sprintf("To wake up at %s", raw)) case rowSleep: @@ -315,7 +314,7 @@ func (m Model) computeResult() string { if err != nil { return errorStyle.Render("Invalid time, use HH:MM") } - return m.renderWakeTimes(sleepTime, buffer, minCycles, maxCycles, + return render.WakeTimes(sleepTime, buffer, minCycles, maxCycles, fmt.Sprintf("Sleeping at %s", raw)) case rowWindow: @@ -332,60 +331,11 @@ func (m Model) computeResult() string { if err != nil { return errorStyle.Render("to: Invalid time, use HH:MM") } - cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) - var sb strings.Builder - sb.WriteString(resultStyle.Render(fmt.Sprintf("Between %s and %s", rawFrom, rawTo)) + "\n") - sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") - sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") - sb.WriteString(fmt.Sprintf(" %s complete cycles %s\n", - resultStyle.Render(fmt.Sprintf("%d", cycles)), - dimStyle.Render("("+formatDuration(cycles)+")"))) - sb.WriteString(fmt.Sprintf(" %s minutes remaining\n", - resultStyle.Render(fmt.Sprintf("%d", int(remainder.Minutes()))))) - return sb.String() + return render.Window(rawFrom, rawTo, fromTime, toTime, m.bufferMinutes()) } return "" } -func (m Model) renderWakeTimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { - wakeTimes := cycle.CalculateWakeTimes(base, buffer, minCycles, maxCycles) - var sb strings.Builder - sb.WriteString(resultStyle.Render(header) + "\n") - sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") - sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") - for i := len(wakeTimes) - 1; i >= 0; i-- { - c := minCycles + i - sb.WriteString(fmt.Sprintf(" %s cycles → wake at %s %s\n", - resultStyle.Render(fmt.Sprintf("%d", c)), - resultStyle.Render(wakeTimes[i].Format("15:04")), - dimStyle.Render("("+formatDuration(c)+")"))) - } - return sb.String() -} - -func (m Model) renderBedtimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, header string) string { - bedTimes := cycle.CalculateBedtimes(base, buffer, minCycles, maxCycles) - var sb strings.Builder - sb.WriteString(resultStyle.Render(header) + "\n") - sb.WriteString(separatorStyle.Render(strings.Repeat("─", 30)) + "\n") - sb.WriteString(dimStyle.Render(fmt.Sprintf("Assuming %d min to fall asleep", m.bufferMinutes())) + "\n\n") - for i := len(bedTimes) - 1; i >= 0; i-- { - c := minCycles + i - sb.WriteString(fmt.Sprintf(" %s cycles → sleep at %s %s\n", - resultStyle.Render(fmt.Sprintf("%d", c)), - resultStyle.Render(bedTimes[i].Format("15:04")), - dimStyle.Render("("+formatDuration(c)+")"))) - } - return sb.String() -} - -func formatDuration(cycleCount int) string { - duration := time.Duration(cycleCount) * cycle.CycleDuration - hours := int(duration.Hours()) - minutes := int(duration.Minutes()) % 60 - return fmt.Sprintf("%dh %02dm", hours, minutes) -} - func (m Model) View() tea.View { var sb strings.Builder diff --git a/modes.go b/modes.go index 4ac76ad..871bb9f 100644 --- a/modes.go +++ b/modes.go @@ -2,11 +2,9 @@ package main import ( "fmt" - "strings" "time" - "github.com/TyostoKarry/sleepycli/internal/cycle" - "github.com/TyostoKarry/sleepycli/internal/styles" + "github.com/TyostoKarry/sleepycli/internal/render" "github.com/TyostoKarry/sleepycli/internal/validate" ) @@ -48,19 +46,8 @@ func validateAndSelectMode( func runNowMode(buffer time.Duration, minCycles, maxCycles int) error { now := time.Now() - wakeTimes := cycle.CalculateWakeTimes(now, buffer, minCycles, maxCycles) - - fmt.Println(styles.Result.Render(fmt.Sprintf("Sleeping now at %s", now.Format("15:04")))) - fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) - fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) - fmt.Println() - for i := len(wakeTimes) - 1; i >= 0; i-- { - cycleCount := minCycles + i - fmt.Printf(" %s cycles → wake at %s %s\n", - styles.Result.Render(fmt.Sprintf("%d", cycleCount)), - styles.Result.Render(wakeTimes[i].Format("15:04")), - styles.Dim.Render("("+formatDuration(cycleCount)+")")) - } + fmt.Print(render.WakeTimes(now, buffer, minCycles, maxCycles, + fmt.Sprintf("Sleeping now at %s", now.Format("15:04")))) return nil } @@ -74,16 +61,7 @@ func runWindowMode(from, to string, buffer time.Duration) error { return err } - cycles, remainder := cycle.CalculateCyclesInWindow(fromTime, toTime, buffer) - fmt.Println(styles.Result.Render(fmt.Sprintf("Between %s and %s", from, to))) - fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) - fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) - fmt.Println() - fmt.Printf(" %s complete cycles %s\n", - styles.Result.Render(fmt.Sprintf("%d", cycles)), - styles.Dim.Render("("+formatDuration(cycles)+")")) - fmt.Printf(" %s minutes remaining\n", - styles.Result.Render(fmt.Sprintf("%d", int(remainder.Minutes())))) + fmt.Print(render.Window(from, to, fromTime, toTime, int(buffer.Minutes()))) return nil } @@ -92,19 +70,8 @@ func runWakeMode(wake string, buffer time.Duration, minCycles, maxCycles int) er if err != nil { return err } - bedtimes := cycle.CalculateBedtimes(wakeTime, buffer, minCycles, maxCycles) - - fmt.Println(styles.Result.Render(fmt.Sprintf("To wake up at %s", wake))) - fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) - fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) - fmt.Println() - for i := len(bedtimes) - 1; i >= 0; i-- { - cycleCount := minCycles + i - fmt.Printf(" %s cycles → sleep at %s %s\n", - styles.Result.Render(fmt.Sprintf("%d", cycleCount)), - styles.Result.Render(bedtimes[i].Format("15:04")), - styles.Dim.Render("("+formatDuration(cycleCount)+")")) - } + fmt.Print(render.Bedtimes(wakeTime, buffer, minCycles, maxCycles, + fmt.Sprintf("To wake up at %s", wake))) return nil } @@ -113,25 +80,7 @@ func runSleepMode(sleep string, buffer time.Duration, minCycles, maxCycles int) if err != nil { return err } - wakeTimes := cycle.CalculateWakeTimes(sleepTime, buffer, minCycles, maxCycles) - - fmt.Println(styles.Result.Render(fmt.Sprintf("Sleeping at %s", sleep))) - fmt.Println(styles.Separator.Render(strings.Repeat("─", 30))) - fmt.Println(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes())))) - fmt.Println() - for i := len(wakeTimes) - 1; i >= 0; i-- { - cycleCount := minCycles + i - fmt.Printf(" %s cycles → wake at %s %s\n", - styles.Result.Render(fmt.Sprintf("%d", cycleCount)), - styles.Result.Render(wakeTimes[i].Format("15:04")), - styles.Dim.Render("("+formatDuration(cycleCount)+")")) - } + fmt.Print(render.WakeTimes(sleepTime, buffer, minCycles, maxCycles, + fmt.Sprintf("Sleeping at %s", sleep))) return nil } - -func formatDuration(cycleCount int) string { - sleepDuration := time.Duration(cycleCount) * cycle.CycleDuration - hours := int(sleepDuration.Hours()) - minutes := int(sleepDuration.Minutes()) % 60 - return fmt.Sprintf("%dh %02dm", hours, minutes) -} From 87e28fd37f387ddfc32dcaf27026213f7d7c570b Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:05:07 +0300 Subject: [PATCH 4/8] Update help command to include interactive mode --- internal/help/help.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/help/help.go b/internal/help/help.go index fb6d7f1..32b4442 100644 --- a/internal/help/help.go +++ b/internal/help/help.go @@ -32,6 +32,7 @@ func writeHelp(w io.Writer, name string) error { fmt.Fprintf(&b, "%s — sleep cycle calculator\n\n", name) b.WriteString("Usage:\n") + fmt.Fprintf(&b, " %s Launch interactive mode\n", name) fmt.Fprintf(&b, " %s [mode] [options]\n\n", name) b.WriteString("Choose exactly one mode:\n") From bbaeb6e34d64e0be73912957d936c8077eab7fd3 Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:09:04 +0300 Subject: [PATCH 5/8] Update README to include interactive mode --- README.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7d57a88..4ff4a0c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The tool supports four main modes: calculate from **now**, from a target **wake* ## Features +- Interactive TUI mode when run without flags - Calculate wake times from the current time - Calculate bedtimes for a target wake time - Calculate wake times for a target sleep time @@ -52,7 +53,21 @@ go build -o sleepycli . go run . --help ``` -## Usage +## Interactive mode + +Run `sleepycli` without any flags to launch the interactive TUI: + +```bash +sleepycli +``` + +- Navigate with `↑` / `↓` +- Press `enter` on a mode to select it and type your time +- Adjust settings (buffer, cycle range) from the settings section +- Press `enter` to confirm — the result is printed to your terminal +- Press `q` to quit without output + +## CLI flags ```bash sleepycli [mode] [options] @@ -109,27 +124,27 @@ sleepycli --sleep 23:00 --cycles-min 3 --cycles-max 7 #### `--wake 07:00` ```text -To wake up at 07:00: -─────────────────── +To wake up at 07:00 +────────────────────────────── Assuming 15 min to fall asleep - - 6 cycles, go to sleep at 21:45 (9h 00m) - - 5 cycles, go to sleep at 23:15 (7h 30m) - - 4 cycles, go to sleep at 00:45 (6h 00m) + 6 cycles → sleep at 21:45 (9h 00m) + 5 cycles → sleep at 23:15 (7h 30m) + 4 cycles → sleep at 00:45 (6h 00m) ``` #### `--from 22:00 --to 07:00` ```text -Between 22:00 and 07:00: -─────────────────────── +Between 22:00 and 07:00 +────────────────────────────── Assuming 15 min to fall asleep - - 5 complete cycles (7h 30m) - - 75 minutes remaining + 5 complete cycles (7h 30m) + 75 minutes remaining ``` -## Flags +## Flag reference | Flag | Description | Default | |---|---|---:| From d45777939ddbce0f6ee7f2bd9306f42dc7f480ab Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:16:24 +0300 Subject: [PATCH 6/8] Bump version number to 1.0.0 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index e88ca33..392924b 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/pflag" ) -const version = "0.2.0" +const version = "1.0.0" func main() { help.SetupCustomHelp() From e79781b75db7f0d73dd9a3670a71e176a6c558ae Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:27:15 +0300 Subject: [PATCH 7/8] Fix linter errors The code was previously ignoring errors. Added default return if error --- internal/tui/tui.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f87284d..0f7d70e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strconv" "strings" "time" "unicode" @@ -252,30 +253,24 @@ func activeSettingsInput(m *Model) *textInput { } func (m Model) bufferMinutes() int { - if m.inputBuffer.value == "" { - return 15 + if n, err := strconv.Atoi(m.inputBuffer.value); err == nil { + return n } - var n int - fmt.Sscanf(m.inputBuffer.value, "%d", &n) - return n + return 15 // default buffer } func (m Model) cyclesMin() int { - if m.inputCyclesMin.value == "" { - return 4 + if n, err := strconv.Atoi(m.inputCyclesMin.value); err == nil { + return n } - var n int - fmt.Sscanf(m.inputCyclesMin.value, "%d", &n) - return n + return 4 // default min cycles } func (m Model) cyclesMax() int { - if m.inputCyclesMax.value == "" { - return 6 + if n, err := strconv.Atoi(m.inputCyclesMax.value); err == nil { + return n } - var n int - fmt.Sscanf(m.inputCyclesMax.value, "%d", &n) - return n + return 6 // default max cycles } func (m Model) computeResult() string { From 937bf64eaef567190808c469a6d638971f567b9c Mon Sep 17 00:00:00 2001 From: TyostoKarry <114697841+TyostoKarry@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:31:43 +0300 Subject: [PATCH 8/8] Fix staticcheck linting errors in render package Replace redundant WriteString(fmt.Sprintf(...)) with single fmt.Fprintf(...) --- internal/render/render.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/render/render.go b/internal/render/render.go index aeb58a6..67641af 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -24,10 +24,10 @@ func WakeTimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, h sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes()))) + "\n\n") for i := len(wakeTimes) - 1; i >= 0; i-- { c := minCycles + i - sb.WriteString(fmt.Sprintf(" %s cycles → wake at %s %s\n", + fmt.Fprintf(&sb, " %s cycles → wake at %s %s\n", styles.Result.Render(fmt.Sprintf("%d", c)), styles.Result.Render(wakeTimes[i].Format("15:04")), - styles.Dim.Render("("+FormatDuration(c)+")"))) + styles.Dim.Render("("+FormatDuration(c)+")")) } return sb.String() } @@ -40,10 +40,10 @@ func Bedtimes(base time.Time, buffer time.Duration, minCycles, maxCycles int, he sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", int(buffer.Minutes()))) + "\n\n") for i := len(bedTimes) - 1; i >= 0; i-- { c := minCycles + i - sb.WriteString(fmt.Sprintf(" %s cycles → sleep at %s %s\n", + fmt.Fprintf(&sb, " %s cycles → sleep at %s %s\n", styles.Result.Render(fmt.Sprintf("%d", c)), styles.Result.Render(bedTimes[i].Format("15:04")), - styles.Dim.Render("("+FormatDuration(c)+")"))) + styles.Dim.Render("("+FormatDuration(c)+")")) } return sb.String() } @@ -55,10 +55,10 @@ func Window(from, to string, fromTime, toTime time.Time, bufferMinutes int) stri sb.WriteString(styles.Result.Render(fmt.Sprintf("Between %s and %s", from, to)) + "\n") sb.WriteString(styles.Separator.Render(strings.Repeat("─", 30)) + "\n") sb.WriteString(styles.Dim.Render(fmt.Sprintf("Assuming %d min to fall asleep", bufferMinutes)) + "\n\n") - sb.WriteString(fmt.Sprintf(" %s complete cycles %s\n", + fmt.Fprintf(&sb, " %s complete cycles %s\n", styles.Result.Render(fmt.Sprintf("%d", cycles)), - styles.Dim.Render("("+FormatDuration(cycles)+")"))) - sb.WriteString(fmt.Sprintf(" %s minutes remaining\n", - styles.Result.Render(fmt.Sprintf("%d", int(remainder.Minutes()))))) + styles.Dim.Render("("+FormatDuration(cycles)+")")) + fmt.Fprintf(&sb, " %s minutes remaining\n", + styles.Result.Render(fmt.Sprintf("%d", int(remainder.Minutes())))) return sb.String() }