diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..90239d0 --- /dev/null +++ b/constants.go @@ -0,0 +1,10 @@ +package main + +//Table definition constants +const ( + numRows = 6 + numCols = 7 + cellW = 10 + cellH = 1 +) + 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..08217df 100644 --- a/main.go +++ b/main.go @@ -1,73 +1,31 @@ -// main.go package main -// A simple program demonstrating the spinner component from the Bubbles -// component library. - import ( "fmt" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "os" + "time" + + tea "github.com/charmbracelet/bubbletea" ) -type errMsg error - -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} -} - -func (m model) Init() tea.Cmd { - return m.spinner.Tick -} - -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 - return m, tea.Quit - default: - return m, nil - } - - case errMsg: - m.err = msg - return m, nil - - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } -} - -func (m model) View() string { - if m.err != nil { - return m.err.Error() - } - 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 -} - func main() { - p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { - fmt.Println(err) + // 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(), + 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\n", err) os.Exit(1) } } + diff --git a/model.go b/model.go new file mode 100644 index 0000000..eadacba --- /dev/null +++ b/model.go @@ -0,0 +1,31 @@ +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 ( + monthView viewMode = iota + hourlyView +) + +type model struct { + 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 // 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 // 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 7710b0f..190b6a6 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,49 @@ -# 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. + diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..85d4a10 --- /dev/null +++ b/styles.go @@ -0,0 +1,28 @@ +package main + +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) + // 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")) + // 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")) + // 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) +) + 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 +} +