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
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)
}

View File

@ -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

View File

@ -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(

View File

@ -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
}

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

View File

@ -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")
}

View File

@ -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)),

Binary file not shown.

View File

@ -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