summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-20 09:40:56 +0300
committerPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-20 09:40:56 +0300
commit13f7678a9fd092ac20eec10e4d2196f5bd1ae107 (patch)
tree512a6b9f8017f8cf0cc24158acc154034c121225
parent13ec0a6ec615b4c7e7ddc461a7a6a623109826f9 (diff)
Add cell navigation, editor hotkey, stats and filter
-rw-r--r--cmd/tasksamurai/main.go77
-rw-r--r--internal/atable/table.go70
-rw-r--r--internal/task/stats.go40
-rw-r--r--internal/task/stats_test.go28
-rw-r--r--internal/task/task.go8
-rw-r--r--internal/ui/table.go126
6 files changed, 257 insertions, 92 deletions
diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go
index 4420e11..9b97554 100644
--- a/cmd/tasksamurai/main.go
+++ b/cmd/tasksamurai/main.go
@@ -1,93 +1,28 @@
package main
import (
+ "flag"
"fmt"
"os"
- "strconv"
- "strings"
- "time"
- "github.com/charmbracelet/lipgloss"
-
- "tasksamurai/internal/task"
"tasksamurai/internal/ui"
tea "github.com/charmbracelet/bubbletea"
- atable "tasksamurai/internal/atable"
)
func main() {
- tasks, err := task.Export()
+ filter := flag.String("filter", "", "task filter expression")
+ flag.Parse()
+
+ m, err := ui.New(*filter)
if err != nil {
- fmt.Fprintln(os.Stderr, "failed to export tasks:", err)
+ fmt.Fprintln(os.Stderr, "failed to load tasks:", err)
os.Exit(1)
}
- var rows []atable.Row
- for _, t := range tasks {
- if t.Status == "completed" {
- continue
- }
- rows = append(rows, taskToRow(t))
- }
-
- m := ui.New(rows)
-
p := tea.NewProgram(&m)
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "error running ui:", err)
os.Exit(1)
}
}
-
-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)
-}
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)
}