mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
243 lines
6.9 KiB
Go
243 lines
6.9 KiB
Go
package component
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"github.com/boolean-maybe/tiki/config"
|
|
"github.com/boolean-maybe/tiki/task"
|
|
"github.com/boolean-maybe/tiki/util"
|
|
"github.com/boolean-maybe/tiki/util/gradient"
|
|
)
|
|
|
|
// TaskList displays tasks in a compact tabular format with three columns:
|
|
// status indicator, tiki ID (gradient-rendered), and title.
|
|
// It supports configurable visible row count, scrolling, and row selection.
|
|
type TaskList struct {
|
|
*tview.Box
|
|
tasks []*task.Task
|
|
maxVisibleRows int
|
|
scrollOffset int
|
|
selectionIndex int
|
|
idColumnWidth int // computed from widest ID
|
|
idGradient config.Gradient // gradient for ID text
|
|
idFallback config.Color // fallback solid color for ID
|
|
titleColor config.Color // color for title text
|
|
selectionColor config.Color // foreground color for selected row highlight
|
|
selectionBgColor config.Color // background color for selected row highlight
|
|
statusDoneColor config.Color // color for done status indicator
|
|
statusPendingColor config.Color // color for pending status indicator
|
|
}
|
|
|
|
// NewTaskList creates a new TaskList with the given maximum visible row count.
|
|
func NewTaskList(maxVisibleRows int) *TaskList {
|
|
colors := config.GetColors()
|
|
return &TaskList{
|
|
Box: tview.NewBox(),
|
|
maxVisibleRows: maxVisibleRows,
|
|
idGradient: colors.TaskBoxIDColor,
|
|
idFallback: colors.FallbackTaskIDColor,
|
|
titleColor: colors.TaskBoxTitleColor,
|
|
selectionColor: colors.TaskListSelectionFg,
|
|
selectionBgColor: colors.TaskListSelectionBg,
|
|
statusDoneColor: colors.TaskListStatusDoneColor,
|
|
statusPendingColor: colors.TaskListStatusPendingColor,
|
|
}
|
|
}
|
|
|
|
// SetTasks replaces the task data, recomputes the ID column width, and clamps scroll/selection.
|
|
func (tl *TaskList) SetTasks(tasks []*task.Task) *TaskList {
|
|
tl.tasks = tasks
|
|
tl.recomputeIDColumnWidth()
|
|
tl.clampSelection()
|
|
tl.clampScroll()
|
|
return tl
|
|
}
|
|
|
|
// SetSelection sets the selected row index, clamped to valid bounds.
|
|
func (tl *TaskList) SetSelection(index int) *TaskList {
|
|
tl.selectionIndex = index
|
|
tl.clampSelection()
|
|
tl.ensureSelectionVisible()
|
|
return tl
|
|
}
|
|
|
|
// GetSelectedIndex returns the current selection index.
|
|
func (tl *TaskList) GetSelectedIndex() int {
|
|
return tl.selectionIndex
|
|
}
|
|
|
|
// GetSelectedTask returns the currently selected task, or nil if none.
|
|
func (tl *TaskList) GetSelectedTask() *task.Task {
|
|
if tl.selectionIndex < 0 || tl.selectionIndex >= len(tl.tasks) {
|
|
return nil
|
|
}
|
|
return tl.tasks[tl.selectionIndex]
|
|
}
|
|
|
|
// ScrollUp moves the selection up by one row.
|
|
func (tl *TaskList) ScrollUp() {
|
|
if tl.selectionIndex > 0 {
|
|
tl.selectionIndex--
|
|
tl.ensureSelectionVisible()
|
|
}
|
|
}
|
|
|
|
// ScrollDown moves the selection down by one row.
|
|
func (tl *TaskList) ScrollDown() {
|
|
if tl.selectionIndex < len(tl.tasks)-1 {
|
|
tl.selectionIndex++
|
|
tl.ensureSelectionVisible()
|
|
}
|
|
}
|
|
|
|
// SetIDColors overrides the gradient and fallback color for the ID column.
|
|
func (tl *TaskList) SetIDColors(g config.Gradient, fallback config.Color) *TaskList {
|
|
tl.idGradient = g
|
|
tl.idFallback = fallback
|
|
return tl
|
|
}
|
|
|
|
// SetTitleColor overrides the color for the title column.
|
|
func (tl *TaskList) SetTitleColor(color config.Color) *TaskList {
|
|
tl.titleColor = color
|
|
return tl
|
|
}
|
|
|
|
// Draw renders the TaskList onto the screen.
|
|
func (tl *TaskList) Draw(screen tcell.Screen) {
|
|
tl.DrawForSubclass(screen, tl)
|
|
|
|
x, y, width, height := tl.GetInnerRect()
|
|
if width <= 0 || height <= 0 || len(tl.tasks) == 0 {
|
|
return
|
|
}
|
|
|
|
tl.ensureSelectionVisible()
|
|
|
|
visibleRows := tl.visibleRowCount(height)
|
|
|
|
for i := range visibleRows {
|
|
itemIndex := tl.scrollOffset + i
|
|
if itemIndex >= len(tl.tasks) {
|
|
break
|
|
}
|
|
|
|
t := tl.tasks[itemIndex]
|
|
row := tl.buildRow(t, itemIndex == tl.selectionIndex, width)
|
|
tview.Print(screen, row, x, y+i, width, tview.AlignLeft, tcell.ColorDefault)
|
|
}
|
|
}
|
|
|
|
// buildRow constructs the tview-tagged string for a single row.
|
|
func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
|
|
// Status indicator: done = checkmark, else circle
|
|
var statusIndicator string
|
|
if config.GetStatusRegistry().IsDone(string(t.Status)) {
|
|
statusIndicator = tl.statusDoneColor.Tag().String() + "\u2713[-]"
|
|
} else {
|
|
statusIndicator = tl.statusPendingColor.Tag().String() + "\u25CB[-]"
|
|
}
|
|
|
|
// Gradient-rendered ID, padded to idColumnWidth
|
|
idText := gradient.RenderAdaptiveGradientText(t.ID, tl.idGradient, tl.idFallback)
|
|
// Pad with spaces if ID is shorter than column width
|
|
if padding := tl.idColumnWidth - len(t.ID); padding > 0 {
|
|
idText += fmt.Sprintf("%*s", padding, "")
|
|
}
|
|
|
|
// Title: fill remaining width, truncated
|
|
// Layout: "X IDID Title" => status(1) + space(1) + id(idColumnWidth) + space(1) + title
|
|
titleAvailable := max(width-1-1-tl.idColumnWidth-1, 0)
|
|
truncatedTitle := tview.Escape(util.TruncateText(t.Title, titleAvailable))
|
|
|
|
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor.Tag().String(), truncatedTitle)
|
|
|
|
if selected {
|
|
row = tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String() + row
|
|
}
|
|
|
|
return row
|
|
}
|
|
|
|
// ensureSelectionVisible adjusts scrollOffset so the selected row is within the viewport.
|
|
func (tl *TaskList) ensureSelectionVisible() {
|
|
if len(tl.tasks) == 0 {
|
|
return
|
|
}
|
|
|
|
_, _, _, height := tl.GetInnerRect()
|
|
maxVisible := tl.visibleRowCount(height)
|
|
if maxVisible <= 0 {
|
|
return
|
|
}
|
|
|
|
// Selection above viewport
|
|
if tl.selectionIndex < tl.scrollOffset {
|
|
tl.scrollOffset = tl.selectionIndex
|
|
}
|
|
|
|
// Selection below viewport
|
|
lastVisible := tl.scrollOffset + maxVisible - 1
|
|
if tl.selectionIndex > lastVisible {
|
|
tl.scrollOffset = tl.selectionIndex - maxVisible + 1
|
|
}
|
|
|
|
tl.clampScroll()
|
|
}
|
|
|
|
// visibleRowCount returns the number of rows that can be displayed.
|
|
func (tl *TaskList) visibleRowCount(height int) int {
|
|
maxVisible := height
|
|
if tl.maxVisibleRows > 0 && maxVisible > tl.maxVisibleRows {
|
|
maxVisible = tl.maxVisibleRows
|
|
}
|
|
if maxVisible > len(tl.tasks) {
|
|
maxVisible = len(tl.tasks)
|
|
}
|
|
return maxVisible
|
|
}
|
|
|
|
// recomputeIDColumnWidth calculates the width needed for the widest task ID.
|
|
func (tl *TaskList) recomputeIDColumnWidth() {
|
|
tl.idColumnWidth = 0
|
|
for _, t := range tl.tasks {
|
|
if len(t.ID) > tl.idColumnWidth {
|
|
tl.idColumnWidth = len(t.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// clampSelection ensures selectionIndex is within [0, len(tasks)-1].
|
|
func (tl *TaskList) clampSelection() {
|
|
if len(tl.tasks) == 0 {
|
|
tl.selectionIndex = 0
|
|
return
|
|
}
|
|
if tl.selectionIndex < 0 {
|
|
tl.selectionIndex = 0
|
|
}
|
|
if tl.selectionIndex >= len(tl.tasks) {
|
|
tl.selectionIndex = len(tl.tasks) - 1
|
|
}
|
|
}
|
|
|
|
// clampScroll ensures scrollOffset stays within valid bounds.
|
|
func (tl *TaskList) clampScroll() {
|
|
if tl.scrollOffset < 0 {
|
|
tl.scrollOffset = 0
|
|
}
|
|
|
|
_, _, _, height := tl.GetInnerRect()
|
|
maxVisible := tl.visibleRowCount(height)
|
|
if maxVisible <= 0 {
|
|
return
|
|
}
|
|
|
|
maxOffset := max(len(tl.tasks)-maxVisible, 0)
|
|
if tl.scrollOffset > maxOffset {
|
|
tl.scrollOffset = maxOffset
|
|
}
|
|
}
|