Initial commit

This commit is contained in:
Martin
2024-05-20 21:17:47 +02:00
commit d960f1f113
25 changed files with 1897 additions and 0 deletions

105
pages/contextPicker.go Normal file
View File

@ -0,0 +1,105 @@
package pages
import (
"log/slog"
"slices"
"tasksquire/common"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type ContextPickerPage struct {
common *common.Common
contexts taskwarrior.Contexts
form *huh.Form
}
func NewContextPickerPage(common *common.Common) *ContextPickerPage {
p := &ContextPickerPage{
common: common,
contexts: common.TW.GetContexts(),
}
// if allowAdd {
// fields = append(fields, huh.NewInput().
// Key("input").
// Title("Input").
// Prompt(fmt.Sprintf("Enter a new %s", header)).
// Inline(false),
// )
// }
selected := common.TW.GetActiveContext().Name
options := make([]string, 0)
for _, c := range p.contexts {
options = append(options, c.Name)
}
slices.Sort(options)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("context").
Options(huh.NewOptions(options...)...).
Title("Contexts").
Description("Choose a context").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(true)
return p
}
func (p *ContextPickerPage) Init() tea.Cmd {
return p.form.Init()
}
func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateContextCmd)
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
}
func (p *ContextPickerPage) View() string {
return p.common.Styles.Main.Render(p.form.View())
}
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
return UpdateContextMsg(p.common.TW.GetContext(p.form.GetString("context")))
}
type UpdateContextMsg *taskwarrior.Context

122
pages/datePicker.go Normal file
View File

@ -0,0 +1,122 @@
package pages
// import (
// "tasksquire/common"
// "github.com/charmbracelet/bubbles/textinput"
// tea "github.com/charmbracelet/bubbletea"
// "github.com/charmbracelet/lipgloss"
// datepicker "github.com/ethanefung/bubble-datepicker"
// )
// type Model struct {
// focus focus
// input textinput.Model
// datepicker datepicker.Model
// }
// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0)
// func initializeModel() tea.Model {
// dp := datepicker.New(time.Now())
// input := textinput.New()
// input.Placeholder = "YYYY-MM-DD (enter date)"
// input.Focus()
// input.Width = 20
// return Model{
// focus: FocusInput,
// input: input,
// datepicker: dp,
// }
// }
// func (m Model) Init() tea.Cmd {
// return textinput.Blink
// }
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmd tea.Cmd
// switch msg := msg.(type) {
// case tea.WindowSizeMsg:
// // TODO figure out how we want to size things
// // we'll probably want both bubbles to be vertically stacked
// // and to take as much room as the can
// return m, nil
// case tea.KeyMsg:
// switch msg.String() {
// case "ctrl+c", "q":
// return m, tea.Quit
// case "tab":
// if m.focus == FocusInput {
// m.focus = FocusDatePicker
// m.input.Blur()
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
// m.datepicker.SelectDate()
// m.datepicker.SetFocus(datepicker.FocusHeaderMonth)
// m.datepicker = m.datepicker
// return m, nil
// }
// case "shift+tab":
// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth {
// m.focus = FocusInput
// m.datepicker.Blur()
// m.input.Focus()
// return m, nil
// }
// }
// }
// switch m.focus {
// case FocusInput:
// m.input, cmd = m.UpdateInput(msg)
// case FocusDatePicker:
// m.datepicker, cmd = m.UpdateDatepicker(msg)
// case FocusNone:
// // do nothing
// }
// return m, cmd
// }
// func (m Model) View() string {
// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View())
// }
// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) {
// var cmd tea.Cmd
// m.input, cmd = m.input.Update(msg)
// val := m.input.Value()
// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val))
// if err == nil {
// m.datepicker.SetTime(t)
// m.datepicker.SelectDate()
// m.datepicker.Blur()
// }
// if err != nil && m.datepicker.Selected {
// m.datepicker.UnselectDate()
// }
// return m.input, cmd
// }
// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) {
// var cmd tea.Cmd
// prev := m.datepicker.Time
// m.datepicker, cmd = m.datepicker.Update(msg)
// if prev != m.datepicker.Time {
// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly))
// }
// return m.datepicker, cmd
// }

11
pages/page.go Normal file
View File

@ -0,0 +1,11 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
)
func BackCmd() tea.Msg {
return BackMsg{}
}
type BackMsg struct{}

100
pages/projectPicker.go Normal file
View File

@ -0,0 +1,100 @@
package pages
import (
"log/slog"
"tasksquire/common"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type ProjectPickerPage struct {
common *common.Common
form *huh.Form
}
func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage {
p := &ProjectPickerPage{
common: common,
}
var selected string
if activeProject == "" {
selected = "(none)"
} else {
selected = activeProject
}
projects := common.TW.GetProjects()
options := []string{"(none)"}
options = append(options, projects...)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("project").
Options(huh.NewOptions(options...)...).
Title("Projects").
Description("Choose a project").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(false)
return p
}
func (p *ProjectPickerPage) Init() tea.Cmd {
return p.form.Init()
}
func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.updateProjectCmd)
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
}
func (p *ProjectPickerPage) View() string {
return p.common.Styles.Main.Render(p.form.View())
}
func (p *ProjectPickerPage) updateProjectCmd() tea.Msg {
project := p.form.GetString("project")
if project == "(none)" {
project = ""
}
return UpdateProjectMsg(project)
}
type UpdateProjectMsg string

201
pages/report.go Normal file
View File

