package tui import ( "errors" "fmt" "strconv" "strings" "time" "git.tjp.lol/punchcard/internal/reports" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) type suggestionType int const ( noSuggestions suggestionType = iota suggestClients suggestProjects suggestReportType ) type FormField struct { textinput.Model label string suggestions suggestionType } func (ff FormField) Update(msg tea.Msg) (FormField, tea.Cmd) { field, cmd := ff.Model.Update(msg) ff.Model = field return ff, cmd } func newTimestampField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if _, err := time.Parse(time.DateTime, s); err != nil { return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) } return nil } return f } func newOptionalTimestampField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return nil } if _, err := time.Parse(time.DateTime, s); err != nil { return fmt.Errorf("timestamps must be written like \"%s\"", time.DateTime) } return nil } return f } func newOptionalFloatField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return nil } _, err := strconv.ParseFloat(s, 64) if err != nil { return errors.New("numerical values only") } return nil } return f } func newDateRangeField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, } f.Validate = func(s string) error { if s == "" { return errors.New("date range is required") } _, err := reports.ParseDateRange(s) if err != nil { return fmt.Errorf("invalid date range: %v", err) } return nil } return f } func newReportTypeField(label string) FormField { f := FormField{ Model: textinput.New(), label: label, suggestions: suggestReportType, } f.Validate = func(s string) error { switch strings.ToLower(s) { case "invoice", "timesheet", "unified": return nil } return errors.New("pick one of invoice, timesheet, or unified") } return f } type Form struct { fields []FormField selIdx int err error SelectedStyle *lipgloss.Style UnselectedStyle *lipgloss.Style } func NewForm(fields []FormField) Form { return Form{fields: fields} } func (f Form) Error() error { for _, field := range f.fields { if field.Err != nil { return field.Err } } return nil } func NewEntryEditorForm() Form { form := NewForm([]FormField{ newTimestampField("Start time"), newOptionalTimestampField("End time"), {Model: textinput.New(), label: "Client", suggestions: suggestClients}, {Model: textinput.New(), label: "Project", suggestions: suggestProjects}, {Model: textinput.New(), label: "Description"}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewClientForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Name"}, {Model: textinput.New(), label: "Email"}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewProjectCreateForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Name"}, {Model: textinput.New(), label: "Client", suggestions: suggestClients}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewProjectEditForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Name"}, newOptionalFloatField("Hourly Rate"), }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewHistoryFilterForm() Form { form := NewForm([]FormField{ newDateRangeField("Date Range"), {Model: textinput.New(), label: "Client (optional)", suggestions: suggestClients}, {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewGenerateReportForm() Form { form := NewForm([]FormField{ newReportTypeField("Report Type"), newDateRangeField("Date Range"), {Model: textinput.New(), label: "Client", suggestions: suggestClients}, {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, {Model: textinput.New(), label: "Output Path (optional)"}, {Model: textinput.New(), label: "Timezone (optional)"}, }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func NewContractorForm() Form { form := NewForm([]FormField{ {Model: textinput.New(), label: "Your Name"}, {Model: textinput.New(), label: "Label for your work"}, {Model: textinput.New(), label: "Your Email"}, }) form.SelectedStyle = &modalFocusedInputStyle form.UnselectedStyle = &modalBlurredInputStyle return form } func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "tab": f.fields[f.selIdx].Blur() f.selIdx = (f.selIdx + 1) % len(f.fields) return f, f.fields[f.selIdx].Focus() case "shift+tab": f.fields[f.selIdx].Blur() f.selIdx-- if f.selIdx < 0 { f.selIdx += len(f.fields) } return f, f.fields[f.selIdx].Focus() } } field, cmd := f.fields[f.selIdx].Update(msg) f.fields[f.selIdx] = field return f, cmd } func (f Form) View() string { renderedFields := make([]string, len(f.fields)) maxFieldWidth := 0 for i, field := range f.fields { style := f.UnselectedStyle if i == f.selIdx { style = f.SelectedStyle } var fieldView string if style != nil { fieldView = style.Render(field.View()) } else { fieldView = field.View() } renderedFields[i] = fieldView fieldWidth := lipgloss.Width(fieldView) if fieldWidth > maxFieldWidth { maxFieldWidth = fieldWidth } } constraintWidth := max(72, maxFieldWidth) content := "" if f.err != nil { wrappedError := ansi.WordwrapWc(f.err.Error(), constraintWidth, " ") content += errorStyle.Render(wrappedError) + "\n\n" } for i, field := range f.fields { if i > 0 { content += "\n\n" } content += field.label + ":\n" content += renderedFields[i] if field.Err != nil { wrappedError := ansi.WordwrapWc(field.Err.Error(), constraintWidth, " ") content += "\n" + errorStyle.Render(wrappedError) } } return content } func (f *Form) SetSuggestions(m AppModel) { for i := range f.fields { ff := &f.fields[i] switch ff.suggestions { case suggestClients: clients := make([]string, len(m.projectsBox.clients)) for i, cl := range m.projectsBox.clients { clients[i] = cl.Name } ff.SetSuggestions(clients) ff.ShowSuggestions = true case suggestProjects: projNames := make([]string, 0, 10) for _, cl := range m.projectsBox.clients { for _, proj := range m.projectsBox.projects[cl.ID] { projNames = append(projNames, proj.Name) } } ff.SetSuggestions(projNames) ff.ShowSuggestions = true case suggestReportType: ff.SetSuggestions([]string{"Invoice", "Timesheet", "Unified"}) ff.ShowSuggestions = true } } } var ( modalFocusedInputStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color("238")) modalBlurredInputStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("238")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) )