From 9eda92503ef89e5511d087fbdd38630098477c78 Mon Sep 17 00:00:00 2001 From: Martin Pander Date: Thu, 26 Feb 2026 20:03:48 +0100 Subject: [PATCH] Use native bubble table --- .gitignore | 1 + internal/common/messages.go | 18 + internal/components/tasktable/tasktable.go | 445 +++++++++++++++++++++ internal/pages/main.go | 12 +- internal/pages/tasks.go | 355 ++++++++++++++++ internal/taskwarrior/config.go | 2 +- 6 files changed, 826 insertions(+), 7 deletions(-) create mode 100644 internal/common/messages.go create mode 100644 internal/components/tasktable/tasktable.go create mode 100644 internal/pages/tasks.go diff --git a/.gitignore b/.gitignore index 8ce9bfc..173babd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ test/taskchampion.sqlite3 tasksquire test/*.sqlite3* result +main diff --git a/internal/common/messages.go b/internal/common/messages.go new file mode 100644 index 0000000..e256ef6 --- /dev/null +++ b/internal/common/messages.go @@ -0,0 +1,18 @@ +package common + +import ( + "tasksquire/internal/taskwarrior" + "time" + + tea "charm.land/bubbletea/v2" +) + +type TaskMsg taskwarrior.Tasks + +type TickMsg time.Time + +func DoTick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} diff --git a/internal/components/tasktable/tasktable.go b/internal/components/tasktable/tasktable.go new file mode 100644 index 0000000..6eac19b --- /dev/null +++ b/internal/components/tasktable/tasktable.go @@ -0,0 +1,445 @@ +package tasktable + +import ( + taskw "tasksquire/internal/taskwarrior" + + "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 + cursor int + focus bool + styles Styles + + viewport viewport.Model + start int + end int +} + +// Row represents one line in the table. +type Row struct { + task taskw.Task + style lipgloss.Style +} + +// Column defines the table structure. +type Column struct { + Title string + Name 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 + } +} + +// 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 Row{} + } + + 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)) +} + +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, col := range m.cols { + if m.cols[i].Width <= 0 { + continue + } + cellStyle := m.rows[r].style + if r == m.cursor { + cellStyle = cellStyle.Inherit(m.styles.Selected) + } + + style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) + renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…"))) + s = append(s, renderedCell) + } + + row := lipgloss.JoinHorizontal(lipgloss.Top, s...) + + if r == m.cursor { + return m.styles.Selected.Render(row) + } + + return row +} + +func clamp(v, low, high int) int { + return min(max(v, low), high) +} diff --git a/internal/pages/main.go b/internal/pages/main.go index 9ebd9f5..6dbba2a 100644 --- a/internal/pages/main.go +++ b/internal/pages/main.go @@ -24,10 +24,10 @@ func NewMainPage(common *common.Common) *MainPage { common: common, } - // m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) + m.taskPage = NewTaskPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default"))) // m.timePage = NewTimePage(common) // - // m.activePage = m.taskPage + m.activePage = m.taskPage m.currentTab = 0 return m @@ -82,9 +82,9 @@ func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // } // } // - // activePage, cmd := m.activePage.Update(msg) - // m.activePage = activePage.(common.Component) - // + activePage, cmd := m.activePage.Update(msg) + m.activePage = activePage.(common.Component) + return m, cmd } @@ -105,7 +105,7 @@ func (m *MainPage) renderTabBar() string { } func (m *MainPage) View() tea.View { - v := tea.NewView("test") + v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View().Content)) v.AltScreen = true return v // return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View()) diff --git a/internal/pages/tasks.go b/internal/pages/tasks.go new file mode 100644 index 0000000..56db905 --- /dev/null +++ b/internal/pages/tasks.go @@ -0,0 +1,355 @@ +// TODO: update table every second (to show correct relative time) +package pages + +import ( + "tasksquire/internal/common" + "tasksquire/internal/components/tasktable" + "tasksquire/internal/taskwarrior" + + tea "charm.land/bubbletea/v2" + // "charm.land/lipgloss/v2" + "charm.land/bubbles/v2/key" +) + +type TaskPage struct { + common *common.Common + + activeReport *taskwarrior.Report + activeContext *taskwarrior.Context + activeProject string + selectedTask *taskwarrior.Task + taskCursor int + + tasks taskwarrior.Tasks + + taskTable tasktable.Model + + // Details panel state + // detailsPanelActive bool + // detailsViewer *detailsviewer.DetailsViewer + + subpage common.Component +} + +func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage { + p := &TaskPage{ + common: com, + activeReport: report, + activeContext: com.TW.GetActiveContext(), + activeProject: "", + taskTable: tasktable.New(), + // detailsPanelActive: false, + // detailsViewer: detailsviewer.New(com), + } + + return p +} + +func (p *TaskPage) SetSize(width int, height int) { + p.common.SetSize(width, height) + + baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize() + baseWidth := 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 + // + // // Set component size (component handles its own border/padding) + // // p.detailsViewer.SetSize(baseWidth, detailsHeight) + // } else { + tableHeight = baseHeight + // } + + p.taskTable.SetWidth(baseWidth) + p.taskTable.SetHeight(tableHeight) +} + +func (p *TaskPage) Init() tea.Cmd { + return tea.Batch(p.getTasks(), common.DoTick()) +} + +func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.SetSize(msg.Width, msg.Height) + // case BackMsg: + case common.TickMsg: + cmds = append(cmds, p.getTasks()) + cmds = append(cmds, common.DoTick()) + return p, tea.Batch(cmds...) + case common.TaskMsg: + p.tasks = taskwarrior.Tasks(msg) + // 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) + // cmds = append(cmds, p.getTasks()) + // case TaskPickedMsg: + // if msg.Task != nil && msg.Task.Status == "pending" { + // p.common.TW.StopActiveTasks() + // p.common.TW.StartTask(msg.Task) + // } + // cmds = append(cmds, p.getTasks()) + // case UpdatedTasksMsg: + // cmds = append(cmds, p.getTasks()) + case tea.KeyPressMsg: + // Handle ESC when details panel is active + // if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) { + // p.detailsPanelActive = false + // p.detailsViewer.Blur() + // p.SetSize(p.common.Width(), p.common.Height()) + // return p, nil + // } + + switch { + case key.Matches(msg, p.common.Keymap.Quit): + return p, tea.Quit + } +// case key.Matches(msg, p.common.Keymap.SetReport): +// p.subpage = NewReportPickerPage(p.common, p.activeReport) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.SetContext): +// p.subpage = NewContextPickerPage(p.common) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.Add): +// p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask()) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.Edit): +// p.subpage = NewTaskEditorPage(p.common, *p.selectedTask) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.Subtask): +// if p.selectedTask != nil { +// // Create new task inheriting parent's attributes +// newTask := taskwarrior.NewTask() +// +// // Set parent relationship +// newTask.Parent = p.selectedTask.Uuid +// +// // Copy parent's attributes +// newTask.Project = p.selectedTask.Project +// newTask.Priority = p.selectedTask.Priority +// newTask.Tags = make([]string, len(p.selectedTask.Tags)) +// copy(newTask.Tags, p.selectedTask.Tags) +// +// // Copy UDAs (except "details" which is task-specific) +// if p.selectedTask.Udas != nil { +// newTask.Udas = make(map[string]any) +// for k, v := range p.selectedTask.Udas { +// // Skip "details" UDA - it's specific to parent task +// if k == "details" { +// continue +// } +// // Deep copy other UDA values +// newTask.Udas[k] = v +// } +// } +// +// // Open task editor with pre-populated task +// p.subpage = NewTaskEditorPage(p.common, newTask) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// } +// return p, nil +// case key.Matches(msg, p.common.Keymap.Ok): +// p.common.TW.SetTaskDone(p.selectedTask) +// return p, p.getTasks() +// case key.Matches(msg, p.common.Keymap.Delete): +// p.common.TW.DeleteTask(p.selectedTask) +// return p, p.getTasks() +// case key.Matches(msg, p.common.Keymap.SetProject): +// p.subpage = NewProjectPickerPage(p.common, p.activeProject) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.PickProjectTask): +// p.subpage = NewProjectTaskPickerPage(p.common) +// cmd := p.subpage.Init() +// p.common.PushPage(p) +// return p.subpage, cmd +// case key.Matches(msg, p.common.Keymap.Tag): +// if p.selectedTask != nil { +// tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default") +// if p.selectedTask.HasTag(tag) { +// p.selectedTask.RemoveTag(tag) +// } else { +// p.selectedTask.AddTag(tag) +// } +// p.common.TW.ImportTask(p.selectedTask) +// return p, p.getTasks() +// } +// return p, nil +// case key.Matches(msg, p.common.Keymap.Undo): +// p.common.TW.Undo() +// return p, p.getTasks() +// case key.Matches(msg, p.common.Keymap.StartStop): +// if p.selectedTask != nil && p.selectedTask.Status == "pending" { +// if p.selectedTask.Start == "" { +// p.common.TW.StopActiveTasks() +// p.common.TW.StartTask(p.selectedTask) +// } else { +// p.common.TW.StopTask(p.selectedTask) +// } +// return p, p.getTasks() +// } +// case key.Matches(msg, p.common.Keymap.ViewDetails): +// if p.selectedTask != nil { +// // Toggle details panel +// p.detailsPanelActive = !p.detailsPanelActive +// if p.detailsPanelActive { +// p.detailsViewer.SetTask(p.selectedTask) +// p.detailsViewer.Focus() +// } else { +// p.detailsViewer.Blur() +// } +// p.SetSize(p.common.Width(), p.common.Height()) +// return p, nil +// } +// } +// } +// +// var cmd tea.Cmd +// +// // Route keyboard messages to details viewer when panel is active +// if p.detailsPanelActive { +// var viewerCmd tea.Cmd +// var viewerModel tea.Model +// viewerModel, viewerCmd = p.detailsViewer.Update(msg) +// p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer) +// 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 +// } +// } + } + + return p, tea.Batch(cmds...) +} + +func (p *TaskPage) View() tea.View { + if len(p.tasks) == 0 { + return tea.NewView(p.common.Styles.Base.Render("No tasks found")) + } + + tableView := p.taskTable.View() + + return tea.NewView(tableView) +} +// +// if !p.detailsPanelActive { +// return tableView +// } +// +// // Combine table and details panel vertically +// return lipgloss.JoinVertical( +// lipgloss.Left, +// tableView, +// p.detailsViewer.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 +// 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 +// } else { +// 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) +// } +// +// // 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{} + if p.activeProject != "" { + filters = append(filters, "project:"+p.activeProject) + } + tasks := p.common.TW.GetTasks(p.activeReport, filters...) + return common.TaskMsg(tasks) + } +} diff --git a/internal/taskwarrior/config.go b/internal/taskwarrior/config.go index c77c3ac..94cdba0 100644 --- a/internal/taskwarrior/config.go +++ b/internal/taskwarrior/config.go @@ -39,7 +39,7 @@ func (tc *TWConfig) GetConfig() map[string]string { func (tc *TWConfig) Get(key string) string { 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 Taskwarrior config: %s", key)) return "" }