package pages import ( "fmt" "log/slog" "strings" "tasksquire/common" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) type mode int const ( modeNormal mode = iota modeInsert ) type TaskEditorPage struct { common *common.Common task taskwarrior.Task mode mode columnCursor int area area areaPicker *areaPicker areas map[area]tea.Model } func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage { p := TaskEditorPage{ common: com, task: task, } if p.task.Priority == "" { p.task.Priority = "(none)" } if p.task.Project == "" { p.task.Project = "(none)" } priorityOptions := append([]string{"(none)"}, p.common.TW.GetPriorities()...) projectOptions := append([]string{"(none)"}, p.common.TW.GetProjects()...) tagOptions := p.common.TW.GetTags() p.areas = map[area]tea.Model{ areaTask: NewTaskEdit(p.common, &p.task.Description, &p.task.Priority, &p.task.Project, priorityOptions, projectOptions), areaTags: NewTagEdit(p.common, &p.task.Tags, tagOptions), areaTime: NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until), } // p.areaList = NewAreaList(common, areaItems) // p.selectedArea = areaTask // p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...) p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"}) p.columnCursor = 1 if p.task.Uuid == "" { // p.mode = modeInsert p.mode = modeInsert } else { p.mode = modeNormal } return &p } func (p *TaskEditorPage) SetSize(width, height int) { p.common.SetSize(width, height) } func (p *TaskEditorPage) Init() tea.Cmd { return nil } func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case changeAreaMsg: p.area = area(msg) case changeModeMsg: p.mode = mode(msg) case prevColumnMsg: p.columnCursor-- if p.columnCursor < 0 { p.columnCursor = len(p.areas) - 1 } case nextColumnMsg: p.columnCursor++ if p.columnCursor > len(p.areas)-1 { p.columnCursor = 0 } case prevAreaMsg: p.area-- if p.area < 0 { p.area = 2 } case nextAreaMsg: p.area++ if p.area > 2 { p.area = 0 } } 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, changeMode(modeInsert) case key.Matches(msg, p.common.Keymap.Ok): model, err := p.common.PopPage() if err != nil { slog.Error("page stack empty") return nil, tea.Quit } return model, p.updateTasksCmd case key.Matches(msg, p.common.Keymap.Left): return p, prevColumn() case key.Matches(msg, p.common.Keymap.Right): return p, nextColumn() case key.Matches(msg, p.common.Keymap.Up): var cmd tea.Cmd if p.columnCursor == 0 { picker, cmd := p.areaPicker.Update(msg) p.areaPicker = picker.(*areaPicker) return p, cmd } else { p.areas[p.area], cmd = p.areas[p.area].Update(prevFieldMsg{}) return p, cmd } case key.Matches(msg, p.common.Keymap.Down): var cmd tea.Cmd if p.columnCursor == 0 { picker, cmd := p.areaPicker.Update(msg) p.areaPicker = picker.(*areaPicker) return p, cmd } else { p.areas[p.area], cmd = p.areas[p.area].Update(nextFieldMsg{}) return p, cmd } } } // var cmd tea.Cmd // if p.columnCursor == 0 { // p., cmd = p.areaList.Update(msg) // p.selectedArea = p.areaList.(areaList).Area() // cmds = append(cmds, cmd) // } else { // p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg) // cmds = append(cmds, cmd) // } case modeInsert: switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, p.common.Keymap.Back): return p, changeMode(modeNormal) case key.Matches(msg, p.common.Keymap.Ok): area, cmd := p.areas[p.area].Update(msg) p.areas[p.area] = area return p, tea.Batch(cmd, nextField()) } } var cmd tea.Cmd if p.columnCursor == 0 { picker, cmd := p.areaPicker.Update(msg) p.areaPicker = picker.(*areaPicker) return p, cmd } else { p.areas[p.area], cmd = p.areas[p.area].Update(msg) return p, cmd } } return p, nil } func (p *TaskEditorPage) View() string { var focusStyle lipgloss.Style if p.mode == modeInsert { focusStyle = p.common.Styles.ColumnInsert } else { focusStyle = p.common.Styles.ColumnFocused } var picker, area string if p.columnCursor == 0 { picker = focusStyle.Render(p.areaPicker.View()) area = p.common.Styles.ColumnBlurred.Render(p.areas[p.area].View()) } else { picker = p.common.Styles.ColumnBlurred.Render(p.areaPicker.View()) area = focusStyle.Render(p.areas[p.area].View()) } return lipgloss.JoinHorizontal( lipgloss.Center, picker, area, ) } // import ( // "fmt" // "io" // "log/slog" // "strings" // "tasksquire/common" // "tasksquire/taskwarrior" // "time" // "github.com/charmbracelet/bubbles/list" // "github.com/charmbracelet/bubbles/textinput" // tea "github.com/charmbracelet/bubbletea" // "github.com/charmbracelet/huh" // "github.com/charmbracelet/lipgloss" // ) // type Field int // const ( // FieldDescription Field = iota // FieldPriority // FieldProject // FieldNewProject // FieldTags // FieldNewTags // FieldDue // FieldScheduled // FieldWait // FieldUntil // ) // type column int // const ( // column1 column = iota // column2 // column3 // ) // func changeColumn(c column) tea.Cmd { // return func() tea.Msg { // return changeColumnMsg(c) // } // } // type changeColumnMsg column // type mode int // const ( // modeNormal mode = iota // modeInsert // modeAddTag // modeAddProject // ) // type TaskEditorPage struct { // common *common.Common // task taskwarrior.Task // areaList tea.Model // mode mode // statusline tea.Model // // TODO: rework support for adding tags and projects // additionalTags string // additionalProject string // columnCursor int // columns []tea.Model // areas map[area][]tea.Model // selectedArea area // } // 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 // } // areaItems := []list.Item{ // item("Task"), // item("Tags"), // item("Time"), // } // } // p.statusline = NewStatusLine(common, p.mode) // return p // } type area int const ( areaTask area = iota areaTags areaTime ) type areaPicker struct { common *common.Common list list.Model } type item string func (i item) Title() string { return string(i) } func (i item) Description() string { return "test" } func (i item) FilterValue() string { return "" } func NewAreaPicker(common *common.Common, items []string) *areaPicker { listItems := make([]list.Item, len(items)) for i, itm := range items { listItems[i] = item(itm) } list := list.New(listItems, list.DefaultDelegate{}, 20, 50) list.SetFilteringEnabled(false) list.SetShowStatusBar(false) list.SetShowHelp(false) list.SetShowPagination(false) list.SetShowTitle(false) return &areaPicker{ common: common, list: list, } } func (a *areaPicker) Area() area { switch a.list.SelectedItem() { case item("Task"): return areaTask case item("Tags"): return areaTags case item("Dates"): return areaTime default: return areaTask } } func (a *areaPicker) Init() tea.Cmd { return nil } func (a *areaPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd cursor := a.list.Cursor() // switch msg.(type) { // case nextFieldMsg: // a.list, cmd = a.list.Update(a.list.KeyMap.CursorDown) // case prevFieldMsg: // a.list, cmd = a.list.Update(a.list.KeyMap.CursorUp) // } a.list, cmd = a.list.Update(msg) cmds = append(cmds, cmd) if cursor != a.list.Cursor() { cmds = append(cmds, changeArea(a.Area())) } return a, tea.Batch(cmds...) } func (a *areaPicker) View() string { return a.list.View() } type taskEdit struct { common *common.Common fields []huh.Field cursor int newProjectName *string } func NewTaskEdit(common *common.Common, description *string, priority *string, project *string, priorityOptions []string, projectOptions []string) *taskEdit { newProject := "" defaultKeymap := huh.NewDefaultKeyMap() t := taskEdit{ common: common, fields: []huh.Field{ huh.NewInput(). Title("Task"). Value(description). Validate(func(desc string) error { if desc == "" { return fmt.Errorf("task description is required") } return nil }). Inline(true). WithTheme(common.Styles.Form), huh.NewSelect[string](). Options(huh.NewOptions(priorityOptions...)...). Title("Priority"). Key("priority"). Value(priority). WithKeyMap(defaultKeymap). WithTheme(common.Styles.Form), huh.NewSelect[string](). Options(huh.NewOptions(projectOptions...)...). Title("Project"). Value(project). WithKeyMap(defaultKeymap). WithTheme(common.Styles.Form), huh.NewInput(). Title("New Project"). Value(&newProject). WithTheme(common.Styles.Form), }, newProjectName: &newProject, } t.fields[0].Focus() return &t } func (t *taskEdit) Init() tea.Cmd { return nil } func (t *taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case nextFieldMsg: if t.cursor == len(t.fields)-1 { t.fields[t.cursor].Blur() return t, nextArea() } t.fields[t.cursor].Blur() t.cursor++ t.fields[t.cursor].Focus() case prevFieldMsg: if t.cursor == 0 { t.fields[t.cursor].Blur() return t, prevArea() } t.fields[t.cursor].Blur() t.cursor-- t.fields[t.cursor].Focus() default: field, cmd := t.fields[t.cursor].Update(msg) t.fields[t.cursor] = field.(huh.Field) return t, cmd } return t, nil } func (t *taskEdit) View() string { views := make([]string, len(t.fields)) for i, field := range t.fields { views[i] = field.View() } return lipgloss.JoinVertical( lipgloss.Left, views..., ) } type tagEdit struct { common *common.Common fields []huh.Field cursor int newTagsValue *string } func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit { newTags := "" defaultKeymap := huh.NewDefaultKeyMap() t := tagEdit{ common: common, fields: []huh.Field{ huh.NewMultiSelect[string](). Options(huh.NewOptions(options...)...). // Key("tags"). Title("Tags"). Value(selected). Filterable(true). WithKeyMap(defaultKeymap). WithTheme(common.Styles.Form), huh.NewInput(). Title("New Tags"). Value(&newTags). Inline(true). WithTheme(common.Styles.Form), }, newTagsValue: &newTags, } t.fields[0].Focus() return &t } func (t *tagEdit) Init() tea.Cmd { return nil } func (t *tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case nextFieldMsg: if t.cursor == len(t.fields)-1 { t.fields[t.cursor].Blur() return t, nextArea() } t.fields[t.cursor].Blur() t.cursor++ t.fields[t.cursor].Focus() case prevFieldMsg: if t.cursor == 0 { t.fields[t.cursor].Blur() return t, prevArea() } t.fields[t.cursor].Blur() t.cursor-- t.fields[t.cursor].Focus() default: field, cmd := t.fields[t.cursor].Update(msg) t.fields[t.cursor] = field.(huh.Field) return t, cmd } return t, nil } func (t tagEdit) View() string { views := make([]string, len(t.fields)) for i, field := range t.fields { views[i] = field.View() } return lipgloss.JoinVertical( lipgloss.Left, views..., ) } type timeEdit struct { common *common.Common fields []huh.Field cursor int } func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit { // defaultKeymap := huh.NewDefaultKeyMap() t := timeEdit{ common: common, fields: []huh.Field{ huh.NewInput(). Title("Due"). Value(due). Validate(taskwarrior.ValidateDate). Inline(true). WithTheme(common.Styles.Form), huh.NewInput(). Title("Scheduled"). Value(scheduled). Validate(taskwarrior.ValidateDate). Inline(true). WithTheme(common.Styles.Form), huh.NewInput(). Title("Wait"). Value(wait). Validate(taskwarrior.ValidateDate). Inline(true). WithTheme(common.Styles.Form), huh.NewInput(). Title("Until"). Value(until). Validate(taskwarrior.ValidateDate). Inline(true). WithTheme(common.Styles.Form), }, } t.fields[0].Focus() return &t } func (t *timeEdit) Init() tea.Cmd { return nil } func (t *timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case nextFieldMsg: if t.cursor == len(t.fields)-1 { t.fields[t.cursor].Blur() return t, nextArea() } t.fields[t.cursor].Blur() t.cursor++ t.fields[t.cursor].Focus() case prevFieldMsg: if t.cursor == 0 { t.fields[t.cursor].Blur() return t, prevArea() } t.fields[t.cursor].Blur() t.cursor-- t.fields[t.cursor].Focus() default: field, cmd := t.fields[t.cursor].Update(msg) t.fields[t.cursor] = field.(huh.Field) return t, cmd } return t, nil } func (t *timeEdit) View() string { views := make([]string, len(t.fields)) for i, field := range t.fields { views[i] = field.View() } return lipgloss.JoinVertical( lipgloss.Left, views..., ) } // func (p *TaskEditorPage) SetSize(width, height int) { // p.common.SetSize(width, height) // } // func (p *TaskEditorPage) Init() tea.Cmd { // // return p.form.Init() // return nil // } // 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 // } // case changeAreaMsg: // p.selectedArea = area(msg) // p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...) // case nextColumnMsg: // p.columnCursor++ // if p.columnCursor > len(p.columns)-1 { // p.columnCursor = 0 // } // case prevColumnMsg: // p.columnCursor-- // if p.columnCursor < 0 { // p.columnCursor = len(p.columns) - 1 // } // } // 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.Left): // return p, prevColumn() // case key.Matches(msg, p.common.Keymap.Right): // return p, nextColumn() // } // } // case modeInsert: // switch msg := msg.(type) { // case tea.KeyMsg: // switch { // case key.Matches(msg, p.common.Keymap.Back): // return p, p.switchModeCmd(modeNormal) // } // } // var cmd tea.Cmd // if p.columnCursor == 0 { // p.areaList, cmd = p.areaList.Update(msg) // p.selectedArea = p.areaList.(areaList).Area() // cmds = append(cmds, cmd) // } else { // p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg) // cmds = append(cmds, cmd) // } // } // var cmd tea.Cmd // if p.columnCursor == 0 { // p.areaList, cmd = p.areaList.Update(msg) // p.selectedArea = p.areaList.(areaList).Area() // cmds = append(cmds, cmd) // } else { // p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg) // cmds = append(cmds, 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 { // columns := make([]string, len(p.columns)) // for i, c := range p.columns { // columns[i] = c.View() // } // return lipgloss.JoinVertical( // lipgloss.Left, // lipgloss.JoinHorizontal( // lipgloss.Top, // // 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(" . ")), // columns..., // ), // 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.areas[areaTask].(*taskEdit).newProjectName) != "" { p.task.Project = *p.areas[areaTask].(*taskEdit).newProjectName } if *(p.areas[areaTags].(*tagEdit).newTagsValue) != "" { newTags := strings.Split(*p.areas[areaTags].(*tagEdit).newTagsValue, " ") if len(newTags) > 0 { p.task.Tags = append(p.task.Tags, newTags...) } } // 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{} } // 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.Base.Render("NORMAL") // case modeInsert: // mode = s.common.Styles.Active.Inline(true).Reverse(true).Render("INSERT") // } // return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View()) // } // // TODO: move this to taskwarrior; add missing date formats // type itemDelegate struct{} // func (d itemDelegate) Height() int { return 1 } // func (d itemDelegate) Spacing() int { return 0 } // func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } // func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { // i, ok := listItem.(item) // if !ok { // return // } // str := fmt.Sprintf("%s", i) // fn := itemStyle.Render // if index == m.Index() { // fn = func(s ...string) string { // return selectedItemStyle.Render("> " + strings.Join(s, " ")) // } // } // fmt.Fprint(w, fn(str)) // } // var ( // titleStyle = lipgloss.NewStyle().MarginLeft(2) // itemStyle = lipgloss.NewStyle().PaddingLeft(4) // selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) // paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) // helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) // quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) // ) // type item string // func (i item) FilterValue() string { return "" }