This repository has been archived on 2023-12-27. You can view files and clone it, but cannot push or open issues or pull requests.
ledger-tui/pkg/tui/dateinput/dateInput.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)
}