From f7b54b607b319ae9b8ef455a026e57c74e20abb2 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Fri, 24 May 2024 17:17:58 +0200 Subject: [PATCH] [WIP] table formatting --- common/common.go | 6 + common/styles.go | 12 +- components/table/table.go | 541 +++++++++++++++++++++++++++++++++++++ pages/report.go | 136 ++-------- taskwarrior/models.go | 1 + taskwarrior/taskwarrior.go | 8 +- 6 files changed, 587 insertions(+), 117 deletions(-) create mode 100644 components/table/table.go diff --git a/common/common.go b/common/common.go index 92544f5..30158cb 100644 --- a/common/common.go +++ b/common/common.go @@ -2,8 +2,12 @@ package common import ( "context" + "log/slog" + "os" "tasksquire/taskwarrior" + + "golang.org/x/term" ) type Common struct { @@ -31,6 +35,8 @@ func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common { func (c *Common) SetSize(width, height int) { c.width = width c.height = height + physicalWidth, physicalHeight, _ := term.GetSize(int(os.Stdout.Fd())) + slog.Info("SetSize", "width", width, "height", height, "physicalWidth", physicalWidth, "physicalHeight", physicalHeight) } func (c *Common) Width() int { diff --git a/common/styles.go b/common/styles.go index 0d5ab8e..6b349e3 100644 --- a/common/styles.go +++ b/common/styles.go @@ -7,16 +7,19 @@ import ( "strconv" "strings" + // "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" + "tasksquire/components/table" "tasksquire/taskwarrior" ) type Styles struct { Main lipgloss.Style - Form *huh.Theme + Form *huh.Theme + TableStyle table.Styles Active lipgloss.Style Alternate lipgloss.Style @@ -71,6 +74,13 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles { styles := parseColors(config.GetConfig()) styles.Main = lipgloss.NewStyle() + styles.TableStyle = table.Styles{ + // Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true), + Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true), + Selected: lipgloss.NewStyle().Foreground(styles.Active.GetForeground()).Background(styles.Active.GetBackground()).Bold(true).Reverse(true), + } + formTheme := huh.ThemeBase() formTheme.Focused.Title = formTheme.Focused.Title.Bold(true) formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ") diff --git a/components/table/table.go b/components/table/table.go new file mode 100644 index 0000000..d68de47 --- /dev/null +++ b/components/table/table.go @@ -0,0 +1,541 @@ +package table + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + + "tasksquire/common" + "tasksquire/taskwarrior" +) + +// Model defines a state for the table widget. +type Model struct { + common *common.Common + KeyMap KeyMap + + cols []Column + rows taskwarrior.Tasks + rowStyles []lipgloss.Style + cursor int + focus bool + styles Styles + styleFunc StyleFunc + + viewport viewport.Model + start int + end int +} + +// Row represents one line in the table. +type Row *taskwarrior.Task + +// Column defines the table structure. +type Column struct { + Title string + Name string + Width int + MaxWidth int + ContentWidth int +} + +// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which +// is used to render the menu. +type KeyMap struct { + LineUp key.Binding + LineDown key.Binding + PageUp key.Binding + PageDown key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding +} + +// ShortHelp implements the KeyMap interface. +func (km KeyMap) ShortHelp() []key.Binding { + return []key.Binding{km.LineUp, km.LineDown} +} + +// FullHelp implements the KeyMap interface. +func (km KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom}, + {km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown}, + } +} + +// DefaultKeyMap returns a default set of keybindings. +func DefaultKeyMap() KeyMap { + const spacebar = " " + return KeyMap{ + LineUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + LineDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PageUp: key.NewBinding( + key.WithKeys("b", "pgup"), + key.WithHelp("b/pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("f", "pgdown", spacebar), + key.WithHelp("f/pgdn", "page down"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u", "ctrl+u"), + key.WithHelp("u", "½ page up"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d", "ctrl+d"), + key.WithHelp("d", "½ page down"), + ), + GotoTop: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("g/home", "go to start"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("G/end", "go to end"), + ), + } +} + +// Styles contains style definitions for this list component. By default, these +// values are generated by DefaultStyles. +type Styles struct { + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style +} + +// DefaultStyles returns a set of default style definitions for this table. +func DefaultStyles() Styles { + return Styles{ + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), + Cell: lipgloss.NewStyle().Padding(0, 1), + } +} + +// SetStyles sets the table styles. +func (m *Model) SetStyles(s Styles) { + m.styles = s + m.UpdateViewport() +} + +// Option is used to set options in New. For example: +// +// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) +type Option func(*Model) + +// New creates a new model for the table widget. +func New(com *common.Common, opts ...Option) Model { + m := Model{ + cursor: 0, + viewport: viewport.New(0, 20), + + KeyMap: DefaultKeyMap(), + styles: DefaultStyles(), + } + + for _, opt := range opts { + opt(&m) + } + + m.cols = m.parseColumns(m.cols) + m.rowStyles = m.parseRowStyles(m.rows) + + m.UpdateViewport() + + return m +} + +func (m *Model) parseRowStyles() + +func (m *Model) parseColumns(cols []Column) []Column { + for i, col := range cols { + for _, task := range m.rows { + col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name))) + } + cols[i] = col + } + + combinedSize := 0 + nonZeroWidths := 0 + descIndex := -1 + for i, col := range cols { + col.Width = max(col.ContentWidth, lipgloss.Width(col.Title)) + + if col.Width > 0 { + nonZeroWidths++ + } + + if !strings.Contains(col.Name, "description") { + combinedSize += col.Width + } else { + descIndex = i + } + + cols[i] = col + } + + if descIndex >= 0 { + cols[descIndex].Width = m.Width() - combinedSize - nonZeroWidths + } + + return cols +} + +// WithColumns sets the table columns (headers). +func WithColumns(cols []Column) Option { + return func(m *Model) { + m.cols = cols + } +} + +func WithReport(report *taskwarrior.Report) Option { + return func(m *Model) { + columns := make([]Column, len(report.Columns)) + for i, col := range report.Columns { + columns[i] = Column{ + Title: report.Labels[i], + Name: col, + Width: 0, + } + } + m.cols = columns + } +} + +// WithRows sets the table rows (data). +func WithRows(rows taskwarrior.Tasks) Option { + return func(m *Model) { + m.rows = rows + } +} + +// WithRows sets the table rows (data). +func WithTasks(rows taskwarrior.Tasks) Option { + return func(m *Model) { + m.rows = rows + } +} + +// WithHeight sets the height of the table. +func WithHeight(h int) Option { + return func(m *Model) { + m.viewport.Height = h - lipgloss.Height(m.headersView()) + } +} + +// WithWidth sets the width of the table. +func WithWidth(w int) Option { + return func(m *Model) { + m.viewport.Width = w + } +} + +// WithFocused sets the focus state of the table. +func WithFocused(f bool) Option { + return func(m *Model) { + m.focus = f + } +} + +// WithStyles sets the table styles. +func WithStyles(s Styles) Option { + return func(m *Model) { + m.styles = s + } +} + +// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state. +func WithStyleFunc(f StyleFunc) Option { + return func(m *Model) { + m.styleFunc = f + } +} + +// WithKeyMap sets the key map. +func WithKeyMap(km KeyMap) Option { + return func(m *Model) { + m.KeyMap = km + } +} + +// Update is the Bubble Tea update loop. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if !m.focus { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.KeyMap.LineUp): + m.MoveUp(1) + case key.Matches(msg, m.KeyMap.LineDown): + m.MoveDown(1) + case key.Matches(msg, m.KeyMap.PageUp): + m.MoveUp(m.viewport.Height) + case key.Matches(msg, m.KeyMap.PageDown): + m.MoveDown(m.viewport.Height) + case key.Matches(msg, m.KeyMap.HalfPageUp): + m.MoveUp(m.viewport.Height / 2) + case key.Matches(msg, m.KeyMap.HalfPageDown): + m.MoveDown(m.viewport.Height / 2) + case key.Matches(msg, m.KeyMap.LineDown): + m.MoveDown(1) + case key.Matches(msg, m.KeyMap.GotoTop): + m.GotoTop() + case key.Matches(msg, m.KeyMap.GotoBottom): + m.GotoBottom() + } + } + + return m, nil +} + +// Focused returns the focus state of the table. +func (m Model) Focused() bool { + return m.focus +} + +// Focus focuses the table, allowing the user to move around the rows and +// interact. +func (m *Model) Focus() { + m.focus = true + m.UpdateViewport() +} + +// Blur blurs the table, preventing selection or movement. +func (m *Model) Blur() { + m.focus = false + m.UpdateViewport() +} + +// View renders the component. +func (m Model) View() string { + return m.headersView() + "\n" + m.viewport.View() +} + +// UpdateViewport updates the list content based on the previously defined +// columns and rows. +func (m *Model) UpdateViewport() { + renderedRows := make([]string, 0, len(m.rows)) + + // Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height + // Constant runtime, independent of number of rows in a table. + // Limits the number of renderedRows to a maximum of 2*m.viewport.Height + if m.cursor >= 0 { + m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor) + } else { + m.start = 0 + } + m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows)) + for i := m.start; i < m.end; i++ { + renderedRows = append(renderedRows, m.renderRow(i)) + } + + m.viewport.SetContent( + lipgloss.JoinVertical(lipgloss.Left, renderedRows...), + ) +} + +// SelectedRow returns the selected row. +// You can cast it to your own implementation. +func (m Model) SelectedRow() Row { + if m.cursor < 0 || m.cursor >= len(m.rows) { + return nil + } + + return m.rows[m.cursor] +} + +// Rows returns the current rows. +func (m Model) Rows() taskwarrior.Tasks { + return m.rows +} + +// Columns returns the current columns. +func (m Model) Columns() []Column { + return m.cols +} + +// SetRows sets a new rows state. +func (m *Model) SetRows(r taskwarrior.Tasks) { + m.rows = r + m.UpdateViewport() +} + +// SetColumns sets a new columns state. +func (m *Model) SetColumns(c []Column) { + m.cols = c + m.UpdateViewport() +} + +// SetWidth sets the width of the viewport of the table. +func (m *Model) SetWidth(w int) { + m.viewport.Width = w + m.UpdateViewport() +} + +// SetHeight sets the height of the viewport of the table. +func (m *Model) SetHeight(h int) { + m.viewport.Height = h - lipgloss.Height(m.headersView()) + m.UpdateViewport() +} + +// Height returns the viewport height of the table. +func (m Model) Height() int { + return m.viewport.Height +} + +// Width returns the viewport width of the table. +func (m Model) Width() int { + return m.viewport.Width +} + +// Cursor returns the index of the selected row. +func (m Model) Cursor() int { + return m.cursor +} + +// SetCursor sets the cursor position in the table. +func (m *Model) SetCursor(n int) { + m.cursor = clamp(n, 0, len(m.rows)-1) + m.UpdateViewport() +} + +// MoveUp moves the selection up by any number of rows. +// It can not go above the first row. +func (m *Model) MoveUp(n int) { + m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1) + switch { + case m.start == 0: + m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor)) + case m.start < m.viewport.Height: + m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height)) + case m.viewport.YOffset >= 1: + m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height) + } + m.UpdateViewport() +} + +// MoveDown moves the selection down by any number of rows. +// It can not go below the last row. +func (m *Model) MoveDown(n int) { + m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1) + m.UpdateViewport() + + switch { + case m.end == len(m.rows) && m.viewport.YOffset > 0: + m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height)) + case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0: + m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor)) + case m.viewport.YOffset > 1: + case m.cursor > m.viewport.YOffset+m.viewport.Height-1: + m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1)) + } +} + +// GotoTop moves the selection to the first row. +func (m *Model) GotoTop() { + m.MoveUp(m.cursor) +} + +// GotoBottom moves the selection to the last row. +func (m *Model) GotoBottom() { + m.MoveDown(len(m.rows)) +} + +// FromValues create the table rows from a simple string. It uses `\n` by +// default for getting all the rows and the given separator for the fields on +// each row. +// func (m *Model) FromValues(value, separator string) { +// rows := []Row{} +// for _, line := range strings.Split(value, "\n") { +// r := Row{} +// for _, field := range strings.Split(line, separator) { +// r = append(r, field) +// } +// rows = append(rows, r) +// } + +// m.SetRows(rows) +// } + +// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index. +type StyleFunc func(row, col int, value string) lipgloss.Style + +func (m Model) headersView() string { + var s = make([]string, 0, len(m.cols)) + for _, col := range m.cols { + if col.Width <= 0 { + continue + } + style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) + renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) + s = append(s, m.styles.Header.Render(renderedCell)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, s...) +} + +func (m *Model) renderRow(r int) string { + var s = make([]string, 0, len(m.cols)) + for i, col := range m.cols { + // for i, task := range m.rows[r] { + if m.cols[i].Width <= 0 { + continue + } + var cellStyle lipgloss.Style + if m.styleFunc != nil { + cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name)) + if r == m.cursor { + cellStyle.Inherit(m.styles.Selected) + } + } else { + cellStyle = m.rowStyle[r] + } + + style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) + renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…"))) + s = append(s, renderedCell) + } + + row := lipgloss.JoinHorizontal(lipgloss.Left, s...) + + if r == m.cursor { + return m.styles.Selected.Render(row) + } + + return row +} + +func max(a, b int) int { + if a > b { + return a + } + + return b +} + +func min(a, b int) int { + if a < b { + return a + } + + return b +} + +func clamp(v, low, high int) int { + return min(max(v, low), high) +} diff --git a/pages/report.go b/pages/report.go index e618c3f..b60c528 100644 --- a/pages/report.go +++ b/pages/report.go @@ -2,14 +2,14 @@ package pages import ( - "strings" + "log/slog" "tasksquire/common" + "tasksquire/components/table" "tasksquire/taskwarrior" "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" + // "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) type ReportPage struct { @@ -22,71 +22,27 @@ type ReportPage struct { tasks taskwarrior.Tasks - taskTable table.Model - tableStyle table.Styles - keymap ReportKeys + taskTable table.Model 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(com.Styles.Active.GetForeground()). - BorderBottom(true). - Bold(true) - s.Selected = s.Selected. - Reverse(true). - Bold(true) - - 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) SetSize(width int, height int) { p.common.SetSize(width, height) + slog.Info("FramSize", "vert", p.common.Styles.Main.GetVerticalFrameSize(), "horz", p.common.Styles.Main.GetHorizontalFrameSize()) - p.taskTable.SetWidth(width - 2) - p.taskTable.SetHeight(height - 4) + p.taskTable.SetWidth(width - p.common.Styles.Main.GetVerticalFrameSize()) + p.taskTable.SetHeight(height - p.common.Styles.Main.GetHorizontalFrameSize()) } func (p *ReportPage) Init() tea.Cmd { @@ -180,77 +136,33 @@ func (p *ReportPage) View() string { } func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { - var selected int + if len(tasks) == 0 { + return + } - 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)) - descIndex := -1 + selected := 0 - for i, task := range tasks { - if p.selectedTask != nil && task.Uuid == p.selectedTask.Uuid { - selected = i - } - - row := table.Row{} - for i, col := range p.activeReport.Columns { - if strings.Contains(col, "description") { - descIndex = i + if p.selectedTask != nil { + for i, task := range tasks { + if task.Uuid == p.selectedTask.Uuid { + selected = i } - field := task.GetString(col) - columnSizes[i] = max(columnSizes[i], len(field)) - row = append(row, field) } - fullRows[i] = row } - 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 - } - - combinedSize := 0 - for i, label := range p.activeReport.Labels { - if columnSizes[i] == 0 { - continue - } - - width := max(columnSizes[i], len(label)) - columns = append(columns, table.Column{Title: label, Width: width}) - - if i == descIndex { - descIndex = len(columns) - 1 - continue - } - combinedSize += width - } - - if descIndex >= 0 { - columns[descIndex].Width = p.taskTable.Width() - combinedSize - 14 - } + p.taskTable = table.New( + table.WithReport(p.activeReport), + table.WithTasks(tasks), + table.WithFocused(true), + table.WithWidth(p.common.Width()-p.common.Styles.Main.GetVerticalFrameSize()), + table.WithHeight(p.common.Height()-p.common.Styles.Main.GetHorizontalFrameSize()-10), + table.WithStyles(p.common.Styles.TableStyle), + ) if selected == 0 { selected = p.taskTable.Cursor() } - - p.taskTable = table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - // table.WithHeight(7), - // table.WithWidth(100), - ) - p.taskTable.SetStyles(p.tableStyle) - - if selected < len(p.tasks) { + if selected < len(tasks) { p.taskTable.SetCursor(selected) } else { p.taskTable.SetCursor(len(p.tasks) - 1) diff --git a/taskwarrior/models.go b/taskwarrior/models.go index e5fda31..070ea02 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -26,6 +26,7 @@ type Task struct { Priority string `json:"priority"` Status string `json:"status,omitempty"` Tags []string `json:"tags"` + VirtualTags []string `json:"-"` Depends []string `json:"depends,omitempty"` DependsIds string `json:"-"` Urgency float32 `json:"urgency,omitempty"` diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go index f3395c6..539d857 100644 --- a/taskwarrior/taskwarrior.go +++ b/taskwarrior/taskwarrior.go @@ -20,7 +20,7 @@ const ( ) var ( - reportBlacklist = map[string]struct{}{ + nonStandardReports = map[string]struct{}{ "burndown.daily": {}, "burndown.monthly": {}, "burndown.weekly": {}, @@ -36,7 +36,7 @@ var ( "timesheet": {}, } - tagBlacklist = map[string]struct{}{ + virtualTags = map[string]struct{}{ "ACTIVE": {}, "ANNOTATED": {}, "BLOCKED": {}, @@ -280,7 +280,7 @@ func (ts *TaskSquire) GetTags() []string { tags := make([]string, 0) for _, tag := range strings.Split(string(output), "\n") { - if _, ok := tagBlacklist[tag]; !ok && tag != "" { + if _, ok := virtualTags[tag]; !ok && tag != "" { tags = append(tags, tag) } } @@ -426,7 +426,7 @@ func (ts *TaskSquire) extractReports() Reports { reports := make(Reports) for _, report := range availableReports { - if _, ok := reportBlacklist[report]; ok { + if _, ok := nonStandardReports[report]; ok { continue } reports[report] = &Report{