@ -0,0 +1,201 @@
package pages
import (
"strconv"
"strings"
"tasksquire/common"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ReportPage struct {
common *common.Common
activeReport *taskwarrior.Report
activeContext *taskwarrior.Context
activeProject string
selectedTask *taskwarrior.Task
tasks taskwarrior.Tasks
taskTable table.Model
tableStyle table.Styles
keymap ReportKeys
subpage tea.Model
subpageActive bool
}
type ReportKeys struct {
Quit key.Binding
Up key.Binding
Down key.Binding
Select key.Binding
ToggleFocus key.Binding
}
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
keys := ReportKeys{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q, ctrl+c", "Quit"),
),
Up: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("↑/k", "Up"),
),
Down: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("↓/j", "Down"),
),
Select: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "Select"),
),
ToggleFocus: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "Toggle focus"),
),
}
return &ReportPage{
common: com,
activeReport: report,
activeContext: com.TW.GetActiveContext(),
activeProject: "",
tableStyle: s,
keymap: keys,
}
}
func (p ReportPage) Init() tea.Cmd {
return p.getTasks()
}
func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case BackMsg:
p.subpageActive = false
case TaskMsg:
p.populateTaskTable(msg)
case UpdateReportMsg:
p.activeReport = msg
cmds = append(cmds, p.getTasks())
case UpdateContextMsg:
p.activeContext = msg
p.common.TW.SetContext(msg)
cmds = append(cmds, p.getTasks())
case UpdateProjectMsg:
p.activeProject = string(msg)
cmds = append(cmds, p.getTasks())
case AddedTaskMsg:
cmds = append(cmds, p.getTasks())
case tea.WindowSizeMsg:
p.taskTable.SetWidth(msg.Width - 2)
p.taskTable.SetHeight(msg.Height - 4)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Quit):
return p, tea.Quit
case key.Matches(msg, p.common.Keymap.SetReport):
p.subpage = NewReportPickerPage(p.common, p.activeReport)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
p.subpage.Init()
p.subpageActive = true
p.common.PageStack.Push(p)
return p.subpage, nil
}
}
var cmd tea.Cmd
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil {
p.selectedTask = (*taskwarrior.Task)(p.tasks[p.taskTable.Cursor()])
}
return p, tea.Batch(cmds...)
}
func (p ReportPage) View() string {
return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
}
func (p *ReportPage) populateTaskTable(tasks []*taskwarrior.Task) {
columns := []table.Column{
{Title: "ID", Width: 4},
{Title: "Project", Width: 10},
{Title: "Tags", Width: 10},
{Title: "Prio", Width: 2},
{Title: "Due", Width: 10},
{Title: "Task", Width: 50},
}
var rows []table.Row
for _, task := range tasks {
rows = append(rows, table.Row{
strconv.FormatInt(task.Id, 10),
task.Project,
strings.Join(task.Tags, ", "),
task.Priority,
task.Due,
task.Description,
})
}
p.taskTable = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
// table.WithHeight(7),
// table.WithWidth(100),
)
p.taskTable.SetStyles(p.tableStyle)
}
func (p *ReportPage) getTasks() tea.Cmd {
return func() tea.Msg {
filters := []string{}
if p.activeProject != "" {
filters = append(filters, "project:"+p.activeProject)
}
p.tasks = p.common.TW.GetTasks(p.activeReport, filters...)
return TaskMsg(p.tasks)
}
}
type TaskMsg taskwarrior.Tasks

97
pages/reportPicker.go Normal file
View File

@ -0,0 +1,97 @@
package pages
import (
"log/slog"
"slices"
"tasksquire/common"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type ReportPickerPage struct {
common *common.Common
reports taskwarrior.Reports
form *huh.Form
}
func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage {
p := &ReportPickerPage{
common: common,
reports: common.TW.GetReports(),
}
selected := activeReport.Name
options := make([]string, 0)
for _, r := range p.reports {
options = append(options, r.Name)
}
slices.Sort(options)
p.form = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Key("report").
Options(huh.NewOptions(options...)...).
Title("Reports").
Description("Choose a report").
Value(&selected),
),
).
WithShowHelp(false).
WithShowErrors(false)
return p
}
func (p *ReportPickerPage) Init() tea.Cmd {
return p.form.Init()
}
func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...)
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, tea.Batch(cmds...)
}
return p, tea.Batch(cmds...)
}
func (p *ReportPickerPage) View() string {
return p.common.Styles.Main.Render(p.form.View())
}
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
}
type UpdateReportMsg *taskwarrior.Report

233
pages/taskEditor.go Normal file
View File

@ -0,0 +1,233 @@
package pages
import (
"fmt"
"log/slog"
"tasksquire/common"
"tasksquire/taskwarrior"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
type TaskEditorPage struct {
common *common.Common
task taskwarrior.Task
form *huh.Form
}
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.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).
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.NewMultiSelect[string]().
Options(huh.NewOptions(tagOptions...)...).
Title("Tags").
Value(&p.task.Tags),
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(false)
return p
}
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 tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PageStack.Pop()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
}
}
f, cmd := p.form.Update(msg)
if f, ok := f.(*huh.Form); ok {
p.form = f
cmds = append(cmds, cmd)
}
if p.form.State == huh.StateCompleted {
cmds = append(cmds, p.addTaskCmd)
model, err := p.common.PageStack.Pop()
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 p.common.Styles.Main.Render(p.form.View())
}
func (p *TaskEditorPage) addTaskCmd() tea.Msg {
p.common.TW.AddTask(&p.task)
return AddedTaskMsg{}
}
type AddedTaskMsg struct{}
// 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")
}