388 lines
8.3 KiB
Go
388 lines
8.3 KiB
Go
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)
|
|
}
|