package dateinput import ( "errors" "fmt" "log" "strconv" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) // TODO: This could probably be its own repo // focus denotes which field in this input is focused. type focus int const ( none focus = iota year month day ) type Model struct { year textinput.Model month textinput.Model day textinput.Model focused focus } func New(defaultDate time.Time, focused bool) Model { d := Model{ year: textinput.New(), month: textinput.New(), day: textinput.New(), } d.year.Placeholder = "YYYY" d.year.Prompt = "" d.year.Width = 4 d.year.SetValue(strconv.Itoa(defaultDate.Year())) d.year.CharLimit = 4 d.year.Validate = yearValidator d.month.Placeholder = "MM" d.month.Prompt = "" d.month.Width = 2 d.month.SetValue(defaultDate.Format("01")) d.month.CharLimit = 2 d.month.Validate = monthValidator d.day.Placeholder = "DD" d.day.Prompt = "" d.day.Width = 2 d.day.SetValue(defaultDate.Format("02")) d.day.CharLimit = 2 d.day.Validate = dayValidator if focused { d.log("Setting initial focus to year") d.focused = year d.year.Focus() } return d } func yearValidator(s string) error { // The input already restricts to 4 digits, so we just need to ensure the // value is greater than 0 (technically). val, err := strconv.Atoi(s) if err != nil { return err } if val <= 0 { return errors.New("year must be positive") } // TODO: Ensure that the month-day combination is still valid. return nil } func monthValidator(s string) error { // The input already restricts to 4 digits, so we just need to ensure the // value is between 1 and 12. val, err := strconv.Atoi(s) if err != nil { return err } if val < 1 || val > 12 { return errors.New("month must be between 1 and 12") } // TODO: Ensure that the month-day combination is valid. return nil } func dayValidator(s string) error { // The input already restricts to 4 digits, so we just need to ensure the // value is between 1 and 31. val, err := strconv.Atoi(s) if err != nil { return err } if val < 1 || val > 31 { return errors.New("day must be between 1 and 31") } // TODO: Ensure that the month-day combination is valid. return nil } func (m Model) Init() tea.Cmd { // Sub-components do not have Init functions to call. return textinput.Blink } func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds := make([]tea.Cmd, 3) switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { // These keys should exit the program. case tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit // Navigate the input fields case tea.KeyTab: cmds = append(cmds, m.nextInput(true)) case tea.KeyShiftTab: cmds = append(cmds, m.prevInput(true)) case tea.KeyRight: // Only advance fields if cursor is at the end of this field cmds = append(cmds, m.nextInput(false)) case tea.KeyLeft: // Only advance fields if cursor is at the start of this field cmds = append(cmds, m.prevInput(false)) // Modify the focused input field with arrow keys case tea.KeyUp: m.incrementField() case tea.KeyDown: m.decrementField() } m.setFocus() } var cmd tea.Cmd m.year, cmd = m.year.Update(msg) cmds = append(cmds, cmd) m.month, cmd = m.month.Update(msg) cmds = append(cmds, cmd) m.day, cmd = m.day.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m Model) View() string { return fmt.Sprintf(`%s- %s- %s`, m.year.View(), m.month.View(), m.day.View()) } func (m *Model) Focused() bool { return m.focused != none } // Focus gives focus to the first field in this model. func (m *Model) Focus() { if m.focused == none { m.focused = year m.setFocus() } } // Blur removes focus from all fields contained in this model. func (m *Model) Blur() { if m.focused != none { m.focused = none m.setFocus() } } // AtEnd returns true if the cursor is at the end of the last field in this // model. If ignoreCursor it will return true if the cursor is anywhere in the // last field. func (m *Model) AtEnd(ignoreCursor bool) bool { if m.focused != day { return false } return ignoreCursor || m.day.Cursor() == 3 } // AtStart returns true if the cursor is at the start of the first field in this // model. If ignoreCursor it will return true if the cursor is anywhere in the // first field. func (m *Model) AtStart(ignoreCursor bool) bool { if m.focused != year { return false } return ignoreCursor || m.year.Cursor() == 0 } // setFocus updates the contained fields to ensure only the correct one has // focus. func (m *Model) setFocus() { if m.focused != year && m.year.Focused() { m.year.Blur() } if m.focused != month && m.month.Focused() { m.month.Blur() } if m.focused != day && m.day.Focused() { m.day.Blur() } switch m.focused { case year: m.year.Focus() case month: m.month.Focus() case day: m.day.Focus() } } // nextInput changes the focus in this model to the next field. Does nothing if // already on the last field. Also places the cursor in that field at the end. func (m *Model) nextInput(force bool) tea.Cmd { if !force { switch m.focused { case year: pos := m.year.Cursor() if pos != 4 { return nil } case month: pos := m.month.Cursor() if pos != 2 { return nil } } } m.log(fmt.Sprintf("Switching to next input from %d", m.focused)) // Setting the cursor to the end of the text box so that backspace is easily available. switch m.focused { case year: m.focused = month m.month.CursorEnd() case month: m.focused = day m.day.CursorEnd() default: // On the last input already m.focused = none return NextInput } return nil } // prevInput changes the focus in this model to the previous field. Does nothing // if already on the first field. Also places the cursor in that field at the // end. func (m *Model) prevInput(force bool) tea.Cmd { if !force { switch m.focused { case year: pos := m.year.Cursor() if pos != 0 { return nil } case month: pos := m.month.Cursor() if pos != 0 { return nil } case day: pos := m.day.Cursor() if pos != 0 { return nil } } } m.log(fmt.Sprintf("Switching to prev input from %d", m.focused)) switch m.focused { case month: m.focused = year m.year.CursorEnd() case day: m.focused = month m.month.CursorEnd() // TODO: Reset blink if needed default: // On the first input already m.focused = none return PrevInput } return nil } // incrementField intelligently increments the value in the selected field. Will // roll over other fields if necessary to keep the date valid. func (m *Model) incrementField() { m.log(fmt.Sprintf("Incrementing %d field", m.focused)) currentDate, err := m.currentDate() if err != nil { log.Fatal(err.Error()) } switch m.focused { case year: currentDate = currentDate.AddDate(1, 0, 0) case month: currentDate = currentDate.AddDate(0, 1, 0) case day: currentDate = currentDate.AddDate(0, 0, 1) } m.setDate(currentDate) } // decrementField intelligently decrements the value in the selected field. Will // roll over other fields if necessary to keep the date valid. func (m *Model) decrementField() { m.log(fmt.Sprintf("Decrementing %d field", m.focused)) currentDate, err := m.currentDate() if err != nil { log.Fatal(err.Error()) } switch m.focused { case year: currentDate = currentDate.AddDate(-1, 0, 0) case month: currentDate = currentDate.AddDate(0, -1, 0) case day: currentDate = currentDate.AddDate(0, 0, -1) } m.setDate(currentDate) } // setDate takes a time.Time and sets the values of the three date fields accordingly. func (m *Model) setDate(date time.Time) { m.log("Setting date to " + date.Format("2006-01-02")) m.year.SetValue(date.Format("2006")) m.month.SetValue(date.Format("01")) m.day.SetValue(date.Format("02")) } // currentDate retrieves the values from the three date fields and constructs // them into a time.Time object if possible. Returns an error if the values are // not valid and cannot be turned into a date. func (m *Model) currentDate() (time.Time, error) { parsed, err := time.Parse("2006-01-02", fmt.Sprintf("%s-%s-%s", m.year.Value(), m.month.Value(), m.day.Value())) if err != nil { return time.Time{}, fmt.Errorf("failed to parse the currently configured time: %w", err) } return parsed, nil } func (m *Model) log(msg string) { log.Println("DateInput: " + msg) }