package pages import ( "fmt" "log/slog" "strings" "tasksquire/common" "tasksquire/taskwarrior" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) type Mode int const ( ModeNormal Mode = iota ModeInsert ModeAddTag ModeAddProject ) type TaskEditorPage struct { common *common.Common task taskwarrior.Task form *huh.Form mode Mode statusline tea.Model nFields int currentField int // TODO: rework support for adding tags and projects additionalTags string additionalProject string } type TaskEditorKeys struct { Quit key.Binding Up key.Binding Down key.Binding Select key.Binding ToggleFocus key.Binding } func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditorPage { p := &TaskEditorPage{ common: common, task: task, } if p.task.Uuid == "" { p.mode = ModeInsert } else { p.mode = ModeNormal } if p.task.Priority == "" { p.task.Priority = "(none)" } if p.task.Project == "" { p.task.Project = "(none)" } priorityOptions := append([]string{"(none)"}, common.TW.GetPriorities()...) projectOptions := append([]string{"(none)"}, common.TW.GetProjects()...) tagOptions := common.TW.GetTags() p.form = huh.NewForm( huh.NewGroup( huh.NewInput(). Title("Task"). Value(&p.task.Description). Validate(func(desc string) error { if desc == "" { return fmt.Errorf("task description is required") } return nil }). Inline(true), huh.NewSelect[string](). Options(huh.NewOptions(priorityOptions...)...). Title("Priority"). Value(&p.task.Priority), huh.NewSelect[string](). Options(huh.NewOptions(projectOptions...)...). Title("Project"). Value(&p.task.Project), huh.NewInput(). Title("Project"). Value(&p.additionalProject). Validate(func(project string) error { if strings.Contains(project, " ") { return fmt.Errorf("project name cannot contain spaces") } return nil }). Inline(true), huh.NewMultiSelect[string](). Options(huh.NewOptions(tagOptions...)...). // Key("tags"). Title("Tags"). Value(&p.task.Tags), huh.NewInput(). Title("Tags"). Value(&p.additionalTags). Inline(true), huh.NewInput(). Title("Due"). Value(&p.task.Due). Validate(validateDate). Inline(true), huh.NewInput(). Title("Scheduled"). Value(&p.task.Scheduled). Validate(validateDate). Inline(true), huh.NewInput(). Title("Wait"). Value(&p.task.Wait). Validate(validateDate). Inline(true), ), ). WithShowHelp(false). WithShowErrors(true). // use styles from common WithHeight(40). WithWidth(50). WithTheme(p.common.Styles.Form) p.nFields = 6 p.currentField = 0 p.statusline = NewStatusLine(common, p.mode) return p } func (p *TaskEditorPage) SetSize(width, height int) { p.common.SetSize(width, height) } func (p *TaskEditorPage) Init() tea.Cmd { return p.form.Init() } func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case SwitchModeMsg: switch Mode(msg) { case ModeNormal: p.mode = ModeNormal case ModeInsert: p.mode = ModeInsert } } switch p.mode { case ModeNormal: switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): model, err := p.common.PopPage() if err != nil { slog.Error("page stack empty") return nil, tea.Quit } return model, BackCmd case key.Matches(msg, p.common.Keymap.Insert): return p, p.switchModeCmd(ModeInsert) case key.Matches(msg, p.common.Keymap.Ok): p.form.State = huh.StateCompleted case key.Matches(msg, p.common.Keymap.Down): p.form.NextField() case key.Matches(msg, p.common.Keymap.Up): p.form.PrevField() } } case ModeInsert: switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): return p, p.switchModeCmd(ModeNormal) } } f, cmd := p.form.Update(msg) if f, ok := f.(*huh.Form); ok { p.form = f cmds = append(cmds, cmd) } } var cmd tea.Cmd p.statusline, cmd = p.statusline.Update(msg) cmds = append(cmds, cmd) if p.form.State == huh.StateCompleted { cmds = append(cmds, p.updateTasksCmd) model, err := p.common.PopPage() if err != nil { slog.Error("page stack empty") return nil, tea.Quit } return model, tea.Batch(cmds...) } return p, tea.Batch(cmds...) } func (p *TaskEditorPage) View() string { return lipgloss.JoinVertical( lipgloss.Left, // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())), lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(" . ")), p.statusline.View(), ) } func (p *TaskEditorPage) updateTasksCmd() tea.Msg { if p.task.Project == "(none)" { p.task.Project = "" } if p.task.Priority == "(none)" { p.task.Priority = "" } if p.additionalTags != "" { p.task.Tags = append(p.task.Tags, strings.Split(p.additionalTags, " ")...) } if p.additionalProject != "" { p.task.Project = p.additionalProject } // tags := p.form.Get("tags").([]string) // p.task.Tags = tags p.common.TW.ImportTask(&p.task) return UpdatedTasksMsg{} } func (p *TaskEditorPage) switchModeCmd(mode Mode) tea.Cmd { return func() tea.Msg { return SwitchModeMsg(mode) } } type UpdatedTasksMsg struct{} type SwitchModeMsg Mode type StatusLine struct { common *common.Common mode Mode input textinput.Model } func NewStatusLine(common *common.Common, mode Mode) *StatusLine { input := textinput.New() input.Placeholder = "" input.Prompt = "" input.Blur() return &StatusLine{ input: textinput.New(), common: common, mode: mode, } } func (s *StatusLine) Init() tea.Cmd { s.input.Blur() return nil } func (s *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case SwitchModeMsg: s.mode = Mode(msg) switch s.mode { case ModeNormal: s.input.Blur() case ModeInsert: s.input.Focus() } case tea.KeyMsg: switch { case key.Matches(msg, s.common.Keymap.Back): s.input.Blur() case key.Matches(msg, s.common.Keymap.Input): s.input.Focus() } } s.input, cmd = s.input.Update(msg) return s, cmd } func (s *StatusLine) View() string { var mode string switch s.mode { case ModeNormal: mode = s.common.Styles.Main.Render("NORMAL") case ModeInsert: mode = s.common.Styles.Active.Inline(true).Render("INSERT") } return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View()) } // TODO: move this to taskwarrior; add missing date formats func validateDate(s string) error { formats := []string{ "2006-01-02", "2006-01-02T15:04", "20060102T150405Z", } otherFormats := []string{ "", "now", "today", "sod", "eod", "yesterday", "tomorrow", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "mon", "tue", "wed", "thu", "fri", "sat", "sun", "soy", "eoy", "soq", "eoq", "som", "eom", "socm", "eocm", "sow", "eow", "socw", "eocw", "soww", "eoww", "1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th", "11th", "12th", "13th", "14th", "15th", "16th", "17th", "18th", "19th", "20th", "21st", "22nd", "23rd", "24th", "25th", "26th", "27th", "28th", "29th", "30th", "31st", } for _, f := range formats { if _, err := time.Parse(f, s); err == nil { return nil } } for _, f := range otherFormats { if s == f { return nil } } return fmt.Errorf("invalid date") }