From af03d06bf5539bf2aeec2f601273db3dfdfabab5 Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 21:39:40 -0400 Subject: [PATCH 1/8] got month grid going --- go.sum | 4 +++ main.go | 95 ++++++++++++++++++++++++++++++++------------------------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/go.sum b/go.sum index 73a85ed..48041bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= @@ -12,6 +14,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/main.go b/main.go index 72afb3f..bd3c95d 100644 --- a/main.go +++ b/main.go @@ -1,73 +1,86 @@ -// main.go package main -// A simple program demonstrating the spinner component from the Bubbles -// component library. - import ( "fmt" - "github.com/charmbracelet/bubbles/spinner" + "os" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "os" ) -type errMsg error +const ( + numRows = 5 + numCols = 7 + cellW = 10 + cellH = 1 +) + +var ( + cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) + selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) +) type model struct { - spinner spinner.Model - quitting bool - err error -} - -func initialModel() model { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - return model{spinner: s} + cursorRow int + cursorCol int } func (m model) Init() tea.Cmd { - return m.spinner.Tick + return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "q", "esc", "ctrl+c": - m.quitting = true + case "ctrl+c", "q": return m, tea.Quit - default: - return m, nil + case "up": + if m.cursorRow > 0 { + m.cursorRow-- + } + case "down": + if m.cursorRow < numRows-1 { + m.cursorRow++ + } + case "left": + if m.cursorCol > 0 { + m.cursorCol-- + } + case "right": + if m.cursorCol < numCols-1 { + m.cursorCol++ + } } - - case errMsg: - m.err = msg - return m, nil - - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd } + return m, nil } func (m model) View() string { - if m.err != nil { - return m.err.Error() + var out string + count := 1 + for r := 0; r < numRows; r++ { + for c := 0; c < numCols; c++ { + label := fmt.Sprintf("Cell %02d", count) + if r == m.cursorRow && c == m.cursorCol { + out += selectedStyle.Render(label) + } else { + out += unselectedStyle.Render(label) + } + count++ + } + out += "\n" } - str := fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", m.spinner.View()) - if m.quitting { - return str + "\n" - } - return str + out += "\nUse arrow keys to move, q to quit." + return out } func main() { - p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { - fmt.Println(err) + p := tea.NewProgram(model{}) + if err := p.Start(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) os.Exit(1) } } + From e425b0ad6543e68ebe378adf7f1e82d72b83ff68 Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 21:47:32 -0400 Subject: [PATCH 2/8] added pagination for months --- main.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/main.go b/main.go index bd3c95d..3ba217b 100644 --- a/main.go +++ b/main.go @@ -3,27 +3,32 @@ package main import ( "fmt" "os" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( - numRows = 5 - numCols = 7 + numRows = 6 // Max possible rows in a calendar month view + numCols = 7 // Days in a week cellW = 10 cellH = 1 ) var ( - cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) - selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) + selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) + headerStyle = lipgloss.NewStyle().Width(numCols * cellW).Align(lipgloss.Center).Bold(true) + daysOfWeekStyle = lipgloss.NewStyle().Width(cellW).Align(lipgloss.Center).Bold(true) ) type model struct { - cursorRow int - cursorCol int + cursorRow int + cursorCol int + monthIndex int + year int } func (m model) Init() tea.Cmd { @@ -52,6 +57,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursorCol < numCols-1 { m.cursorCol++ } + case "a": // Previous month + if m.monthIndex == 0 { + m.monthIndex = 11 + m.year-- + } else { + m.monthIndex-- + } + case "d": // Next month + if m.monthIndex == 11 { + m.monthIndex = 0 + m.year++ + } else { + m.monthIndex++ + } } } return m, nil @@ -59,25 +78,56 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() string { var out string - count := 1 + + // Header + monthTime := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + header := headerStyle.Render(monthTime.Format("January 2006")) + out += header + "\n" + + // Days of the week header + weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + for _, day := range weekdays { + out += daysOfWeekStyle.Render(day) + } + out += "\n" + + // First weekday and number of days + firstDay := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + startWeekday := int(firstDay.Weekday()) + daysInMonth := time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() + + // Calendar grid + day := 1 for r := 0; r < numRows; r++ { for c := 0; c < numCols; c++ { - label := fmt.Sprintf("Cell %02d", count) - if r == m.cursorRow && c == m.cursorCol { - out += selectedStyle.Render(label) + cellNum := r*numCols + c + var content string + + if cellNum >= startWeekday && day <= daysInMonth { + content = fmt.Sprintf("%2d", day) + if r == m.cursorRow && c == m.cursorCol { + out += selectedStyle.Render(content) + } else { + out += unselectedStyle.Render(content) + } + day++ } else { - out += unselectedStyle.Render(label) + out += cellStyle.Render("") // Empty cell } - count++ } out += "\n" } - out += "\nUse arrow keys to move, q to quit." + + out += "\n[a]/[d] to change month, arrows to move, q to quit." return out } func main() { - p := tea.NewProgram(model{}) + now := time.Now() + p := tea.NewProgram(model{ + year: now.Year(), + monthIndex: int(now.Month()) - 1, + }) if err := p.Start(); err != nil { fmt.Fprintf(os.Stderr, "error: %v", err) os.Exit(1) From bc1b4a223875d3b1e92e17842377c2a5e8a6263d Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 22:03:06 -0400 Subject: [PATCH 3/8] added current date highlight and the ability to switch to the daily schecule by pressing enter --- main.go | 187 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 153 insertions(+), 34 deletions(-) diff --git a/main.go b/main.go index 3ba217b..283ac15 100644 --- a/main.go +++ b/main.go @@ -10,25 +10,44 @@ import ( ) const ( - numRows = 6 // Max possible rows in a calendar month view - numCols = 7 // Days in a week + numRows = 6 + numCols = 7 cellW = 10 cellH = 1 ) var ( cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) - selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) // Blue bg unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) + todayStyle = cellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text headerStyle = lipgloss.NewStyle().Width(numCols * cellW).Align(lipgloss.Center).Bold(true) daysOfWeekStyle = lipgloss.NewStyle().Width(cellW).Align(lipgloss.Center).Bold(true) + + hourCellStyle = lipgloss.NewStyle().Width(cellW * 3).Height(cellH).Align(lipgloss.Left).PaddingLeft(1) + hourSelectedStyle = hourCellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) +) + +type viewMode int + +const ( + monthView viewMode = iota + hourlyView ) type model struct { - cursorRow int - cursorCol int - monthIndex int - year int + cursorRow int + cursorCol int + monthIndex int + year int + startOffset int // weekday offset where day 1 starts + daysInMonth int + + mode viewMode + + // For hourly view + selectedDay int + hourCursor int // which hour (0-23) is selected in hourly view } func (m model) Init() tea.Cmd { @@ -36,11 +55,50 @@ func (m model) Init() tea.Cmd { } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.mode { + case monthView: + return m.updateMonthView(msg) + case hourlyView: + return m.updateHourlyView(msg) + default: + return m, nil + } +} + +func (m model) updateMonthView(msg tea.Msg) (tea.Model, tea.Cmd) { + firstDay := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + m.startOffset = int(firstDay.Weekday()) + m.daysInMonth = time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() + switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit + case "a": // Previous month + if m.monthIndex == 0 { + m.monthIndex = 11 + m.year-- + } else { + m.monthIndex-- + } + first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + offset := int(first.Weekday()) + m.cursorRow = offset / numCols + m.cursorCol = offset % numCols + return m, nil + case "d": // Next month + if m.monthIndex == 11 { + m.monthIndex = 0 + m.year++ + } else { + m.monthIndex++ + } + first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + offset := int(first.Weekday()) + m.cursorRow = offset / numCols + m.cursorCol = offset % numCols + return m, nil case "up": if m.cursorRow > 0 { m.cursorRow-- @@ -57,19 +115,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursorCol < numCols-1 { m.cursorCol++ } - case "a": // Previous month - if m.monthIndex == 0 { - m.monthIndex = 11 - m.year-- - } else { - m.monthIndex-- + case "enter": + // Calculate selected day based on cursor + dayIndex := m.cursorRow*numCols + m.cursorCol + if dayIndex >= m.startOffset && dayIndex < m.startOffset+m.daysInMonth { + m.selectedDay = dayIndex - m.startOffset + 1 + m.hourCursor = 0 + m.mode = hourlyView } - case "d": // Next month - if m.monthIndex == 11 { - m.monthIndex = 0 - m.year++ - } else { - m.monthIndex++ + } + + // Clamp cursor to valid days + dayIndex := m.cursorRow*numCols + m.cursorCol + if dayIndex < m.startOffset || dayIndex >= m.startOffset+m.daysInMonth { + m.cursorRow = m.startOffset / numCols + m.cursorCol = m.startOffset % numCols + } + return m, nil + default: + return m, nil + } +} + +func (m model) updateHourlyView(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.mode = monthView + return m, nil + case "ctrl+c", "q": + return m, tea.Quit + case "up": + if m.hourCursor > 0 { + m.hourCursor-- + } + case "down": + if m.hourCursor < 23 { + m.hourCursor++ } } } @@ -77,59 +160,95 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { + switch m.mode { + case monthView: + return m.viewMonth() + case hourlyView: + return m.viewHourly() + default: + return "" + } +} + +func (m model) viewMonth() string { var out string - // Header monthTime := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) header := headerStyle.Render(monthTime.Format("January 2006")) out += header + "\n" - // Days of the week header weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} for _, day := range weekdays { out += daysOfWeekStyle.Render(day) } out += "\n" - // First weekday and number of days - firstDay := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) - startWeekday := int(firstDay.Weekday()) - daysInMonth := time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() - - // Calendar grid day := 1 for r := 0; r < numRows; r++ { for c := 0; c < numCols; c++ { cellNum := r*numCols + c var content string - if cellNum >= startWeekday && day <= daysInMonth { + if cellNum >= m.startOffset && day <= m.daysInMonth { content = fmt.Sprintf("%2d", day) if r == m.cursorRow && c == m.cursorCol { out += selectedStyle.Render(content) } else { - out += unselectedStyle.Render(content) + today := time.Now() + isToday := m.year == today.Year() && + m.monthIndex == int(today.Month())-1 && + day == today.Day() + if isToday { + out += todayStyle.Render(content) + } else { + out += unselectedStyle.Render(content) + } } day++ } else { - out += cellStyle.Render("") // Empty cell + out += cellStyle.Render("") } } out += "\n" } - out += "\n[a]/[d] to change month, arrows to move, q to quit." + out += "\n[a]/[d] to change month, arrows to move, enter to select a day, q to quit." + return out +} + +func (m model) viewHourly() string { + var out string + + dateHeader := fmt.Sprintf("Schedule for %04d-%02d-%02d", m.year, m.monthIndex+1, m.selectedDay) + out += headerStyle.Render(dateHeader) + "\n\n" + + for hour := 0; hour < 24; hour++ { + label := fmt.Sprintf("%02d:00 - %02d:00", hour, hour+1) + if hour == m.hourCursor { + out += hourSelectedStyle.Render(label) + "\n" + } else { + out += hourCellStyle.Render(label) + "\n" + } + } + + out += "\nPress ESC to return to month view, q to quit." return out } func main() { now := time.Now() + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + offset := int(start.Weekday()) p := tea.NewProgram(model{ - year: now.Year(), - monthIndex: int(now.Month()) - 1, + year: now.Year(), + monthIndex: int(now.Month()) - 1, + cursorRow: offset / numCols, + cursorCol: offset % numCols, + startOffset: offset, + mode: monthView, }) if err := p.Start(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v", err) + fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } } From b33d3acf227f7608c271d351d834d9fa33398bfc Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 22:10:36 -0400 Subject: [PATCH 4/8] added current hour highlight --- main.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 283ac15..c77d1af 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,8 @@ var ( hourCellStyle = lipgloss.NewStyle().Width(cellW * 3).Height(cellH).Align(lipgloss.Left).PaddingLeft(1) hourSelectedStyle = hourCellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + // New style for current hour highlight (purple background, white text) + currentHourStyle = hourCellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) ) type viewMode int @@ -222,11 +224,19 @@ func (m model) viewHourly() string { dateHeader := fmt.Sprintf("Schedule for %04d-%02d-%02d", m.year, m.monthIndex+1, m.selectedDay) out += headerStyle.Render(dateHeader) + "\n\n" + nowHour := time.Now().Hour() + for hour := 0; hour < 24; hour++ { label := fmt.Sprintf("%02d:00 - %02d:00", hour, hour+1) - if hour == m.hourCursor { + + switch { + case hour == m.hourCursor: + // selected hour gets blue highlight out += hourSelectedStyle.Render(label) + "\n" - } else { + case hour == nowHour: + // current hour gets purple highlight + out += currentHourStyle.Render(label) + "\n" + default: out += hourCellStyle.Render(label) + "\n" } } From e59765bfc6dd8093ceb7f4aac6a6af054cffaa00 Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 22:42:18 -0400 Subject: [PATCH 5/8] restructured main --- main.go | 237 ------------------------------------------------------ readme.md | 49 ++++++++++- 2 files changed, 48 insertions(+), 238 deletions(-) diff --git a/main.go b/main.go index c77d1af..6c9c9b1 100644 --- a/main.go +++ b/main.go @@ -6,245 +6,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) -const ( - numRows = 6 - numCols = 7 - cellW = 10 - cellH = 1 -) - -var ( - cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) - selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) // Blue bg - unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) - todayStyle = cellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text - headerStyle = lipgloss.NewStyle().Width(numCols * cellW).Align(lipgloss.Center).Bold(true) - daysOfWeekStyle = lipgloss.NewStyle().Width(cellW).Align(lipgloss.Center).Bold(true) - - hourCellStyle = lipgloss.NewStyle().Width(cellW * 3).Height(cellH).Align(lipgloss.Left).PaddingLeft(1) - hourSelectedStyle = hourCellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) - // New style for current hour highlight (purple background, white text) - currentHourStyle = hourCellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) -) - -type viewMode int - -const ( - monthView viewMode = iota - hourlyView -) - -type model struct { - cursorRow int - cursorCol int - monthIndex int - year int - startOffset int // weekday offset where day 1 starts - daysInMonth int - - mode viewMode - - // For hourly view - selectedDay int - hourCursor int // which hour (0-23) is selected in hourly view -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch m.mode { - case monthView: - return m.updateMonthView(msg) - case hourlyView: - return m.updateHourlyView(msg) - default: - return m, nil - } -} - -func (m model) updateMonthView(msg tea.Msg) (tea.Model, tea.Cmd) { - firstDay := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) - m.startOffset = int(firstDay.Weekday()) - m.daysInMonth = time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "a": // Previous month - if m.monthIndex == 0 { - m.monthIndex = 11 - m.year-- - } else { - m.monthIndex-- - } - first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) - offset := int(first.Weekday()) - m.cursorRow = offset / numCols - m.cursorCol = offset % numCols - return m, nil - case "d": // Next month - if m.monthIndex == 11 { - m.monthIndex = 0 - m.year++ - } else { - m.monthIndex++ - } - first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) - offset := int(first.Weekday()) - m.cursorRow = offset / numCols - m.cursorCol = offset % numCols - return m, nil - case "up": - if m.cursorRow > 0 { - m.cursorRow-- - } - case "down": - if m.cursorRow < numRows-1 { - m.cursorRow++ - } - case "left": - if m.cursorCol > 0 { - m.cursorCol-- - } - case "right": - if m.cursorCol < numCols-1 { - m.cursorCol++ - } - case "enter": - // Calculate selected day based on cursor - dayIndex := m.cursorRow*numCols + m.cursorCol - if dayIndex >= m.startOffset && dayIndex < m.startOffset+m.daysInMonth { - m.selectedDay = dayIndex - m.startOffset + 1 - m.hourCursor = 0 - m.mode = hourlyView - } - } - - // Clamp cursor to valid days - dayIndex := m.cursorRow*numCols + m.cursorCol - if dayIndex < m.startOffset || dayIndex >= m.startOffset+m.daysInMonth { - m.cursorRow = m.startOffset / numCols - m.cursorCol = m.startOffset % numCols - } - return m, nil - default: - return m, nil - } -} - -func (m model) updateHourlyView(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "esc": - m.mode = monthView - return m, nil - case "ctrl+c", "q": - return m, tea.Quit - case "up": - if m.hourCursor > 0 { - m.hourCursor-- - } - case "down": - if m.hourCursor < 23 { - m.hourCursor++ - } - } - } - return m, nil -} - -func (m model) View() string { - switch m.mode { - case monthView: - return m.viewMonth() - case hourlyView: - return m.viewHourly() - default: - return "" - } -} - -func (m model) viewMonth() string { - var out string - - monthTime := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) - header := headerStyle.Render(monthTime.Format("January 2006")) - out += header + "\n" - - weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} - for _, day := range weekdays { - out += daysOfWeekStyle.Render(day) - } - out += "\n" - - day := 1 - for r := 0; r < numRows; r++ { - for c := 0; c < numCols; c++ { - cellNum := r*numCols + c - var content string - - if cellNum >= m.startOffset && day <= m.daysInMonth { - content = fmt.Sprintf("%2d", day) - if r == m.cursorRow && c == m.cursorCol { - out += selectedStyle.Render(content) - } else { - today := time.Now() - isToday := m.year == today.Year() && - m.monthIndex == int(today.Month())-1 && - day == today.Day() - if isToday { - out += todayStyle.Render(content) - } else { - out += unselectedStyle.Render(content) - } - } - day++ - } else { - out += cellStyle.Render("") - } - } - out += "\n" - } - - out += "\n[a]/[d] to change month, arrows to move, enter to select a day, q to quit." - return out -} - -func (m model) viewHourly() string { - var out string - - dateHeader := fmt.Sprintf("Schedule for %04d-%02d-%02d", m.year, m.monthIndex+1, m.selectedDay) - out += headerStyle.Render(dateHeader) + "\n\n" - - nowHour := time.Now().Hour() - - for hour := 0; hour < 24; hour++ { - label := fmt.Sprintf("%02d:00 - %02d:00", hour, hour+1) - - switch { - case hour == m.hourCursor: - // selected hour gets blue highlight - out += hourSelectedStyle.Render(label) + "\n" - case hour == nowHour: - // current hour gets purple highlight - out += currentHourStyle.Render(label) + "\n" - default: - out += hourCellStyle.Render(label) + "\n" - } - } - - out += "\nPress ESC to return to month view, q to quit." - return out -} - func main() { now := time.Now() start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) diff --git a/readme.md b/readme.md index 7710b0f..7cc29c4 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,48 @@ -# GoCal \ No newline at end of file +# GoCal + +This is a terminal-based calendar and hourly schedule viewer built using [Bubble Tea](https://github.com/charmbracelet/bubbletea) and styled with [Lipgloss](https://github.com/charmbracelet/lipgloss). + +--- + +## Project Structure + +| File | Description | +|----------------|-------------------------------------------------------------------| +| `main.go` | Entry point of the application. Initializes the program and sets the initial model state (current year/month and cursor position). | +| `constants.go` | Defines global constants such as calendar grid size and cell dimensions used throughout the app. | +| `styles.go` | Contains all the Lipgloss style definitions for calendar cells, headers, and hourly schedule views. | +| `model.go` | Defines the data model struct (`model`) that holds the application state, and the `viewMode` type representing current UI mode. Also contains the `Init` function for Bubble Tea. | +| `update.go` | Implements all update logic for handling user input and updating the model state in both month and hourly views. Includes the fix to properly update cursor position when switching months. | +| `view.go` | Contains all rendering logic that converts the current model state into styled terminal output for both month and hourly views. | + +--- + +## Features + +- Month view with navigable calendar grid. +- Highlighted current day. +- Cursor highlighting with keyboard navigation. +- Enter key switches to hourly schedule view for the selected day. +- Hourly view with current hour highlighted. +- Switch back to month view with ESC. + +--- + +## Usage + +Run the program: + +```bash +go run . +``` +Controls: + +- [a] / [d]: Navigate previous / next month. + +- Arrow keys: Move cursor around days. + +- [Enter]: Switch to hourly view for selected day. + +- [Esc]: Return to month view. + +- [q] / Ctrl+C: Quit. From bbbd0d60b695809214912e25908f31e484776447 Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 22:42:27 -0400 Subject: [PATCH 6/8] restructured main --- constants.go | 9 ++++ model.go | 30 +++++++++++++ styles.go | 19 ++++++++ update.go | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++ view.go | 89 ++++++++++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+) create mode 100644 constants.go create mode 100644 model.go create mode 100644 styles.go create mode 100644 update.go create mode 100644 view.go diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..12cf83a --- /dev/null +++ b/constants.go @@ -0,0 +1,9 @@ +package main + +const ( + numRows = 6 + numCols = 7 + cellW = 10 + cellH = 1 +) + diff --git a/model.go b/model.go new file mode 100644 index 0000000..f0c78b7 --- /dev/null +++ b/model.go @@ -0,0 +1,30 @@ +package main + +import tea "github.com/charmbracelet/bubbletea" + +type viewMode int + +const ( + monthView viewMode = iota + hourlyView +) + +type model struct { + cursorRow int + cursorCol int + monthIndex int + year int + startOffset int // weekday offset where day 1 starts + daysInMonth int + + mode viewMode + + // For hourly view + selectedDay int + hourCursor int // which hour (0-23) is selected in hourly view +} + +func (m model) Init() tea.Cmd { + return nil +} + diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..22b219a --- /dev/null +++ b/styles.go @@ -0,0 +1,19 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +// Styles for calendar cells and headers +var ( + cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) + selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) // Blue bg + unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) + todayStyle = cellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text + headerStyle = lipgloss.NewStyle().Width(numCols * cellW).Align(lipgloss.Center).Bold(true) + daysOfWeekStyle = lipgloss.NewStyle().Width(cellW).Align(lipgloss.Center).Bold(true) + + // Styles for hourly view cells + hourCellStyle = lipgloss.NewStyle().Width(cellW * 3).Height(cellH).Align(lipgloss.Left).PaddingLeft(1) + hourSelectedStyle = hourCellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + currentHourStyle = hourCellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text +) + diff --git a/update.go b/update.go new file mode 100644 index 0000000..fe720d3 --- /dev/null +++ b/update.go @@ -0,0 +1,125 @@ +package main + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.mode { + case monthView: + return m.updateMonthView(msg) + case hourlyView: + return m.updateHourlyView(msg) + default: + return m, nil + } +} + +func (m model) updateMonthView(msg tea.Msg) (tea.Model, tea.Cmd) { + firstDay := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + m.startOffset = int(firstDay.Weekday()) + m.daysInMonth = time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "a": // Previous month + if m.monthIndex == 0 { + m.monthIndex = 11 + m.year-- + } else { + m.monthIndex-- + } + + first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + offset := int(first.Weekday()) + + m.startOffset = offset + m.daysInMonth = time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() + + m.cursorRow = offset / numCols + m.cursorCol = offset % numCols + + return m, nil + case "d": // Next month + if m.monthIndex == 11 { + m.monthIndex = 0 + m.year++ + } else { + m.monthIndex++ + } + + first := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + offset := int(first.Weekday()) + + m.startOffset = offset + m.daysInMonth = time.Date(m.year, time.Month(m.monthIndex+2), 0, 0, 0, 0, 0, time.UTC).Day() + + m.cursorRow = offset / numCols + m.cursorCol = offset % numCols + + return m, nil + case "up": + if m.cursorRow > 0 { + m.cursorRow-- + } + case "down": + if m.cursorRow < numRows-1 { + m.cursorRow++ + } + case "left": + if m.cursorCol > 0 { + m.cursorCol-- + } + case "right": + if m.cursorCol < numCols-1 { + m.cursorCol++ + } + case "enter": + // Calculate selected day based on cursor + dayIndex := m.cursorRow*numCols + m.cursorCol + if dayIndex >= m.startOffset && dayIndex < m.startOffset+m.daysInMonth { + m.selectedDay = dayIndex - m.startOffset + 1 + m.hourCursor = 0 + m.mode = hourlyView + } + } + + // Clamp cursor to valid days + dayIndex := m.cursorRow*numCols + m.cursorCol + if dayIndex < m.startOffset || dayIndex >= m.startOffset+m.daysInMonth { + m.cursorRow = m.startOffset / numCols + m.cursorCol = m.startOffset % numCols + } + return m, nil + default: + return m, nil + } +} + +func (m model) updateHourlyView(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.mode = monthView + return m, nil + case "ctrl+c", "q": + return m, tea.Quit + case "up": + if m.hourCursor > 0 { + m.hourCursor-- + } + case "down": + if m.hourCursor < 23 { + m.hourCursor++ + } + } + } + return m, nil +} + diff --git a/view.go b/view.go new file mode 100644 index 0000000..3f91ac4 --- /dev/null +++ b/view.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "time" +) + +func (m model) View() string { + switch m.mode { + case monthView: + return m.viewMonth() + case hourlyView: + return m.viewHourly() + default: + return "" + } +} + +func (m model) viewMonth() string { + var out string + + monthTime := time.Date(m.year, time.Month(m.monthIndex+1), 1, 0, 0, 0, 0, time.UTC) + header := headerStyle.Render(monthTime.Format("January 2006")) + out += header + "\n" + + weekdays := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + for _, day := range weekdays { + out += daysOfWeekStyle.Render(day) + } + out += "\n" + + day := 1 + for r := 0; r < numRows; r++ { + for c := 0; c < numCols; c++ { + cellNum := r*numCols + c + var content string + + if cellNum >= m.startOffset && day <= m.daysInMonth { + content = fmt.Sprintf("%2d", day) + if r == m.cursorRow && c == m.cursorCol { + out += selectedStyle.Render(content) + } else { + today := time.Now() + isToday := m.year == today.Year() && + m.monthIndex == int(today.Month())-1 && + day == today.Day() + if isToday { + out += todayStyle.Render(content) + } else { + out += unselectedStyle.Render(content) + } + } + day++ + } else { + out += cellStyle.Render("") + } + } + out += "\n" + } + + out += "\n[a]/[d] to change month, arrows to move, enter to select a day, q to quit." + return out +} + +func (m model) viewHourly() string { + var out string + + dateHeader := fmt.Sprintf("Schedule for %04d-%02d-%02d", m.year, m.monthIndex+1, m.selectedDay) + out += headerStyle.Render(dateHeader) + "\n\n" + + nowHour := time.Now().Hour() + + for hour := 0; hour < 24; hour++ { + label := fmt.Sprintf("%02d:00 - %02d:00", hour, hour+1) + + switch { + case hour == m.hourCursor: + out += hourSelectedStyle.Render(label) + "\n" + case hour == nowHour: + out += currentHourStyle.Render(label) + "\n" + default: + out += hourCellStyle.Render(label) + "\n" + } + } + + out += "\nPress ESC to return to month view, q to quit." + return out +} + From 3071ce425045ee70059faffdddee4e3796ee8e6a Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 23:08:27 -0400 Subject: [PATCH 7/8] annotated main --- constants.go | 1 + main.go | 3 +++ model.go | 15 ++++++++------- readme.md | 2 ++ styles.go | 15 ++++++++++++--- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/constants.go b/constants.go index 12cf83a..90239d0 100644 --- a/constants.go +++ b/constants.go @@ -1,5 +1,6 @@ package main +//Table definition constants const ( numRows = 6 numCols = 7 diff --git a/main.go b/main.go index 6c9c9b1..08217df 100644 --- a/main.go +++ b/main.go @@ -9,8 +9,11 @@ import ( ) func main() { + // Get the current datetime now := time.Now() + // Set the start date used for opening the current month instead of 1/1/1970 start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + // This gets the weekday of the first of the month so that we have the correct day with the correct date offset := int(start.Weekday()) p := tea.NewProgram(model{ year: now.Year(), diff --git a/model.go b/model.go index f0c78b7..eadacba 100644 --- a/model.go +++ b/model.go @@ -1,7 +1,7 @@ package main import tea "github.com/charmbracelet/bubbletea" - +// viewMode is used to store which view is being currently seen (i.e. month or hour) type viewMode int const ( @@ -10,20 +10,21 @@ const ( ) type model struct { - cursorRow int - cursorCol int - monthIndex int - year int + cursorRow int // which row the cursor is sitting on used for cursor highlight + cursorCol int // which col the cursor is sitiing on used for cursor highlight + monthIndex int // what month 1-12 that is being displayed + year int // the current year being displayed startOffset int // weekday offset where day 1 starts - daysInMonth int + daysInMonth int // a count of how many days there are in the month to account for 30,31,or 28 mode viewMode // For hourly view - selectedDay int + selectedDay int // which day we are looking at in hourly view hourCursor int // which hour (0-23) is selected in hourly view } +//No init state func (m model) Init() tea.Cmd { return nil } diff --git a/readme.md b/readme.md index 7cc29c4..0f260ff 100644 --- a/readme.md +++ b/readme.md @@ -46,3 +46,5 @@ Controls: - [Esc]: Return to month view. - [q] / Ctrl+C: Quit. + +## TODO: anotate the update and view files diff --git a/styles.go b/styles.go index 22b219a..85d4a10 100644 --- a/styles.go +++ b/styles.go @@ -4,16 +4,25 @@ import "github.com/charmbracelet/lipgloss" // Styles for calendar cells and headers var ( + // this is the style thats used as the default style for the month view cellStyle = lipgloss.NewStyle().Width(cellW).Height(cellH).Align(lipgloss.Center) - selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) // Blue bg + // this is the style that sets the current selected hours bg to orange and text to white + selectedStyle = cellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) + // this is the style that sets the color of cells that are not in use for a given month unselectedStyle = cellStyle.Copy().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("250")) - todayStyle = cellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text + // this is the style that sets the color of todays cell to purple bg white text + todayStyle = cellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) + // this is the style that defines the header where the current months name goes headerStyle = lipgloss.NewStyle().Width(numCols * cellW).Align(lipgloss.Center).Bold(true) + // this is the style that defines the DoW header where mon-sun go daysOfWeekStyle = lipgloss.NewStyle().Width(cellW).Align(lipgloss.Center).Bold(true) // Styles for hourly view cells + // this is the style thats used as the default for the hours view hourCellStyle = lipgloss.NewStyle().Width(cellW * 3).Height(cellH).Align(lipgloss.Left).PaddingLeft(1) + // This is the style that sets the current selected hours style to orange bg white text hourSelectedStyle = hourCellStyle.Copy().Bold(true).Background(lipgloss.Color("12")).Foreground(lipgloss.Color("15")) - currentHourStyle = hourCellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) // Purple bg + white text + // this is the style that sets the current hour to purple bg and white text + currentHourStyle = hourCellStyle.Copy().Background(lipgloss.Color("99")).Foreground(lipgloss.Color("15")).Bold(true) ) From cbf5189ef75342c6798de3db1b516b3054069abb Mon Sep 17 00:00:00 2001 From: steven carpenter Date: Sat, 28 Jun 2025 23:08:50 -0400 Subject: [PATCH 8/8] updated readme --- readme.md | 1 - 1 file changed, 1 deletion(-) diff --git a/readme.md b/readme.md index 0f260ff..190b6a6 100644 --- a/readme.md +++ b/readme.md @@ -47,4 +47,3 @@ Controls: - [q] / Ctrl+C: Quit. -## TODO: anotate the update and view files