Style forms; [WIP] Draw table

This commit is contained in:
Martin Pander
2024-05-21 16:52:18 +02:00
parent d960f1f113
commit fce35f0fc7
9 changed files with 517 additions and 45 deletions

View File

@ -1,6 +1,13 @@
package common package common
import ( import (
"errors"
"log/slog"
"math"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
@ -9,17 +16,360 @@ import (
type Styles struct { type Styles struct {
Main lipgloss.Style Main lipgloss.Style
ActiveTask lipgloss.Style Form *huh.Theme
InactiveTask lipgloss.Style
NormalTask lipgloss.Style 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 { func NewStyles(config *taskwarrior.TWConfig) *Styles {
return &Styles{ styles := parseColors(config.GetConfig())
Main: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), styles.Main = lipgloss.NewStyle()
ActiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")), formTheme := huh.ThemeBase()
InactiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")), formTheme.Focused.Card = formTheme.Focused.Card.BorderStyle(lipgloss.RoundedBorder()).BorderBottom(true).BorderTop(true)
NormalTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), 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)
} }

View File

@ -35,9 +35,12 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
selected := common.TW.GetActiveContext().Name selected := common.TW.GetActiveContext().Name
options := make([]string, 0) options := make([]string, 0)
for _, c := range p.contexts { for _, c := range p.contexts {
options = append(options, c.Name) if c.Name != "none" {
options = append(options, c.Name)
}
} }
slices.Sort(options) slices.Sort(options)
options = append([]string{"(none)"}, options...)
p.form = huh.NewForm( p.form = huh.NewForm(
huh.NewGroup( huh.NewGroup(
@ -50,7 +53,8 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
), ),
). ).
WithShowHelp(false). WithShowHelp(false).
WithShowErrors(true) WithShowErrors(true).
WithTheme(p.common.Styles.Form)
return p return p
} }
@ -99,7 +103,11 @@ func (p *ContextPickerPage) View() string {
} }
func (p *ContextPickerPage) updateContextCmd() tea.Msg { 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 type UpdateContextMsg *taskwarrior.Context

View File

@ -1,9 +1,6 @@
package pages package pages
import ( import (
"strconv"
"strings"
"tasksquire/common" "tasksquire/common"
"tasksquire/taskwarrior" "tasksquire/taskwarrior"
@ -43,13 +40,12 @@ func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
s := table.DefaultStyles() s := table.DefaultStyles()
s.Header = s.Header. s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")). BorderForeground(com.Styles.Active.GetForeground()).
BorderBottom(true). BorderBottom(true).
Bold(false) Bold(true)
s.Selected = s.Selected. s.Selected = s.Selected.
Foreground(lipgloss.Color("229")). Reverse(true).
Background(lipgloss.Color("57")). Bold(true)
Bold(false)
keys := ReportKeys{ keys := ReportKeys{
Quit: key.NewBinding( Quit: key.NewBinding(
@ -91,6 +87,9 @@ func (p ReportPage) Init() tea.Cmd {
func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.taskTable.SetWidth(msg.Width - 2)
p.taskTable.SetHeight(msg.Height - 4)
case BackMsg: case BackMsg:
p.subpageActive = false p.subpageActive = false
case TaskMsg: case TaskMsg:
@ -107,9 +106,6 @@ func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case AddedTaskMsg: case AddedTaskMsg:
cmds = append(cmds, p.getTasks()) cmds = append(cmds, p.getTasks())
case tea.WindowSizeMsg:
p.taskTable.SetWidth(msg.Width - 2)
p.taskTable.SetHeight(msg.Height - 4)
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, p.common.Keymap.Quit): case key.Matches(msg, p.common.Keymap.Quit):
@ -157,24 +153,39 @@ func (p ReportPage) View() string {
} }
func (p *ReportPage) populateTaskTable(tasks []*taskwarrior.Task) { func (p *ReportPage) populateTaskTable(tasks []*taskwarrior.Task) {
columns := []table.Column{ nCols := len(p.activeReport.Columns)
{Title: "ID", Width: 4}, columns := make([]table.Column, 0)
{Title: "Project", Width: 10}, columnSizes := make([]int, nCols)
{Title: "Tags", Width: 10}, fullRows := make([]table.Row, len(tasks))
{Title: "Prio", Width: 2}, rows := make([]table.Row, len(tasks))
{Title: "Due", Width: 10},
{Title: "Task", Width: 50}, 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 { for i, r := range fullRows {
rows = append(rows, table.Row{ row := table.Row{}
strconv.FormatInt(task.Id, 10), for j, size := range columnSizes {
task.Project, if size == 0 {
strings.Join(task.Tags, ", "), continue
task.Priority, }
task.Due, row = append(row, r[j])
task.Description, }
}) 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( p.taskTable = table.New(

View File

@ -85,7 +85,8 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor
), ),
). ).
WithShowHelp(false). WithShowHelp(false).
WithShowErrors(false) WithShowErrors(false).
WithTheme(p.common.Styles.Form)
return p return p
} }

View File

@ -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 { func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok { if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key)) slog.Debug(fmt.Sprintf("Key not found in config: %s", key))

View File

@ -1,5 +1,13 @@
package taskwarrior package taskwarrior
import (
"fmt"
"log/slog"
"strconv"
"strings"
"time"
)
type Task struct { type Task struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Uuid string `json:"uuid"` Uuid string `json:"uuid"`
@ -8,13 +16,69 @@ type Task struct {
Priority string `json:"priority"` Priority string `json:"priority"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Depends []string `json:"depends"`
Urgency float32 `json:"urgency"` Urgency float32 `json:"urgency"`
Due string `json:"due"` Due string `json:"due"`
Wait string `json:"wait"` Wait string `json:"wait"`
Scheduled string `json:"scheduled"` Scheduled string `json:"scheduled"`
Until string `json:"until"`
Recur string `json:"recur"`
Start string `json:"start"`
End string `json:"end"` End string `json:"end"`
Entry string `json:"entry"` Entry string `json:"entry"`
Modified string `json:"modified"` 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 type Tasks []*Task
@ -39,3 +103,16 @@ type Report struct {
} }
type Reports map[string]*Report 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")
}

View File

@ -16,6 +16,22 @@ const (
) )
var ( 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{}{ tagBlacklist = map[string]struct{}{
"ACTIVE": {}, "ACTIVE": {},
"ANNOTATED": {}, "ANNOTATED": {},
@ -330,6 +346,9 @@ func (ts *TaskSquire) extractReports() Reports {
reports := make(Reports) reports := make(Reports)
for _, report := range availableReports { for _, report := range availableReports {
if _, ok := reportBlacklist[report]; ok {
continue
}
reports[report] = &Report{ reports[report] = &Report{
Name: report, Name: report,
Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)), Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)),

Binary file not shown.

View File

@ -1,4 +1,6 @@
data.location=/Users/moustachioed/projects/tasksquire/test include light-256.theme
context.test1.read=+test
context.test1.write=+test context.test.read=+test
context=test1 context.test.write=+test
context.home.read=+home
context.home.write=+home