diff --git a/common/styles.go b/common/styles.go index e903bdc..e2f809f 100644 --- a/common/styles.go +++ b/common/styles.go @@ -1,6 +1,13 @@ package common import ( + "errors" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "tasksquire/taskwarrior" @@ -9,17 +16,360 @@ import ( type Styles struct { Main lipgloss.Style - ActiveTask lipgloss.Style - InactiveTask lipgloss.Style - NormalTask lipgloss.Style + Form *huh.Theme + + Active lipgloss.Style + Alternate lipgloss.Style + Blocked lipgloss.Style + Blocking lipgloss.Style + BurndownDone lipgloss.Style + BurndownPending lipgloss.Style + BurndownStarted lipgloss.Style + CalendarDue lipgloss.Style + CalendarDueToday lipgloss.Style + CalendarHoliday lipgloss.Style + CalendarOverdue lipgloss.Style + CalendarScheduled lipgloss.Style + CalendarToday lipgloss.Style + CalendarWeekend lipgloss.Style + CalendarWeeknumber lipgloss.Style + Completed lipgloss.Style + Debug lipgloss.Style + Deleted lipgloss.Style + Due lipgloss.Style + DueToday lipgloss.Style + Error lipgloss.Style + Footnote lipgloss.Style + Header lipgloss.Style + HistoryAdd lipgloss.Style + HistoryDelete lipgloss.Style + HistoryDone lipgloss.Style + Label lipgloss.Style + LabelSort lipgloss.Style + Overdue lipgloss.Style + ProjectNone lipgloss.Style + Recurring lipgloss.Style + Scheduled lipgloss.Style + SummaryBackground lipgloss.Style + SummaryBar lipgloss.Style + SyncAdded lipgloss.Style + SyncChanged lipgloss.Style + SyncRejected lipgloss.Style + TagNext lipgloss.Style + TagNone lipgloss.Style + Tagged lipgloss.Style + UdaPriorityH lipgloss.Style + UdaPriorityL lipgloss.Style + UdaPriorityM lipgloss.Style + UndoAfter lipgloss.Style + UndoBefore lipgloss.Style + Until lipgloss.Style + Warning lipgloss.Style } func NewStyles(config *taskwarrior.TWConfig) *Styles { - return &Styles{ - Main: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + styles := parseColors(config.GetConfig()) + styles.Main = lipgloss.NewStyle() - ActiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")), - InactiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")), - NormalTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), - } + formTheme := huh.ThemeBase() + formTheme.Focused.Card = formTheme.Focused.Card.BorderStyle(lipgloss.RoundedBorder()).BorderBottom(true).BorderTop(true) + formTheme.Focused.Title = formTheme.Focused.Title.Bold(true) + formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ") + formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true) + formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ") + formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ") + formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ") + formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ") + formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true) + formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ") + formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ") + formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ") + + styles.Form = formTheme + + return styles +} + +func parseColors(config map[string]string) *Styles { + styles := Styles{} + + for key, value := range config { + if strings.HasPrefix(key, "color.") { + if value != "" { + color := strings.Split(key, ".")[1] + switch color { + case "active": + styles.Active = parseColorString(value) + case "alternate": + styles.Alternate = parseColorString(value) + case "blocked": + styles.Blocked = parseColorString(value) + case "blocking": + styles.Blocking = parseColorString(value) + case "burndown.done": + styles.BurndownDone = parseColorString(value) + case "burndown.pending": + styles.BurndownPending = parseColorString(value) + case "burndown.started": + styles.BurndownStarted = parseColorString(value) + case "calendar.due": + styles.CalendarDue = parseColorString(value) + case "calendar.due.today": + styles.CalendarDueToday = parseColorString(value) + case "calendar.holiday": + styles.CalendarHoliday = parseColorString(value) + case "calendar.overdue": + styles.CalendarOverdue = parseColorString(value) + case "calendar.scheduled": + styles.CalendarScheduled = parseColorString(value) + case "calendar.today": + styles.CalendarToday = parseColorString(value) + case "calendar.weekend": + styles.CalendarWeekend = parseColorString(value) + case "calendar.weeknumber": + styles.CalendarWeeknumber = parseColorString(value) + case "completed": + styles.Completed = parseColorString(value) + case "debug": + styles.Debug = parseColorString(value) + case "deleted": + styles.Deleted = parseColorString(value) + case "due": + styles.Due = parseColorString(value) + case "due.today": + styles.DueToday = parseColorString(value) + case "error": + styles.Error = parseColorString(value) + case "footnote": + styles.Footnote = parseColorString(value) + case "header": + styles.Header = parseColorString(value) + case "history.add": + styles.HistoryAdd = parseColorString(value) + case "history.delete": + styles.HistoryDelete = parseColorString(value) + case "history.done": + styles.HistoryDone = parseColorString(value) + case "label": + styles.Label = parseColorString(value) + case "label.sort": + styles.LabelSort = parseColorString(value) + case "overdue": + styles.Overdue = parseColorString(value) + case "project.none": + styles.ProjectNone = parseColorString(value) + case "recurring": + styles.Recurring = parseColorString(value) + case "scheduled": + styles.Scheduled = parseColorString(value) + case "summary.background": + styles.SummaryBackground = parseColorString(value) + case "summary.bar": + styles.SummaryBar = parseColorString(value) + case "sync.added": + styles.SyncAdded = parseColorString(value) + case "sync.changed": + styles.SyncChanged = parseColorString(value) + case "sync.rejected": + styles.SyncRejected = parseColorString(value) + case "tag.next": + styles.TagNext = parseColorString(value) + case "tag.none": + styles.TagNone = parseColorString(value) + case "tagged": + styles.Tagged = parseColorString(value) + case "uda.priority.H": + styles.UdaPriorityH = parseColorString(value) + case "uda.priority.L": + styles.UdaPriorityL = parseColorString(value) + case "uda.priority.M": + styles.UdaPriorityM = parseColorString(value) + case "undo.after": + styles.UndoAfter = parseColorString(value) + case "undo.before": + styles.UndoBefore = parseColorString(value) + case "until": + styles.Until = parseColorString(value) + case "warning": + styles.Warning = parseColorString(value) + } + } + } + } + + return &styles +} + +func parseColorString(color string) lipgloss.Style { + style := lipgloss.NewStyle() + if strings.Contains(color, "on") { + fgbg := strings.Split(color, "on") + fg := strings.TrimSpace(fgbg[0]) + bg := strings.TrimSpace(fgbg[1]) + if fg != "" { + style = style.Foreground(parseColor(fg)) + } + if bg != "" { + style = style.Background(parseColor(bg)) + } + } else { + style = style.Foreground(parseColor(strings.TrimSpace(color))) + } + + return style +} + +func parseColor(color string) lipgloss.Color { + if strings.HasPrefix(color, "rgb") { + rgb, err := parseRGBString(strings.TrimPrefix(color, "rgb")) + if err != nil { + slog.Error("Invalid RGB color format") + return lipgloss.Color("0") + } + return lipgloss.Color(rgbToAnsi(rgb)) + } + if strings.HasPrefix(color, "color") { + return lipgloss.Color(strings.TrimPrefix(color, "color")) + } + if strings.HasPrefix(color, "gray") { + gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray")) + if err != nil { + slog.Error("Invalid gray color format") + return lipgloss.Color("0") + } + return lipgloss.Color(strconv.Itoa(gray + 232)) + } + if ansi, okcolor := colorStrings[color]; okcolor { + return lipgloss.Color(strconv.Itoa(ansi)) + } + + slog.Error("Invalid color format") + return lipgloss.Color("0") +} + +type RGB struct { + r int + g int + b int +} + +func parseRGBString(rgbString string) (RGB, error) { + var err error + rgb := RGB{} + + if len(rgbString) != 3 { + return rgb, errors.New("invalid RGB format") + } + + rgb.r, err = strconv.Atoi(string(rgbString[0])) + if err != nil { + return rgb, errors.New("invalid value for R") + } + + rgb.g, err = strconv.Atoi(string(rgbString[1])) + if err != nil { + return rgb, errors.New("invalid value for G") + } + + rgb.b, err = strconv.Atoi(string(rgbString[2])) + if err != nil { + return rgb, errors.New("invalid value for B") + } + + return rgb, nil +} + +var colorStrings = map[string]int{ + "black": 0, + "red": 1, + "green": 2, + "yellow": 3, + "blue": 4, + "magenta": 5, + "cyan": 6, + "white": 7, + "bright black": 8, + "bright red": 9, + "bright green": 10, + "bright yellow": 11, + "bright blue": 12, + "bright magenta": 13, + "bright cyan": 14, + "bright white": 15, +} + +var baseColors = []RGB{ + {0, 0, 0}, // Black + {128, 0, 0}, // Red + {0, 128, 0}, // Green + {128, 128, 0}, // Yellow + {0, 0, 128}, // Blue + {128, 0, 128}, // Magenta + {0, 128, 128}, // Cyan + {192, 192, 192}, // White +} + +var highIntensityColors = []RGB{ + {128, 128, 128}, // Bright Black (Gray) + {255, 0, 0}, // Bright Red + {0, 255, 0}, // Bright Green + {255, 255, 0}, // Bright Yellow + {0, 0, 255}, // Bright Blue + {255, 0, 255}, // Bright Magenta + {0, 255, 255}, // Bright Cyan + {255, 255, 255}, // Bright White +} + +// Calculate the Euclidean distance between two colors +func colorDistance(c1, c2 RGB) float64 { + return math.Sqrt(float64((c1.r-c2.r)*(c1.r-c2.r) + (c1.g-c2.g)*(c1.g-c2.g) + (c1.b-c2.b)*(c1.b-c2.b))) +} + +// Convert RGB to the nearest ANSI color code +func rgbToAnsi(rgb RGB) string { + // Check standard and high-intensity colors + allColors := append(baseColors, highIntensityColors...) + bestIndex := 0 + minDist := colorDistance(rgb, allColors[0]) + + for i := 1; i < len(allColors); i++ { + dist := colorDistance(rgb, allColors[i]) + if dist < minDist { + bestIndex = i + minDist = dist + } + } + + if bestIndex < 8 { + return strconv.Itoa(bestIndex) + } else if bestIndex < 16 { + return strconv.Itoa(bestIndex + 8) + } + + // Check 6x6x6 color cube + for i := 0; i < 216; i++ { + cubeColor := RGB{ + (rgb.r / 51) * 51, + (rgb.g / 51) * 51, + (rgb.b / 51) * 51, + } + dist := colorDistance(rgb, cubeColor) + if dist < minDist { + bestIndex = i + 16 + minDist = dist + } + } + + // Check grayscale colors + for i := 0; i < 24; i++ { + gray := i*10 + 8 + grayColor := RGB{gray, gray, gray} + dist := colorDistance(rgb, grayColor) + if dist < minDist { + bestIndex = i + 232 + minDist = dist + } + } + + return strconv.Itoa(bestIndex) } diff --git a/pages/contextPicker.go b/pages/contextPicker.go index de15c4d..ef07278 100644 --- a/pages/contextPicker.go +++ b/pages/contextPicker.go @@ -35,9 +35,12 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { selected := common.TW.GetActiveContext().Name options := make([]string, 0) for _, c := range p.contexts { - options = append(options, c.Name) + if c.Name != "none" { + options = append(options, c.Name) + } } slices.Sort(options) + options = append([]string{"(none)"}, options...) p.form = huh.NewForm( huh.NewGroup( @@ -50,7 +53,8 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage { ), ). WithShowHelp(false). - WithShowErrors(true) + WithShowErrors(true). + WithTheme(p.common.Styles.Form) return p } @@ -99,7 +103,11 @@ func (p *ContextPickerPage) View() string { } func (p *ContextPickerPage) updateContextCmd() tea.Msg { - return UpdateContextMsg(p.common.TW.GetContext(p.form.GetString("context"))) + context := p.form.GetString("context") + if context == "(none)" { + context = "none" + } + return UpdateContextMsg(p.common.TW.GetContext(context)) } type UpdateContextMsg *taskwarrior.Context diff --git a/pages/report.go b/pages/report.go index ddc8ce9..743d275 100644 --- a/pages/report.go +++ b/pages/report.go @@ -1,9 +1,6 @@ package pages import ( - "strconv" - "strings" - "tasksquire/common" "tasksquire/taskwarrior" @@ -43,13 +40,12 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). + BorderForeground(com.Styles.Active.GetForeground()). BorderBottom(true). - Bold(false) + Bold(true) s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) + Reverse(true). + Bold(true) keys := ReportKeys{ Quit: key.NewBinding( @@ -91,6 +87,9 @@ func (p ReportPage) Init() tea.Cmd { func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.taskTable.SetWidth(msg.Width - 2) + p.taskTable.SetHeight(msg.Height - 4) case BackMsg: p.subpageActive = false case TaskMsg: @@ -107,9 +106,6 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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): @@ -157,24 +153,39 @@ func (p ReportPage) View() string { } 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}, + nCols := len(p.activeReport.Columns) + columns := make([]table.Column, 0) + columnSizes := make([]int, nCols) + fullRows := make([]table.Row, len(tasks)) + rows := make([]table.Row, len(tasks)) + + for i, task := range tasks { + row := table.Row{} + for i, col := range p.activeReport.Columns { + field := task.Get(col) + columnSizes[i] = max(columnSizes[i], len(field)) + row = append(row, field) + } + fullRows[i] = row } - 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, - }) + + for i, r := range fullRows { + row := table.Row{} + for j, size := range columnSizes { + if size == 0 { + continue + } + row = append(row, r[j]) + } + rows[i] = row + } + + for i, label := range p.activeReport.Labels { + if columnSizes[i] == 0 { + continue + } + + columns = append(columns, table.Column{Title: label, Width: max(columnSizes[i], len(label))}) } p.taskTable = table.New( diff --git a/pages/taskEditor.go b/pages/taskEditor.go index 6ff0480..1253724 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -85,7 +85,8 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor ), ). WithShowHelp(false). - WithShowErrors(false) + WithShowErrors(false). + WithTheme(p.common.Styles.Form) return p } diff --git a/taskwarrior/config.go b/taskwarrior/config.go index 29686d0..b20aabd 100644 --- a/taskwarrior/config.go +++ b/taskwarrior/config.go @@ -16,6 +16,10 @@ func NewConfig(config []string) *TWConfig { } } +func (tc *TWConfig) GetConfig() map[string]string { + return tc.config +} + func (tc *TWConfig) Get(key string) string { if _, ok := tc.config[key]; !ok { slog.Debug(fmt.Sprintf("Key not found in config: %s", key)) diff --git a/taskwarrior/models.go b/taskwarrior/models.go index 51cd4cf..4b8e48d 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -1,5 +1,13 @@ package taskwarrior +import ( + "fmt" + "log/slog" + "strconv" + "strings" + "time" +) + type Task struct { Id int64 `json:"id"` Uuid string `json:"uuid"` @@ -8,13 +16,69 @@ type Task struct { Priority string `json:"priority"` Status string `json:"status"` Tags []string `json:"tags"` + Depends []string `json:"depends"` Urgency float32 `json:"urgency"` Due string `json:"due"` Wait string `json:"wait"` Scheduled string `json:"scheduled"` + Until string `json:"until"` + Recur string `json:"recur"` + Start string `json:"start"` End string `json:"end"` Entry string `json:"entry"` Modified string `json:"modified"` + Parent string `json:"parent"` +} + +func (t *Task) Get(field string) string { + switch field { + case "id": + return strconv.FormatInt(t.Id, 10) + case "uuid": + return t.Uuid + case "description": + return t.Description + case "project": + return t.Project + case "priority": + return t.Priority + case "status": + return t.Status + case "tags": + return strings.Join(t.Tags, ", ") + case "urgency": + return fmt.Sprintf("%.2f", t.Urgency) + case "due": + return t.Due + case "wait": + return t.Wait + case "scheduled": + return t.Scheduled + case "end": + return t.End + case "entry": + return t.Entry + case "modified": + return t.Modified + // TODO: implement these fields + case "start.age": + return formatTime(t.Start) + case "depends": + return strings.Join(t.Depends, ", ") + case "entry.age": + return formatTime(t.Entry) + case "scheduled.countdown": + return formatTime(t.Scheduled) + case "until.remaining": + return formatTime(t.Until) + case "due.relative": + return formatTime(t.Due) + case "recur": + return t.Recur + default: + slog.Error(fmt.Sprintf("Field not implemented: %s", field)) + return "" + } } type Tasks []*Task @@ -39,3 +103,16 @@ type Report struct { } type Reports map[string]*Report + +func formatTime(timeStr string) string { + if timeStr == "" { + return "" + } + format := "20060102T150405Z" + t, err := time.Parse(format, timeStr) + if err != nil { + slog.Error("Failed to parse time:", err) + return timeStr + } + return t.Format("2006-01-02 15:04") +} diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go index 4542faf..4006b10 100644 --- a/taskwarrior/taskwarrior.go +++ b/taskwarrior/taskwarrior.go @@ -16,6 +16,22 @@ const ( ) var ( + reportBlacklist = map[string]struct{}{ + "burndown.daily": {}, + "burndown.monthly": {}, + "burndown.weekly": {}, + "calendar": {}, + "colors": {}, + "export": {}, + "ghistory.annual": {}, + "ghistory.monthly": {}, + "history.annual": {}, + "history.monthly": {}, + "information": {}, + "summary": {}, + "timesheet": {}, + } + tagBlacklist = map[string]struct{}{ "ACTIVE": {}, "ANNOTATED": {}, @@ -330,6 +346,9 @@ func (ts *TaskSquire) extractReports() Reports { reports := make(Reports) for _, report := range availableReports { + if _, ok := reportBlacklist[report]; ok { + continue + } reports[report] = &Report{ Name: report, Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)), diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index 209fe04..df7e68a 100644 Binary files a/test/taskchampion.sqlite3 and b/test/taskchampion.sqlite3 differ diff --git a/test/taskrc b/test/taskrc index a157542..66474c5 100644 --- a/test/taskrc +++ b/test/taskrc @@ -1,4 +1,6 @@ -data.location=/Users/moustachioed/projects/tasksquire/test -context.test1.read=+test -context.test1.write=+test -context=test1 +include light-256.theme + +context.test.read=+test +context.test.write=+test +context.home.read=+home +context.home.write=+home