From f6ce2e30dcfdfd2219254f72009e3c9d58c378e5 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Thu, 26 Feb 2026 22:49:00 +0100 Subject: [PATCH] Integrate tasktable --- .gitignore | 1 + .tmuxp.yaml | 25 + internal/common/styles.go | 80 ++- internal/components/table/table.go | 468 ++++++++++++++++++ internal/components/table/table_test.go | 80 +++ internal/pages/main.go | 63 ++- internal/pages/tasks.go | 170 ++++--- .../tasktable.go => tasktable.go.old | 0 8 files changed, 778 insertions(+), 109 deletions(-) create mode 100644 .tmuxp.yaml create mode 100644 internal/components/table/table.go create mode 100644 internal/components/table/table_test.go rename internal/components/tasktable/tasktable.go => tasktable.go.old (100%) diff --git a/.gitignore b/.gitignore index 173babd..7cb4815 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ tasksquire test/*.sqlite3* result main +__debug* diff --git a/.tmuxp.yaml b/.tmuxp.yaml new file mode 100644 index 0000000..834b955 --- /dev/null +++ b/.tmuxp.yaml @@ -0,0 +1,25 @@ +session_name: task +start_directory: ./ + +windows: +- window_name: code + panes: + - focus: 'true' + shell_command: jj st +- window_name: go + panes: + - focus: 'true' + shell_command: clear +- window_name: oc + panes: + - focus: 'true' + shell_command: clear +- window_name: v1 + start_directory: ../tasksquire_dev + panes: + - focus: 'true' + shell_command: clear +- window_name: sh + panes: + - focus: 'true' + shell_command: clear diff --git a/internal/common/styles.go b/internal/common/styles.go index bec2f14..33a0453 100644 --- a/internal/common/styles.go +++ b/internal/common/styles.go @@ -5,6 +5,8 @@ import ( "strconv" "strings" "image/color" + "fmt" + "time" "tasksquire/internal/taskwarrior" @@ -82,10 +84,10 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles { styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground()) styles.TableStyle = TableStyle{ - // 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).Foreground(styles.Palette.Primary.GetForeground()), - Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()), + // Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()), + Selected: lipgloss.NewStyle().Bold(true).Reverse(true), } // formTheme := huh.ThemeBase() @@ -232,3 +234,77 @@ var colorStrings = map[string]int{ "bright cyan": 14, "bright white": 15, } + +func GetTaskTabelStyle(task *taskwarrior.Task, com Common) lipgloss.Style { + if task.Status == "deleted" { + if c, ok := com.Styles.Colors["deleted"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if task.Status == "completed" { + if c, ok := com.Styles.Colors["completed"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if task.Status == "pending" && task.Start != "" { + if c, ok := com.Styles.Colors["active"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + // TODO: implement keyword + // TODO: implement tag + if task.HasTag("next") { + if c, ok := com.Styles.Colors["tag.next"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + // TODO: implement project + if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) { + if c, ok := com.Styles.Colors["overdue"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if task.Scheduled != "" { + if c, ok := com.Styles.Colors["scheduled"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) { + if c, ok := com.Styles.Colors["due.today"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if task.Due != "" { + if c, ok := com.Styles.Colors["due"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if len(task.Depends) > 0 { + if c, ok := com.Styles.Colors["blocked"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + // TODO implement blocking + if task.Recur != "" { + if c, ok := com.Styles.Colors["recurring"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + // TODO: make styles optional and discard if empty + if len(task.Tags) > 0 { + if c, ok := com.Styles.Colors["tagged"]; ok && c != nil { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + if len(com.Udas) > 0 { + for _, uda := range com.Udas { + if u, ok := task.Udas[uda.Name]; ok { + if c, ok := com.Styles.Colors[fmt.Sprintf("uda.%s.%s", uda.Name, u)]; ok { + return c.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + } + } + } + return com.Styles.Base.Inherit(com.Styles.TableStyle.Cell).Margin(com.Styles.TableStyle.Cell.GetMargin()).Padding(com.Styles.TableStyle.Cell.GetPadding()) + } + diff --git a/internal/components/table/table.go b/internal/components/table/table.go new file mode 100644 index 0000000..e743a63 --- /dev/null +++ b/internal/components/table/table.go @@ -0,0 +1,468 @@ +// Package table provides a simple table component for Bubble Tea applications. +package table + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// Model defines a state for the table widget. +type Model struct { + KeyMap KeyMap + Help help.Model + + cols []Column + rows []Row + rowStyles []lipgloss.Style + cursor int + focus bool + styles Styles + + viewport viewport.Model + start int + end int +} + +// Row represents one line in the table. +type Row []string + +// Column defines the table structure. +type Column struct { + Title string + Width int +} + +// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which +// is used to render the help 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 { + 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", "space"), + 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(opts ...Option) Model { + m := Model{ + cursor: 0, + viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd + + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + } + + for _, opt := range opts { + opt(&m) + } + + m.UpdateViewport() + + return m +} + +// WithColumns sets the table columns (headers). +func WithColumns(cols []Column) Option { + return func(m *Model) { + m.cols = cols + } +} + +// WithRows sets the table rows (data). +func WithRows(rows []Row) Option { + return func(m *Model) { + m.rows = rows + } +} + +// WithRowStyles sets the per row styles +func WithRowStyles(styles []lipgloss.Style) Option { + return func(m *Model) { + m.rowStyles = styles + } +} + +// WithHeight sets the height of the table. +func WithHeight(h int) Option { + return func(m *Model) { + m.viewport.SetHeight(h - lipgloss.Height(m.headersView())) + } +} + +// WithWidth sets the width of the table. +func WithWidth(w int) Option { + return func(m *Model) { + m.viewport.SetWidth(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 + } +} + +// 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.KeyPressMsg: + 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) //nolint:mnd + case key.Matches(msg, m.KeyMap.HalfPageDown): + m.MoveDown(m.viewport.Height() / 2) //nolint:mnd + 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() +} + +// HelpView is a helper method for rendering the help menu from the keymap. +// Note that this view is not rendered by default and you must call it +// manually in your application, where applicable. +func (m Model) HelpView() string { + return m.Help.View(m.KeyMap) +} + +// 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() []Row { + 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 []Row) { + m.rows = r + + if m.cursor > len(m.rows)-1 { + m.cursor = len(m.rows) - 1 + } + + 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.SetWidth(w) + m.UpdateViewport() +} + +// SetHeight sets the height of the viewport of the table. +func (m *Model) SetHeight(h int) { + m.viewport.SetHeight(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) + + offset := m.viewport.YOffset() + switch { + case m.start == 0: + offset = clamp(offset, 0, m.cursor) + case m.start < m.viewport.Height(): + offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height()) + case offset >= 1: + offset = clamp(offset+n, 1, m.viewport.Height()) + } + m.viewport.SetYOffset(offset) + 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() + + offset := m.viewport.YOffset() + switch { + case m.end == len(m.rows) && offset > 0: + offset = clamp(offset-n, 1, m.viewport.Height()) + case m.cursor > (m.end-m.start)/2 && offset > 0: + offset = clamp(offset-n, 1, m.cursor) + case offset > 1: + case m.cursor > offset+m.viewport.Height()-1: + offset = clamp(offset+1, 0, 1) + } + m.viewport.SetYOffset(offset) +} + +// 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{} //nolint:prealloc + 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) +} + +func (m Model) headersView() string { + 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(ansi.Truncate(col.Title, col.Width, "…")) + s = append(s, m.styles.Header.Render(renderedCell)) + } + return lipgloss.JoinHorizontal(lipgloss.Top, s...) +} + +func (m *Model) renderRow(r int) string { + s := make([]string, 0, len(m.cols)) + for i, value := range m.rows[r] { + if m.cols[i].Width <= 0 { + continue + } + + style := lipgloss.NewStyle(). + Width(m.cols[i].Width + m.styles.Cell.GetHorizontalPadding()). + MaxWidth(m.cols[i].Width + m.styles.Cell.GetHorizontalPadding()). + Inherit(m.rowStyles[r]). + Inherit(m.styles.Cell). + Inline(true) + + if r == m.cursor { + style = style.Inherit(m.styles.Selected) + } + + renderedCell := style.Render(ansi.Truncate(value, m.cols[i].Width, "…")) + s = append(s, renderedCell) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, s...) + + return row +} + +func clamp(v, low, high int) int { + return min(max(v, low), high) +} diff --git a/internal/components/table/table_test.go b/internal/components/table/table_test.go new file mode 100644 index 0000000..d833797 --- /dev/null +++ b/internal/components/table/table_test.go @@ -0,0 +1,80 @@ +package table + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestRenderRowSelection(t *testing.T) { + cols := []Column{ + {Title: "ID", Width: 5}, + {Title: "Task", Width: 10}, + } + rows := []Row{ + {"1", "Task 1"}, + {"2", "Task 2"}, + } + + m := New( + WithColumns(cols), + WithRows(rows), + WithStyles(Styles{ + Cell: lipgloss.NewStyle().Padding(0, 1), + Selected: lipgloss.NewStyle().Background(lipgloss.Color("212")), + }), + ) + + m.SetWidth(30) + m.SetCursor(0) + + rendered := m.renderRow(0) + + // The rendered row should contain the background color 212 + // AND the background should be present in the padding area. + // Since we are using lipgloss, we check for the color code. + if !strings.Contains(rendered, "212") { + t.Errorf("expected rendered row to contain background color 212, got %s", rendered) + } + + // Verify it has the full width + if lipgloss.Width(rendered) != 30 { + t.Errorf("expected width 30, got %d", lipgloss.Width(rendered)) + } +} + +func TestRenderRowOutOfBounds(t *testing.T) { + cols := []Column{ + {Title: "ID", Width: 5}, + } + rows := []Row{ + {"1", "Task 1 Extra Column"}, + } + + m := New( + WithColumns(cols), + WithRows(rows), + ) + + // This should not panic + m.renderRow(0) +} + +func TestRenderRowNoStyles(t *testing.T) { + cols := []Column{ + {Title: "ID", Width: 5}, + } + rows := []Row{ + {"1"}, + } + + m := New( + WithColumns(cols), + WithRows(rows), + ) + m.rowStyles = nil // Ensure rowStyles is nil + + // This should not panic + m.renderRow(0) +} diff --git a/internal/pages/main.go b/internal/pages/main.go index 6dbba2a..e94c33d 100644 --- a/internal/pages/main.go +++ b/internal/pages/main.go @@ -4,7 +4,7 @@ import ( "tasksquire/internal/common" tea "charm.land/bubbletea/v2" - // "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/key" "charm.land/lipgloss/v2" ) @@ -34,33 +34,30 @@ func NewMainPage(common *common.Common) *MainPage { } func (m *MainPage) Init() tea.Cmd { + return tea.Batch(m.taskPage.Init()) // return tea.Batch(m.taskPage.Init(), m.timePage.Init()) - return tea.Batch() } func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - // switch msg := msg.(type) { - // case tea.WindowSizeMsg: - // m.width = msg.Width - // m.height = msg.Height - // m.common.SetSize(msg.Width, msg.Height) - // - // tabHeight := lipgloss.Height(m.renderTabBar()) - // contentHeight := msg.Height - tabHeight - // if contentHeight < 0 { - // contentHeight = 0 - // } - // - // newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight} - // activePage, cmd := m.activePage.Update(newMsg) - // m.activePage = activePage.(common.Component) - // return m, cmd - // - // case tea.KeyMsg: - // // Only handle tab key for page switching when at the top level (no subpages active) - // if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.common.SetSize(msg.Width, msg.Height) + + tabHeight := lipgloss.Height(m.renderTabBar()) + contentHeight := max(msg.Height - tabHeight, 0) + + newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight} + activePage, cmd := m.activePage.Update(newMsg) + m.activePage = activePage.(common.Component) + return m, cmd + + case tea.KeyMsg: + // Only handle tab key for page switching when at the top level (no subpages active) + if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() { // if m.activePage == m.taskPage { // m.activePage = m.timePage // m.currentTab = 1 @@ -69,18 +66,16 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // m.currentTab = 0 // } // - // tabHeight := lipgloss.Height(m.renderTabBar()) - // contentHeight := m.height - tabHeight - // if contentHeight < 0 { - // contentHeight = 0 - // } - // m.activePage.SetSize(m.width, contentHeight) - // - // // Trigger a refresh/init on switch? Maybe not needed if we keep state. - // // But we might want to refresh data. - // return m, m.activePage.Init() - // } - // } + tabHeight := lipgloss.Height(m.renderTabBar()) + contentHeight := m.height - tabHeight + if contentHeight < 0 { + contentHeight = 0 + } + m.activePage.SetSize(m.width, contentHeight) + + return m, m.activePage.Init() + } + } // activePage, cmd := m.activePage.Update(msg) m.activePage = activePage.(common.Component) diff --git a/internal/pages/tasks.go b/internal/pages/tasks.go index 56db905..993f23b 100644 --- a/internal/pages/tasks.go +++ b/internal/pages/tasks.go @@ -3,11 +3,11 @@ package pages import ( "tasksquire/internal/common" - "tasksquire/internal/components/tasktable" + "tasksquire/internal/components/table" "tasksquire/internal/taskwarrior" tea "charm.land/bubbletea/v2" - // "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2" "charm.land/bubbles/v2/key" ) @@ -22,7 +22,7 @@ type TaskPage struct { tasks taskwarrior.Tasks - taskTable tasktable.Model + taskTable table.Model // Details panel state // detailsPanelActive bool @@ -37,7 +37,7 @@ func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage { activeReport: report, activeContext: com.TW.GetActiveContext(), activeProject: "", - taskTable: tasktable.New(), + taskTable: table.New(), // detailsPanelActive: false, // detailsViewer: detailsviewer.New(com), } @@ -84,13 +84,13 @@ func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Batch(cmds...) case common.TaskMsg: p.tasks = taskwarrior.Tasks(msg) + p.populateTaskTable(p.tasks) // case UpdateReportMsg: // p.activeReport = msg // cmds = append(cmds, p.getTasks()) // case UpdateContextMsg: // p.activeContext = msg // p.common.TW.SetContext(msg) - // p.populateTaskTable(p.tasks) // cmds = append(cmds, p.getTasks()) // case UpdateProjectMsg: // p.activeProject = string(msg) @@ -227,7 +227,7 @@ func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // } // } // -// var cmd tea.Cmd + var cmd tea.Cmd // // // Route keyboard messages to details viewer when panel is active // if p.detailsPanelActive { @@ -238,15 +238,15 @@ func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // cmds = append(cmds, viewerCmd) // } else { // // Route to table when details panel not active -// p.taskTable, cmd = p.taskTable.Update(msg) -// cmds = append(cmds, cmd) -// -// if p.tasks != nil && len(p.tasks) > 0 { -// p.selectedTask = p.tasks[p.taskTable.Cursor()] -// } else { -// p.selectedTask = nil -// } -// } + p.taskTable, cmd = p.taskTable.Update(msg) + cmds = append(cmds, cmd) + + if len(p.tasks) > 0 { + p.selectedTask = p.tasks[p.taskTable.Cursor()] + } else { + p.selectedTask = nil + } + // } } return p, tea.Batch(cmds...) @@ -274,75 +274,99 @@ func (p *TaskPage) View() tea.View { // ) // } // -// func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) { -// if len(tasks) == 0 { -// return -// } -// -// // Build task tree for hierarchical display -// taskTree := taskwarrior.BuildTaskTree(tasks) -// -// // Use flattened tree list for display order -// orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList)) -// for i, node := range taskTree.FlatList { -// orderedTasks[i] = node.Task -// } -// -// selected := p.taskTable.Cursor() -// -// // Adjust cursor for tree ordering -// if p.selectedTask != nil { -// for i, task := range orderedTasks { -// if task.Uuid == p.selectedTask.Uuid { -// selected = i -// break -// } -// } -// } -// if selected > len(orderedTasks)-1 { -// selected = len(orderedTasks) - 1 -// } -// -// // Calculate proper dimensions based on whether details panel is active -// baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize() -// baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize() -// -// var tableHeight int +func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) { + if len(tasks) == 0 { + return + } + + selected := p.taskTable.Cursor() + + // Calculate proper dimensions based on whether details panel is active + baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize() + baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize() + + var tableHeight int // if p.detailsPanelActive { // // Allocate 60% for table, 40% for details panel // // Minimum 5 lines for details, minimum 10 lines for table -// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) -// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing + // detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5) + // tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing // } else { -// tableHeight = baseHeight + tableHeight = baseHeight // } // -// p.taskTable = table.New( -// p.common, -// able.WithReport(p.activeReport), -// table.WithTasks(orderedTasks), -// table.WithTaskTree(taskTree), -// table.WithFocused(true), -// table.WithWidth(baseWidth), -// table.WithHeight(tableHeight), -// table.WithStyles(p.common.Styles.TableStyle), -// ) -// -// if selected == 0 { -// selected = p.taskTable.Cursor() -// } -// if selected < len(orderedTasks) { -// p.taskTable.SetCursor(selected) -// } else { -// p.taskTable.SetCursor(len(p.tasks) - 1) -// } + + numCols := len(p.activeReport.Columns) + taskRows := make([]table.Row, len(tasks)) + taskStyles := make([]lipgloss.Style, len(tasks)) + widths := make([]int, numCols) + + for i, task := range tasks { + row := make(table.Row, numCols) + for j, colKey := range p.activeReport.Columns { + val := task.GetString(colKey) + row[j] = val + widths[j] = max(widths[j], lipgloss.Width(val)) + } + taskRows[i] = row + taskStyles[i] = common.GetTaskTabelStyle(task, *p.common) + } + + var columns []table.Column + for j, w := range widths { + title := p.activeReport.Labels[j] + + width := 0 + if w > 0 { + width = max(w, lipgloss.Width(title)) + } + + columns = append(columns, table.Column{ + Title: title, + Width: width, + }) + } + + if len(columns) > 0 { + usedWidth := 0 + for i := 0; i < len(columns)-1; i++ { + usedWidth += columns[i].Width + 1 // padding/border offset + } + + remaining := p.taskTable.Width() - usedWidth - 1 + lastIdx := len(columns) - 1 + columns[lastIdx].Width = max(columns[lastIdx].Width, remaining) + } + + p.taskTable = table.New( + table.WithColumns(columns), + table.WithRows(taskRows), + table.WithRowStyles(taskStyles), + table.WithFocused(true), + table.WithWidth(baseWidth), + table.WithHeight(tableHeight), + table.WithStyles(table.Styles{ + Header: p.common.Styles.TableStyle.Header , + Cell: p.common.Styles.TableStyle.Cell, + Selected: p.common.Styles.TableStyle.Selected, + }), + ) + + if selected == 0 { + selected = p.taskTable.Cursor() + } + if selected < len(tasks) { + p.taskTable.SetCursor(selected) + } else { + p.taskTable.SetCursor(len(p.tasks) - 1) + } // // // Refresh details content if panel is active // if p.detailsPanelActive && p.selectedTask != nil { // p.detailsViewer.SetTask(p.selectedTask) // } -// } -// +} + func (p *TaskPage) getTasks() tea.Cmd { return func() tea.Msg { filters := []string{} diff --git a/internal/components/tasktable/tasktable.go b/tasktable.go.old similarity index 100% rename from internal/components/tasktable/tasktable.go rename to tasktable.go.old