summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-22 17:12:01 +0300
committerGitHub <noreply@github.com>2025-06-22 17:12:01 +0300
commit8a74c22d7a0708c79cbba40ccf85f32f568326e6 (patch)
treea7881192a026b8d4b472e5af872db40f8b3b0728
parent97ca49e85034cf6b57a6515407f8a73b440755ee (diff)
parent90f2d2efbf374f6ea82a46d2d701a099dbae1c89 (diff)
Merge pull request #83 from snonux/codex/fix-task-modification-bug-and-swap-hotkeys
Fix done marking bug and swap hotkeys
-rw-r--r--README.md7
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/task/task.go23
-rw-r--r--internal/ui/table.go139
-rw-r--r--internal/ui/table_test.go57
6 files changed, 189 insertions, 40 deletions
diff --git a/README.md b/README.md
index 05b03ca..bc8c7d6 100644
--- a/README.md
+++ b/README.md
@@ -32,9 +32,10 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are
- `e` or `E`: edit task
- `s`: toggle start/stop
-- `D`: mark task done
+- `d`: mark task done
- `U`: undo last done
-- `d`: set due date
+- `D`: set due date
+- `+`: add task
- `r`: random due date
- `R`: edit recurrence
- `a`: annotate task
@@ -55,3 +56,5 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are
- `H`: toggle help
- `q` or `esc`: close search/help or quit (press `q` when nothing is open)
+Example: press `+`, type `Buy milk` and hit Enter to add a new task called "Buy milk".
+
diff --git a/go.mod b/go.mod
index 9086482..d05118a 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
diff --git a/go.sum b/go.sum
index c89ca1f..5375c80 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
diff --git a/internal/task/task.go b/internal/task/task.go
index 603002d..b20eef0 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -12,6 +12,8 @@ import (
"strconv"
"strings"
"time"
+
+ "github.com/google/shlex"
)
// Task represents a taskwarrior task as returned by `task export`.
@@ -54,7 +56,7 @@ func SetDebugLog(path string) error {
// Add creates a new task with the given description and tags.
func Add(description string, tags []string) error {
- args := []string{"add"}
+ var args []string
for _, t := range tags {
if len(t) > 0 && t[0] != '+' {
t = "+" + t
@@ -62,11 +64,28 @@ func Add(description string, tags []string) error {
args = append(args, t)
}
args = append(args, description)
+ return AddArgs(args)
+}
- cmd := exec.Command("task", args...)
+// AddArgs runs "task add" with the provided arguments. Each element in args
+// is passed as a separate command-line argument, allowing the caller to
+// specify additional modifiers like due dates or tags.
+func AddArgs(args []string) error {
+ cmd := exec.Command("task", append([]string{"add"}, args...)...)
return cmd.Run()
}
+// AddLine splits the given line into shell words and runs "task add" with the
+// resulting arguments. This allows users to pass raw Taskwarrior parameters
+// such as "due:today" directly.
+func AddLine(line string) error {
+ fields, err := shlex.Split(line)
+ if err != nil {
+ return err
+ }
+ return AddArgs(fields)
+}
+
// Export retrieves all tasks using `task export rc.json.array=off` and parses
// the JSON output into a slice of Task structs.
// Export retrieves tasks using `task <filter> export rc.json.array=off` and parses
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 3d26f41..47ab784 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -61,6 +61,9 @@ type Model struct {
filterEditing bool
filterInput textinput.Model
+ addingTask bool
+ addInput textinput.Model
+
searching bool
searchInput textinput.Model
searchRegex *regexp.Regexp
@@ -78,10 +81,11 @@ type Model struct {
editID int
- blinkID int
- blinkRow int
- blinkOn bool
- blinkCount int
+ blinkID int
+ blinkRow int
+ blinkOn bool
+ blinkCount int
+ blinkMarkDone bool
cellExpanded bool
@@ -124,8 +128,9 @@ func blinkCmd() tea.Cmd {
return tea.Tick(blinkInterval, func(time.Time) tea.Msg { return blinkMsg{} })
}
-func (m *Model) startBlink(id int) tea.Cmd {
+func (m *Model) startBlink(id int, markDone bool) tea.Cmd {
m.blinkID = id
+ m.blinkMarkDone = markDone
m.blinkRow = -1
for i, tsk := range m.tasks {
if tsk.ID == id {
@@ -159,6 +164,9 @@ func New(filters []string) (Model, error) {
m.filterInput = textinput.New()
m.filterInput.Prompt = "filter: "
+ m.addInput = textinput.New()
+ m.addInput.Prompt = "add: "
+
m.defaultTheme = DefaultTheme()
m.theme = m.defaultTheme
@@ -264,7 +272,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Ignore any error and reload tasks once editing completes.
_ = msg.err
m.reload()
- cmd := m.startBlink(m.editID)
+ cmd := m.startBlink(m.editID, false)
m.editID = 0
return m, cmd
case blinkMsg:
@@ -274,16 +282,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateBlinkRow()
if m.blinkCount >= blinkCycles {
id := m.blinkID
+ mark := m.blinkMarkDone
m.blinkID = 0
m.blinkOn = false
m.blinkCount = 0
- for _, tsk := range m.tasks {
- if tsk.ID == id {
- m.undoStack = append(m.undoStack, tsk.UUID)
- break
+ m.blinkMarkDone = false
+ if mark {
+ for _, tsk := range m.tasks {
+ if tsk.ID == id {
+ m.undoStack = append(m.undoStack, tsk.UUID)
+ break
+ }
}
+ task.Done(id)
}
- task.Done(id)
m.reload()
return m, nil
}
@@ -303,7 +315,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.annotating = false
m.annotateInput.Blur()
m.reload()
- cmd := m.startBlink(m.annotateID)
+ cmd := m.startBlink(m.annotateID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -324,7 +336,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.descEditing = false
m.descInput.Blur()
m.reload()
- cmd := m.startBlink(m.descID)
+ cmd := m.startBlink(m.descID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -365,7 +377,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tagsEditing = false
m.tagsInput.Blur()
m.reload()
- cmd := m.startBlink(m.tagsID)
+ cmd := m.startBlink(m.tagsID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -384,7 +396,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02"))
m.dueEditing = false
m.reload()
- cmd := m.startBlink(m.dueID)
+ cmd := m.startBlink(m.dueID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -411,7 +423,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.recurEditing = false
m.recurInput.Blur()
m.reload()
- cmd := m.startBlink(m.recurID)
+ cmd := m.startBlink(m.recurID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -430,7 +442,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
task.SetPriority(m.priorityID, priorityOptions[m.priorityIndex])
m.prioritySelecting = false
m.reload()
- cmd := m.startBlink(m.priorityID)
+ cmd := m.startBlink(m.priorityID, false)
m.updateTableHeight()
return m, cmd
case tea.KeyEsc:
@@ -465,6 +477,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filterInput, cmd = m.filterInput.Update(msg)
return m, cmd
}
+ if m.addingTask {
+ switch msg.Type {
+ case tea.KeyEnter:
+ oldIDs := make(map[int]struct{})
+ for _, tsk := range m.tasks {
+ oldIDs[tsk.ID] = struct{}{}
+ }
+ task.AddLine(m.addInput.Value())
+ m.addingTask = false
+ m.addInput.Blur()
+ m.reload()
+ var newID int
+ row := -1
+ for i, tsk := range m.tasks {
+ if _, ok := oldIDs[tsk.ID]; !ok {
+ newID = tsk.ID
+ row = i
+ break
+ }
+ }
+ m.updateTableHeight()
+ if row >= 0 {
+ prevRow := m.tbl.Cursor()
+ prevCol := m.tbl.ColumnCursor()
+ m.tbl.SetCursor(row)
+ m.tbl.SetColumnCursor(7)
+ m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor())
+ return m, m.startBlink(newID, false)
+ }
+ return m, nil
+ case tea.KeyEsc:
+ m.addingTask = false
+ m.addInput.Blur()
+ m.updateTableHeight()
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.addInput, cmd = m.addInput.Update(msg)
+ return m, cmd
+ }
if m.searching {
switch msg.Type {
case tea.KeyEnter:
@@ -547,20 +599,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
task.Start(id)
}
m.reload()
- cmd := m.startBlink(id)
+ cmd := m.startBlink(id, false)
return m, cmd
}
}
- case "D":
+ case "d":
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
- m.blinkID = id
- m.blinkRow = m.tbl.Cursor()
- m.blinkOn = true
- m.blinkCount = 0
- m.updateBlinkRow()
- return m, blinkCmd()
+ return m, m.startBlink(id, true)
}
}
case "U":
@@ -576,10 +623,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
break
}
}
- cmd := m.startBlink(id)
+ cmd := m.startBlink(id, false)
return m, cmd
}
- case "d":
+ case "D":
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
@@ -598,7 +645,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
due := time.Now().AddDate(0, 0, days).Format("2006-01-02")
task.SetDueDate(id, due)
m.reload()
- cmd := m.startBlink(id)
+ cmd := m.startBlink(id, false)
return m, cmd
}
}
@@ -657,6 +704,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filterInput.Focus()
m.updateTableHeight()
return m, nil
+ case "+":
+ m.addingTask = true
+ m.addInput.SetValue("")
+ m.addInput.Focus()
+ m.updateTableHeight()
+ return m, nil
case "t":
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
@@ -796,14 +849,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// View renders the table UI.
func (m Model) View() string {
if m.showHelp {
- return lipgloss.JoinVertical(lipgloss.Left,
+ lines := []string{
m.tbl.HelpView(),
"enter/i: edit or expand cell",
"E: edit task",
+ "+: add task",
"s: toggle start/stop",
- "D: mark task done",
+ "d: mark task done",
"U: undo done",
- "d: set due date",
+ "D: set due date",
"r: random due date",
"R: edit recurrence",
"a: annotate task",
@@ -818,7 +872,11 @@ func (m Model) View() string {
"esc: close help/search",
"q: quit",
"H: help", // show help toggle line
- )
+ }
+ for i, l := range lines {
+ lines[i] = centerLines(l, m.tbl.Width())
+ }
+ return lipgloss.JoinVertical(lipgloss.Top, lines...)
}
view := lipgloss.JoinVertical(lipgloss.Left,
m.topStatusLine(),
@@ -874,6 +932,12 @@ func (m Model) View() string {
m.filterInput.View(),
)
}
+ if m.addingTask {
+ view = lipgloss.JoinVertical(lipgloss.Left,
+ view,
+ m.addInput.View(),
+ )
+ }
if m.searching {
view = lipgloss.JoinVertical(lipgloss.Left,
view,
@@ -1187,7 +1251,7 @@ func (m *Model) updateTableHeight() {
if m.cellExpanded {
h--
}
- if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing {
+ if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing || m.addingTask {
h--
}
if h < 1 {
@@ -1303,3 +1367,12 @@ func (m *Model) applyTheme() {
m.tblStyles.Highlight = m.tblStyles.Highlight.Background(lipgloss.Color(m.theme.RowBG)).Foreground(lipgloss.Color(m.theme.RowFG))
m.tbl.SetStyles(m.tblStyles)
}
+
+func centerLines(s string, width int) string {
+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
+ style := lipgloss.NewStyle().Width(width).Align(lipgloss.Center)
+ for i, l := range lines {
+ lines[i] = style.Render(l)
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index 4e2679d..365180c 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -162,7 +162,7 @@ func TestDoneHotkey(t *testing.T) {
t.Fatalf("New: %v", err)
}
- mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
m = mv.(Model)
for i := 0; i < blinkCycles; i++ {
mv, _ = m.Update(blinkMsg{})
@@ -211,7 +211,7 @@ func TestUndoHotkey(t *testing.T) {
t.Fatalf("New: %v", err)
}
- mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
m = mv.(Model)
for i := 0; i < blinkCycles; i++ {
mv, _ = m.Update(blinkMsg{})
@@ -269,7 +269,7 @@ func TestDueDateHotkey(t *testing.T) {
t.Fatalf("New: %v", err)
}
- mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
m = mv.(Model)
for i := 0; i < 3; i++ {
mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyRight})
@@ -442,6 +442,57 @@ func TestPriorityHotkey(t *testing.T) {
}
}
+func TestAddHotkey(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ addFile := filepath.Join(tmp, "add.txt")
+
+ script := "#!/bin/sh\n" +
+ "if echo \"$@\" | grep -q export; then\n" +
+ " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" +
+ " exit 0\n" +
+ "fi\n" +
+ "echo \"$@\" > " + addFile + "\n"
+
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ os.Setenv("TASKDATA", tmp)
+ os.Setenv("TASKRC", "/dev/null")
+ t.Cleanup(func() {
+ os.Unsetenv("TASKDATA")
+ os.Unsetenv("TASKRC")
+ })
+
+ m, err := New(nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}})
+ m = mv.(Model)
+ for _, r := range "foo due:today" {
+ mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ m = mv.(Model)
+ }
+ mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ m = mv.(Model)
+
+ data, err := os.ReadFile(addFile)
+ if err != nil {
+ t.Fatalf("read add: %v", err)
+ }
+
+ if strings.TrimSpace(string(data)) != "add foo due:today" {
+ t.Fatalf("add not called: %q", data)
+ }
+}
+
func TestNavigationHotkeys(t *testing.T) {
tmp := t.TempDir()
taskPath := filepath.Join(tmp, "task")