diff options
| author | Paul Bütow <1224732+snonux@users.noreply.github.com> | 2025-06-20 09:40:56 +0300 |
|---|---|---|
| committer | Paul Bütow <1224732+snonux@users.noreply.github.com> | 2025-06-20 09:40:56 +0300 |
| commit | 13f7678a9fd092ac20eec10e4d2196f5bd1ae107 (patch) | |
| tree | 512a6b9f8017f8cf0cc24158acc154034c121225 /internal | |
| parent | 13ec0a6ec615b4c7e7ddc461a7a6a623109826f9 (diff) | |
Add cell navigation, editor hotkey, stats and filter
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/atable/table.go | 70 | ||||
| -rw-r--r-- | internal/task/stats.go | 40 | ||||
| -rw-r--r-- | internal/task/stats_test.go | 28 | ||||
| -rw-r--r-- | internal/task/task.go | 8 | ||||
| -rw-r--r-- | internal/ui/table.go | 126 |
5 files changed, 251 insertions, 21 deletions
diff --git a/internal/atable/table.go b/internal/atable/table.go index 0582069..701ea6f 100644 --- a/internal/atable/table.go +++ b/internal/atable/table.go @@ -17,11 +17,12 @@ type Model struct { KeyMap KeyMap Help help.Model - cols []Column - rows []Row - cursor int - focus bool - styles Styles + cols []Column + rows []Row + cursor int + colCursor int + focus bool + styles Styles viewport viewport.Model start int @@ -48,11 +49,13 @@ type KeyMap struct { HalfPageDown key.Binding GotoTop key.Binding GotoBottom key.Binding + CellLeft key.Binding + CellRight key.Binding } // ShortHelp implements the KeyMap interface. func (km KeyMap) ShortHelp() []key.Binding { - return []key.Binding{km.LineUp, km.LineDown} + return []key.Binding{km.LineUp, km.LineDown, km.CellLeft, km.CellRight} } // FullHelp implements the KeyMap interface. @@ -60,6 +63,7 @@ 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}, + {km.CellLeft, km.CellRight}, } } @@ -99,6 +103,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end"), ), + CellLeft: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left"), + ), + CellRight: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "right"), + ), } } @@ -133,8 +145,9 @@ 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(0, 20), //nolint:mnd + cursor: 0, + colCursor: 0, + viewport: viewport.New(0, 20), //nolint:mnd KeyMap: DefaultKeyMap(), Help: help.New(), @@ -224,6 +237,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): m.GotoBottom() + case key.Matches(msg, m.KeyMap.CellLeft): + m.MoveLeft(1) + case key.Matches(msg, m.KeyMap.CellRight): + m.MoveRight(1) } } @@ -342,12 +359,23 @@ func (m Model) Cursor() int { return m.cursor } +// ColumnCursor returns the index of the selected column. +func (m Model) ColumnCursor() int { + return m.colCursor +} + // 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() } +// SetColumnCursor sets the column cursor position in the table. +func (m *Model) SetColumnCursor(n int) { + m.colCursor = clamp(n, 0, len(m.cols)-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) { @@ -380,6 +408,18 @@ func (m *Model) MoveDown(n int) { } } +// MoveLeft moves the column selection left by n columns. +func (m *Model) MoveLeft(n int) { + m.colCursor = clamp(m.colCursor-n, 0, len(m.cols)-1) + m.UpdateViewport() +} + +// MoveRight moves the column selection right by n columns. +func (m *Model) MoveRight(n int) { + m.colCursor = clamp(m.colCursor+n, 0, len(m.cols)-1) + m.UpdateViewport() +} + // GotoTop moves the selection to the first row. func (m *Model) GotoTop() { m.MoveUp(m.cursor) @@ -426,17 +466,15 @@ func (m *Model) renderRow(r int) string { continue } style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) - renderedCell := m.styles.Cell.Render(style.Render(ansi.Truncate(value, m.cols[i].Width, "…"))) + cellStyle := m.styles.Cell + if r == m.cursor && i == m.colCursor { + cellStyle = m.styles.Selected + } + renderedCell := cellStyle.Render(style.Render(ansi.Truncate(value, 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 + return lipgloss.JoinHorizontal(lipgloss.Top, s...) } func clamp(v, low, high int) int { diff --git a/internal/task/stats.go b/internal/task/stats.go new file mode 100644 index 0000000..c516249 --- /dev/null +++ b/internal/task/stats.go @@ -0,0 +1,40 @@ +package task + +import "time" + +// TotalTasks returns the number of tasks provided. +func TotalTasks(tasks []Task) int { + return len(tasks) +} + +// InProgressTasks returns the number of tasks that have been started and are not completed. +func InProgressTasks(tasks []Task) int { + count := 0 + for _, t := range tasks { + if t.Status == "completed" { + continue + } + if t.Start != "" { + count++ + } + } + return count +} + +// DueTasks returns the number of tasks with a due date that is not in the future. +func DueTasks(tasks []Task, now time.Time) int { + count := 0 + for _, t := range tasks { + if t.Status == "completed" || t.Due == "" { + continue + } + ts, err := time.Parse("20060102T150405Z", t.Due) + if err != nil { + continue + } + if !ts.After(now) { + count++ + } + } + return count +} diff --git a/internal/task/stats_test.go b/internal/task/stats_test.go new file mode 100644 index 0000000..fddbd69 --- /dev/null +++ b/internal/task/stats_test.go @@ -0,0 +1,28 @@ +package task + +import ( + "testing" + "time" +) + +func TestStats(t *testing.T) { + now := time.Now() + tasks := []Task{ + {Description: "t1"}, + {Description: "t2", Start: "20240101T000000Z"}, + {Description: "t3", Due: now.Add(-time.Hour).Format("20060102T150405Z")}, + {Description: "t4", Start: "20240101T000000Z", Due: now.Add(-time.Hour).Format("20060102T150405Z")}, + } + + if TotalTasks(tasks) != 4 { + t.Errorf("total tasks wrong: %d", TotalTasks(tasks)) + } + + if InProgressTasks(tasks) != 2 { + t.Errorf("in progress wrong: %d", InProgressTasks(tasks)) + } + + if DueTasks(tasks, now) != 2 { + t.Errorf("due tasks wrong: %d", DueTasks(tasks, now)) + } +} diff --git a/internal/task/task.go b/internal/task/task.go index 4c33fdb..f7a3f73 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -46,8 +46,12 @@ func Add(description string, tags []string) error { // Export retrieves all tasks using `task export rc.json.array=off` and parses // the JSON output into a slice of Task structs. -func Export() ([]Task, error) { - cmd := exec.Command("task", "export", "rc.json.array=off") +// Export retrieves tasks using `task <filter> export rc.json.array=off` and parses +// the JSON output into a slice of Task structs. Optional filter arguments are +// passed directly to the `task` command before `export`. +func Export(filters ...string) ([]Task, error) { + args := append(filters, "export", "rc.json.array=off") + cmd := exec.Command("task", args...) out, err := cmd.Output() if err != nil { return nil, err diff --git a/internal/ui/table.go b/internal/ui/table.go index 4300034..4293500 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,19 +1,43 @@ package ui import ( + "fmt" + "strconv" + "strings" + "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + atable "tasksamurai/internal/atable" + "tasksamurai/internal/task" ) // Model wraps a Bubble Tea table.Model to display tasks. type Model struct { tbl atable.Model showHelp bool + + filter string + tasks []task.Task + + total int + inProgress int + due int } // New creates a new UI model with the provided rows. -func New(rows []atable.Row) Model { +func New(filter string) (Model, error) { + m := Model{filter: filter} + + if err := m.reload(); err != nil { + return Model{}, err + } + + return m, nil +} + +func newTable(rows []atable.Row) atable.Model { cols := []atable.Column{ {Title: "ID", Width: 4}, {Title: "Task", Width: 30}, @@ -36,7 +60,36 @@ func New(rows []atable.Row) Model { styles.Selected = styles.Selected.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")) styles.Cell = styles.Cell.Padding(0, 1) t.SetStyles(styles) - return Model{tbl: t} + return t +} + +func (m *Model) reload() error { + tasks, err := task.Export(strings.Fields(m.filter)...) + if err != nil { + return err + } + + var rows []atable.Row + var filtered []task.Task + for _, tsk := range tasks { + if tsk.Status == "completed" { + continue + } + filtered = append(filtered, tsk) + rows = append(rows, taskToRow(tsk)) + } + + m.tasks = filtered + m.total = task.TotalTasks(filtered) + m.inProgress = task.InProgressTasks(filtered) + m.due = task.DueTasks(filtered, time.Now()) + + if m.tbl.Columns() == nil { + m.tbl = newTable(rows) + } else { + m.tbl.SetRows(rows) + } + return nil } // Init implements tea.Model. @@ -60,6 +113,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m, tea.Quit + case "E": + if row := m.tbl.SelectedRow(); row != nil { + id, err := strconv.Atoi(row[0]) + if err == nil { + _ = task.Edit(id) + m.reload() + } + } } } @@ -81,5 +142,64 @@ func (m Model) View() string { "?: help", // show help toggle line ) } - return m.tbl.View() + return lipgloss.JoinVertical(lipgloss.Left, + m.tbl.View(), + m.statusLine(), + ) +} + +func (m Model) statusLine() string { + return fmt.Sprintf("Total:%d InProgress:%d Due:%d", m.total, m.inProgress, m.due) +} + +func taskToRow(t task.Task) atable.Row { + active := "" + if t.Start != "" { + active = "yes" + } + + age := "" + if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { + days := int(time.Since(ts).Hours() / 24) + age = fmt.Sprintf("%dd", days) + } + + tags := strings.Join(t.Tags, ",") + urg := fmt.Sprintf("%.1f", t.Urgency) + + var anns []string + for _, a := range t.Annotations { + anns = append(anns, a.Description) + } + + return atable.Row{ + strconv.Itoa(t.ID), + t.Description, + active, + age, + t.Priority, + tags, + t.Recur, + formatDue(t.Due), + urg, + strings.Join(anns, "; "), + } +} + +func formatDue(s string) string { + if s == "" { + return "" + } + ts, err := time.Parse("20060102T150405Z", s) + if err != nil { + return s + } + + days := int(time.Until(ts).Hours() / 24) + val := fmt.Sprintf("%dd", days) + style := lipgloss.NewStyle() + if days < 0 { + style = style.Background(lipgloss.Color("1")) + } + return style.Render(val) } |
