From b77839545023cb39ab2f9d2a2717c641aeb3a588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 19:56:25 +0300 Subject: Fix extra spacing in highlighted table rows --- internal/atable/table.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/atable/table.go b/internal/atable/table.go index 3e6cc93..528276f 100644 --- a/internal/atable/table.go +++ b/internal/atable/table.go @@ -530,7 +530,7 @@ func addSpacingStyled(cells []string, style lipgloss.Style) []string { spaced := make([]string, 0, len(cells)*2-1) for i, cell := range cells { if i > 0 { - spaced = append(spaced, style.Render(" ")) + spaced = append(spaced, style.Copy().Padding(0, 0).Render(" ")) } spaced = append(spaced, cell) } -- cgit v1.2.3 From 8046421e7c47b8a7cc8775589ce150e05f3a2cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:02:42 +0300 Subject: Move column header to cell view --- internal/ui/table.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index dd862a5..fd9ee8f 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -549,12 +549,7 @@ func (m Model) statusLine() string { } func (m Model) topStatusLine() string { - header := "" - cols := m.tbl.Columns() - if idx := m.tbl.ColumnCursor(); idx >= 0 && idx < len(cols) { - header = cols[idx].Title - } - line := fmt.Sprintf("Task Samurai %s | %s", internal.Version, header) + line := fmt.Sprintf("Task Samurai %s", internal.Version) return lipgloss.NewStyle(). Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). @@ -767,6 +762,14 @@ func (m Model) expandedCellView() string { } val = strings.Join(anns, "; ") } + header := "" + cols := m.tbl.Columns() + if col >= 0 && col < len(cols) { + header = cols[col].Title + } + if header != "" { + val = header + ": " + val + } style := lipgloss.NewStyle().Width(m.tbl.Width()) return style.Render(val) } -- cgit v1.2.3 From b79d58622fce06daf2be6438f6e5bcc35630f46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:10:42 +0300 Subject: Swap annotation and description columns --- internal/ui/table.go | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index fd9ee8f..02582f0 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -115,8 +115,8 @@ func newTable(rows []atable.Row) (atable.Model, atable.Styles) { {Title: "Urg", Width: urgWidth}, {Title: "Due", Width: dueWidth}, {Title: "Tags", Width: tagsWidth}, - {Title: "Description", Width: descWidth}, {Title: "Annotations", Width: annWidth}, + {Title: "Description", Width: descWidth}, } t := atable.New( atable.WithColumns(cols), @@ -153,11 +153,11 @@ func (m *Model) reload() error { m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 5}) } if m.searchRegex.MatchString(tsk.Description) { - m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 6}) + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 7}) } for _, a := range tsk.Annotations { if m.searchRegex.MatchString(a.Description) { - m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 7}) + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 6}) break } } @@ -577,6 +577,11 @@ func taskToRow(t task.Task) atable.Row { anns = append(anns, a.Description) } + annStr := "" + if n := len(anns); n > 0 { + annStr = strconv.Itoa(n) + } + return atable.Row{ style.Render(strconv.Itoa(t.ID)), formatPriority(t.Priority, priWidth), @@ -584,8 +589,8 @@ func taskToRow(t task.Task) atable.Row { style.Render(urg), formatDue(t.Due, dueWidth), style.Render(tags), + style.Render(annStr), style.Render(t.Description), - style.Render(strings.Join(anns, "; ")), } } @@ -676,6 +681,14 @@ func highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) string { return b.String() } +func highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display string) string { + if re != nil && re.MatchString(raw) { + highlight := lipgloss.NewStyle().Background(lipgloss.Color("226")).Foreground(lipgloss.Color("21")) + return highlight.Copy().Inherit(base).Render(display) + } + return base.Render(display) +} + func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selectedCol int) atable.Row { rowStyle := lipgloss.NewStyle() if t.Start != "" { @@ -713,9 +726,13 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec urgStr := getStyle(3).Render(urg) tagStr := highlightCell(getStyle(5), re, tags) - descStr := highlightCell(getStyle(6), re, t.Description) annRaw := strings.Join(anns, "; ") - annStr := highlightCell(getStyle(7), re, annRaw) + annCount := "" + if n := len(anns); n > 0 { + annCount = strconv.Itoa(n) + } + annStr := highlightCellMatch(getStyle(6), re, annRaw, annCount) + descStr := highlightCell(getStyle(7), re, t.Description) return atable.Row{ idStr, @@ -724,8 +741,8 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec urgStr, dueStr, tagStr, - descStr, annStr, + descStr, } } @@ -754,13 +771,13 @@ func (m Model) expandedCellView() string { case 5: val = strings.Join(t.Tags, " ") case 6: - val = t.Description - case 7: var anns []string for _, a := range t.Annotations { anns = append(anns, a.Description) } val = strings.Join(anns, "; ") + case 7: + val = t.Description } header := "" cols := m.tbl.Columns() -- cgit v1.2.3 From d1228ab2c5af89a35929ce313c39db9eb076f3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:15:48 +0300 Subject: Tighten annotation and priority column widths --- internal/ui/table.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index 02582f0..a78eeb9 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -23,13 +23,13 @@ var priorityOptions = []string{"H", "M", "L", ""} const ( idWidth = 4 - priWidth = 4 + priWidth = 1 ageWidth = 6 urgWidth = 5 dueWidth = 10 tagsWidth = 15 descWidth = 45 - annWidth = 20 + annWidth = 1 ) func init() { @@ -579,7 +579,7 @@ func taskToRow(t task.Task) atable.Row { annStr := "" if n := len(anns); n > 0 { - annStr = strconv.Itoa(n) + annStr = strconv.FormatInt(int64(n), 16) } return atable.Row{ @@ -729,7 +729,7 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec annRaw := strings.Join(anns, "; ") annCount := "" if n := len(anns); n > 0 { - annCount = strconv.Itoa(n) + annCount = strconv.FormatInt(int64(n), 16) } annStr := highlightCellMatch(getStyle(6), re, annRaw, annCount) descStr := highlightCell(getStyle(7), re, t.Description) -- cgit v1.2.3 From 273d493170abe8838252c61a40b558ef4edc3db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:25:28 +0300 Subject: Add dynamic column widths and overdue sorting --- internal/task/task.go | 12 +++- internal/ui/table.go | 171 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/internal/task/task.go b/internal/task/task.go index 4acfec9..64dfc0d 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -242,7 +242,8 @@ func Edit(id int) error { // SortTasks orders tasks by start status, priority, due date, tag names and id. // Started tasks are always placed before non-started ones. Tasks without a due -// date are placed after tasks with a due date. +// date are placed after tasks with a due date. Overdue tasks are placed at the +// very top regardless of other properties. func SortTasks(tasks []Task) { joinTags := func(tags []string) string { if len(tags) == 0 { @@ -277,9 +278,18 @@ func SortTasks(tasks []Task) { return t, true } + overdue := func(t Task) bool { + du, ok := parseDue(t.Due) + return ok && time.Now().After(du) + } + sort.Slice(tasks, func(i, j int) bool { ti, tj := tasks[i], tasks[j] + if oi, oj := overdue(ti), overdue(tj); oi != oj { + return oi + } + startedI := ti.Start != "" && ti.Status != "completed" startedJ := tj.Start != "" && tj.Status != "completed" if startedI != startedJ { diff --git a/internal/ui/table.go b/internal/ui/table.go index a78eeb9..4a92876 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -21,17 +21,6 @@ import ( var priorityOptions = []string{"H", "M", "L", ""} -const ( - idWidth = 4 - priWidth = 1 - ageWidth = 6 - urgWidth = 5 - dueWidth = 10 - tagsWidth = 15 - descWidth = 45 - annWidth = 1 -) - func init() { rand.Seed(time.Now().UnixNano()) } @@ -76,6 +65,15 @@ type Model struct { windowHeight int + idWidth int + priWidth int + ageWidth int + urgWidth int + dueWidth int + tagsWidth int + descWidth int + annWidth int + total int inProgress int due int @@ -107,16 +105,16 @@ func New(filters []string) (Model, error) { return m, nil } -func newTable(rows []atable.Row) (atable.Model, atable.Styles) { +func (m *Model) newTable(rows []atable.Row) (atable.Model, atable.Styles) { cols := []atable.Column{ - {Title: "ID", Width: idWidth}, - {Title: "Pri", Width: priWidth}, - {Title: "Age", Width: ageWidth}, - {Title: "Urg", Width: urgWidth}, - {Title: "Due", Width: dueWidth}, - {Title: "Tags", Width: tagsWidth}, - {Title: "Annotations", Width: annWidth}, - {Title: "Description", Width: descWidth}, + {Title: "ID", Width: m.idWidth}, + {Title: "Pri", Width: m.priWidth}, + {Title: "Age", Width: m.ageWidth}, + {Title: "Urg", Width: m.urgWidth}, + {Title: "Due", Width: m.dueWidth}, + {Title: "Tags", Width: m.tagsWidth}, + {Title: "Annotations", Width: m.annWidth}, + {Title: "Description", Width: m.descWidth}, } t := atable.New( atable.WithColumns(cols), @@ -143,10 +141,17 @@ func (m *Model) reload() error { task.SortTasks(tasks) + m.tasks = tasks + m.total = task.TotalTasks(tasks) + m.inProgress = task.InProgressTasks(tasks) + m.due = task.DueTasks(tasks, time.Now()) + + m.computeColumnWidths() + var rows []atable.Row m.searchMatches = nil for i, tsk := range tasks { - rows = append(rows, taskToRowSearch(tsk, m.searchRegex, m.tblStyles, -1)) + rows = append(rows, m.taskToRowSearch(tsk, m.searchRegex, m.tblStyles, -1)) if m.searchRegex != nil { tags := strings.Join(tsk.Tags, " ") if m.searchRegex.MatchString(tags) { @@ -167,15 +172,11 @@ func (m *Model) reload() error { m.searchIndex = 0 } - m.tasks = tasks - m.total = task.TotalTasks(tasks) - m.inProgress = task.InProgressTasks(tasks) - m.due = task.DueTasks(tasks, time.Now()) - if m.tbl.Columns() == nil { - m.tbl, m.tblStyles = newTable(rows) + m.tbl, m.tblStyles = m.newTable(rows) } else { m.tbl.SetRows(rows) + m.applyColumns() } m.updateSelectionHighlight(-1, m.tbl.Cursor(), 0, m.tbl.ColumnCursor()) return nil @@ -190,6 +191,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.tbl.SetWidth(msg.Width) m.windowHeight = msg.Height + m.computeColumnWidths() m.updateTableHeight() return m, nil case editDoneMsg: @@ -557,7 +559,7 @@ func (m Model) topStatusLine() string { Render(line) } -func taskToRow(t task.Task) atable.Row { +func (m Model) taskToRow(t task.Task) atable.Row { style := lipgloss.NewStyle() if t.Start != "" { style = style.Background(lipgloss.Color("6")) @@ -584,10 +586,10 @@ func taskToRow(t task.Task) atable.Row { return atable.Row{ style.Render(strconv.Itoa(t.ID)), - formatPriority(t.Priority, priWidth), + formatPriority(t.Priority, m.priWidth), style.Render(age), style.Render(urg), - formatDue(t.Due, dueWidth), + formatDue(t.Due, m.dueWidth), style.Render(tags), style.Render(annStr), style.Render(t.Description), @@ -689,7 +691,7 @@ func highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display str return base.Render(display) } -func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selectedCol int) atable.Row { +func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selectedCol int) atable.Row { rowStyle := lipgloss.NewStyle() if t.Start != "" { rowStyle = rowStyle.Background(lipgloss.Color("6")) @@ -720,9 +722,9 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec } idStr := getStyle(0).Render(strconv.Itoa(t.ID)) - priStr := formatPriority(t.Priority, priWidth) + priStr := formatPriority(t.Priority, m.priWidth) ageStr := getStyle(2).Render(age) - dueStr := formatDue(t.Due, dueWidth) + dueStr := formatDue(t.Due, m.dueWidth) urgStr := getStyle(3).Render(urg) tagStr := highlightCell(getStyle(5), re, tags) @@ -758,7 +760,7 @@ func (m Model) expandedCellView() string { case 0: val = strconv.Itoa(t.ID) case 1: - val = ansi.Strip(formatPriority(t.Priority, priWidth)) + val = ansi.Strip(formatPriority(t.Priority, m.priWidth)) case 2: if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { days := int(time.Since(ts).Hours() / 24) @@ -767,7 +769,7 @@ func (m Model) expandedCellView() string { case 3: val = fmt.Sprintf("%.1f", t.Urgency) case 4: - val = ansi.Strip(formatDue(t.Due, dueWidth)) + val = ansi.Strip(formatDue(t.Due, m.dueWidth)) case 5: val = strings.Join(t.Tags, " ") case 6: @@ -797,10 +799,10 @@ func (m *Model) updateSelectionHighlight(prevRow, newRow, prevCol, newCol int) { } rows := m.tbl.Rows() if prevRow >= 0 && prevRow < len(rows) { - rows[prevRow] = taskToRowSearch(m.tasks[prevRow], m.searchRegex, m.tblStyles, -1) + rows[prevRow] = m.taskToRowSearch(m.tasks[prevRow], m.searchRegex, m.tblStyles, -1) } if newRow >= 0 && newRow < len(rows) { - rows[newRow] = taskToRowSearch(m.tasks[newRow], m.searchRegex, m.tblStyles, newCol) + rows[newRow] = m.taskToRowSearch(m.tasks[newRow], m.searchRegex, m.tblStyles, newCol) } m.tbl.SetRows(rows) } @@ -823,3 +825,98 @@ func (m *Model) updateTableHeight() { } m.tbl.SetHeight(h) } + +func dueText(s string) string { + if s == "" { + return "" + } + ts, err := time.Parse("20060102T150405Z", s) + if err != nil { + return s + } + days := int(time.Until(ts).Hours() / 24) + switch days { + case 0: + return "today" + case 1: + return "tomorrow" + case -1: + return "yesterday" + default: + return fmt.Sprintf("%dd", days) + } +} + +func (m *Model) computeColumnWidths() { + maxID := 1 + maxAge := 0 + maxUrg := 0 + maxDue := 0 + maxTags := 0 + maxAnn := 1 + for _, t := range m.tasks { + if l := len(strconv.Itoa(t.ID)); l > maxID { + maxID = l + } + age := "" + if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { + age = fmt.Sprintf("%dd", int(time.Since(ts).Hours()/24)) + } + if l := len(age); l > maxAge { + maxAge = l + } + urg := fmt.Sprintf("%.1f", t.Urgency) + if l := len(urg); l > maxUrg { + maxUrg = l + } + due := dueText(t.Due) + if l := len(due); l > maxDue { + maxDue = l + } + tags := strings.Join(t.Tags, " ") + if l := len(tags); l > maxTags { + maxTags = l + } + ann := len(t.Annotations) + if l := len(strconv.FormatInt(int64(ann), 16)); l > maxAnn { + maxAnn = l + } + } + + m.idWidth = maxID + m.priWidth = 1 + m.ageWidth = maxAge + m.urgWidth = maxUrg + m.dueWidth = maxDue + m.tagsWidth = maxTags + m.annWidth = maxAnn + + total := m.tbl.Width() + if total == 0 { + total = 80 + } + base := m.idWidth + m.priWidth + m.ageWidth + m.urgWidth + m.dueWidth + m.tagsWidth + m.annWidth + base += 7 // spaces between columns + m.descWidth = total - base + if m.descWidth < 1 { + m.descWidth = 1 + } + + if m.tbl.Columns() != nil { + m.applyColumns() + } +} + +func (m *Model) applyColumns() { + cols := []atable.Column{ + {Title: "ID", Width: m.idWidth}, + {Title: "Pri", Width: m.priWidth}, + {Title: "Age", Width: m.ageWidth}, + {Title: "Urg", Width: m.urgWidth}, + {Title: "Due", Width: m.dueWidth}, + {Title: "Tags", Width: m.tagsWidth}, + {Title: "Annotations", Width: m.annWidth}, + {Title: "Description", Width: m.descWidth}, + } + m.tbl.SetColumns(cols) +} -- cgit v1.2.3 From 011f4e7880b1548abb86a3d2f78f80e167004ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 20:36:08 +0300 Subject: Add in-table editing shortcuts --- internal/task/task.go | 44 ++++++++++++++++++ internal/ui/table.go | 126 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/internal/task/task.go b/internal/task/task.go index 64dfc0d..603002d 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -167,6 +167,50 @@ func RemoveTags(id int, tags []string) error { return run(args...) } +// SetTags sets the tags of the task with the given id to exactly the provided set. +// Tags not present will be removed and new tags added as needed. +func SetTags(id int, tags []string) error { + tasks, err := Export(strconv.Itoa(id)) + if err != nil { + return err + } + if len(tasks) == 0 { + return fmt.Errorf("task %d not found", id) + } + current := make(map[string]struct{}) + for _, t := range tasks[0].Tags { + current[t] = struct{}{} + } + desired := make(map[string]struct{}) + for _, t := range tags { + desired[t] = struct{}{} + } + + var adds, removes []string + for t := range desired { + if _, ok := current[t]; !ok { + adds = append(adds, t) + } + } + for t := range current { + if _, ok := desired[t]; !ok { + removes = append(removes, t) + } + } + + if len(adds) > 0 { + if err := AddTags(id, adds); err != nil { + return err + } + } + if len(removes) > 0 { + if err := RemoveTags(id, removes); err != nil { + return err + } + } + return nil +} + // SetRecurrence sets the recurrence for the task with the given id. func SetRecurrence(id int, rec string) error { return run(strconv.Itoa(id), "modify", "recur:"+rec) diff --git a/internal/ui/table.go b/internal/ui/table.go index 4a92876..4f7c2d6 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -42,6 +42,14 @@ type Model struct { annotateInput textinput.Model replaceAnnotations bool + descEditing bool + descID int + descInput textinput.Model + + tagsEditing bool + tagsID int + tagsInput textinput.Model + dueEditing bool dueID int dueDate time.Time @@ -94,6 +102,10 @@ func New(filters []string) (Model, error) { m := Model{filters: filters} m.annotateInput = textinput.New() m.annotateInput.Prompt = "annotation: " + m.descInput = textinput.New() + m.descInput.Prompt = "description: " + m.tagsInput = textinput.New() + m.tagsInput.Prompt = "tags: " m.dueDate = time.Now() m.searchInput = textinput.New() m.searchInput.Prompt = "search: " @@ -225,6 +237,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.annotateInput, cmd = m.annotateInput.Update(msg) return m, cmd } + if m.descEditing { + switch msg.Type { + case tea.KeyEnter: + task.SetDescription(m.descID, m.descInput.Value()) + m.descEditing = false + m.descInput.Blur() + m.reload() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + m.descEditing = false + m.descInput.Blur() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + m.descInput, cmd = m.descInput.Update(msg) + return m, cmd + } + if m.tagsEditing { + switch msg.Type { + case tea.KeyEnter: + tags := strings.Fields(m.tagsInput.Value()) + task.SetTags(m.tagsID, tags) + m.tagsEditing = false + m.tagsInput.Blur() + m.reload() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + m.tagsEditing = false + m.tagsInput.Blur() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + m.tagsInput, cmd = m.tagsInput.Update(msg) + return m, cmd + } if m.dueEditing { switch msg.Type { case tea.KeyEnter: @@ -461,7 +512,66 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateSelectionHighlight(prevRow, m.tbl.Cursor(), prevCol, m.tbl.ColumnCursor()) return m, nil } - case "enter": + case "enter", "i": + if row := m.tbl.SelectedRow(); row != nil { + idStr := ansi.Strip(row[0]) + if id, err := strconv.Atoi(idStr); err == nil { + col := m.tbl.ColumnCursor() + switch col { + case 1: + m.priorityID = id + m.prioritySelecting = true + switch m.tasks[m.tbl.Cursor()].Priority { + case "H": + m.priorityIndex = 0 + case "M": + m.priorityIndex = 1 + case "L": + m.priorityIndex = 2 + default: + m.priorityIndex = 3 + } + m.updateTableHeight() + return m, nil + case 4: + m.dueID = id + if ts, err := time.Parse("20060102T150405Z", m.tasks[m.tbl.Cursor()].Due); err == nil { + m.dueDate = ts + } else { + m.dueDate = time.Now() + } + m.dueEditing = true + m.updateTableHeight() + return m, nil + case 5: + m.tagsID = id + m.tagsEditing = true + m.tagsInput.SetValue(strings.Join(m.tasks[m.tbl.Cursor()].Tags, " ")) + m.tagsInput.Focus() + m.updateTableHeight() + return m, nil + case 6: + m.annotateID = id + m.annotating = true + m.replaceAnnotations = true + var anns []string + for _, a := range m.tasks[m.tbl.Cursor()].Annotations { + anns = append(anns, a.Description) + } + m.annotateInput.SetValue(strings.Join(anns, "; ")) + m.annotateInput.Focus() + m.updateTableHeight() + return m, nil + case 7: + m.descID = id + m.descEditing = true + m.descInput.SetValue(m.tasks[m.tbl.Cursor()].Description) + m.descInput.Focus() + m.updateTableHeight() + return m, nil + } + } + } m.cellExpanded = !m.cellExpanded m.updateTableHeight() return m, nil @@ -532,6 +642,18 @@ func (m Model) View() string { m.priorityView(), ) } + if m.descEditing { + view = lipgloss.JoinVertical(lipgloss.Left, + view, + m.descInput.View(), + ) + } + if m.tagsEditing { + view = lipgloss.JoinVertical(lipgloss.Left, + view, + m.tagsInput.View(), + ) + } if m.searching { view = lipgloss.JoinVertical(lipgloss.Left, view, @@ -817,7 +939,7 @@ func (m *Model) updateTableHeight() { if m.cellExpanded { h-- } - if m.annotating || m.dueEditing || m.prioritySelecting || m.searching { + if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing { h-- } if h < 1 { -- cgit v1.2.3 From b41781c9718e0c38dd3d39559ac84360923ef7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:14:26 +0300 Subject: Add tag editing hotkey and diff-based tag updates --- internal/ui/table.go | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index 4f7c2d6..5ea744c 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -259,8 +259,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.tagsEditing { switch msg.Type { case tea.KeyEnter: - tags := strings.Fields(m.tagsInput.Value()) - task.SetTags(m.tagsID, tags) + words := strings.Fields(m.tagsInput.Value()) + var adds, removes []string + for _, w := range words { + if strings.HasPrefix(w, "-") { + if len(w) > 1 { + removes = append(removes, w[1:]) + } + } else { + if strings.HasPrefix(w, "+") { + w = w[1:] + } + if w != "" { + adds = append(adds, w) + } + } + } + if len(adds) > 0 { + task.AddTags(m.tagsID, adds) + } + if len(removes) > 0 { + task.RemoveTags(m.tagsID, removes) + } m.tagsEditing = false m.tagsInput.Blur() m.reload() @@ -484,6 +504,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + case "t": + if row := m.tbl.SelectedRow(); row != nil { + idStr := ansi.Strip(row[0]) + if id, err := strconv.Atoi(idStr); err == nil { + m.tagsID = id + m.tagsEditing = true + m.tagsInput.SetValue(strings.Join(m.tasks[m.tbl.Cursor()].Tags, " ")) + m.tagsInput.Focus() + m.updateTableHeight() + return m, nil + } + } case "/", "?": m.searching = true m.searchInput.SetValue("") -- cgit v1.2.3 From 2234198b6f603240ed81f7656449cc6a319de701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 21:19:05 +0300 Subject: Open tag editor with empty prompt --- internal/ui/table.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index 5ea744c..affb148 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -510,7 +510,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if id, err := strconv.Atoi(idStr); err == nil { m.tagsID = id m.tagsEditing = true - m.tagsInput.SetValue(strings.Join(m.tasks[m.tbl.Cursor()].Tags, " ")) + m.tagsInput.SetValue("") m.tagsInput.Focus() m.updateTableHeight() return m, nil @@ -578,7 +578,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case 5: m.tagsID = id m.tagsEditing = true - m.tagsInput.SetValue(strings.Join(m.tasks[m.tbl.Cursor()].Tags, " ")) + m.tagsInput.SetValue("") m.tagsInput.Focus() m.updateTableHeight() return m, nil -- cgit v1.2.3 From 03c1f3095af847a2177f791a36c1864fef01825a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 22:53:35 +0300 Subject: make colors configurable and add theme hotkeys --- internal/atable/table.go | 19 ++++----- internal/ui/table.go | 92 ++++++++++++++++++++++------------------ internal/ui/theme.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 50 deletions(-) create mode 100644 internal/ui/theme.go diff --git a/internal/atable/table.go b/internal/atable/table.go index 528276f..c22d479 100644 --- a/internal/atable/table.go +++ b/internal/atable/table.go @@ -118,17 +118,19 @@ func DefaultKeyMap() KeyMap { // 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 + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style + Highlight 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), + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1), + Cell: lipgloss.NewStyle().Padding(0, 1), + Highlight: lipgloss.NewStyle().Background(lipgloss.Color("57")).Foreground(lipgloss.Color("0")), } } @@ -479,10 +481,7 @@ func (m *Model) renderRow(r int) string { highlightRow := r == m.cursor rowStyle := m.styles.Cell if highlightRow { - rowStyle = rowStyle. - Background(lipgloss.Color("57")). - Foreground(lipgloss.Color("0")). - Bold(false) + rowStyle = m.styles.Highlight } s := make([]string, 0, len(m.cols)) diff --git a/internal/ui/table.go b/internal/ui/table.go index affb148..f5e614a 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -85,6 +85,9 @@ type Model struct { total int inProgress int due int + + theme Theme + defaultTheme Theme } // editDoneMsg is emitted when the external editor process finishes. @@ -110,6 +113,9 @@ func New(filters []string) (Model, error) { m.searchInput = textinput.New() m.searchInput.Prompt = "search: " + m.defaultTheme = DefaultTheme() + m.theme = m.defaultTheme + if err := m.reload(); err != nil { return Model{}, err } @@ -135,11 +141,12 @@ func (m *Model) newTable(rows []atable.Row) (atable.Model, atable.Styles) { atable.WithShowHeaders(false), ) styles := atable.DefaultStyles() - styles.Header = styles.Header.Foreground(lipgloss.Color("205")) - styles.Selected = styles.Selected.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")) styles.Cell = styles.Cell.Padding(0, 1) t.SetStyles(styles) - return t, styles + m.tbl = t + m.tblStyles = styles + m.applyTheme() + return m.tbl, m.tblStyles } func (m *Model) reload() error { @@ -505,17 +512,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case "t": - if row := m.tbl.SelectedRow(); row != nil { - idStr := ansi.Strip(row[0]) - if id, err := strconv.Atoi(idStr); err == nil { - m.tagsID = id - m.tagsEditing = true - m.tagsInput.SetValue("") - m.tagsInput.Focus() - m.updateTableHeight() - return m, nil - } - } + m.theme = RandomTheme() + m.applyTheme() + m.reload() + return m, nil + case "T": + m.theme = m.defaultTheme + m.applyTheme() + m.reload() + return m, nil case "/", "?": m.searching = true m.searchInput.SetValue("") @@ -698,8 +703,8 @@ func (m Model) View() string { func (m Model) statusLine() string { status := fmt.Sprintf("Total:%d InProgress:%d Due:%d | press H for help", m.total, m.inProgress, m.due) return lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Foreground(lipgloss.Color(m.theme.StatusFG)). + Background(lipgloss.Color(m.theme.StatusBG)). Width(m.tbl.Width()). Render(status) } @@ -707,8 +712,8 @@ func (m Model) statusLine() string { func (m Model) topStatusLine() string { line := fmt.Sprintf("Task Samurai %s", internal.Version) return lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Foreground(lipgloss.Color(m.theme.StatusFG)). + Background(lipgloss.Color(m.theme.StatusBG)). Width(m.tbl.Width()). Render(line) } @@ -716,7 +721,7 @@ func (m Model) topStatusLine() string { func (m Model) taskToRow(t task.Task) atable.Row { style := lipgloss.NewStyle() if t.Start != "" { - style = style.Background(lipgloss.Color("6")) + style = style.Background(lipgloss.Color(m.theme.StartBG)) } age := "" @@ -740,10 +745,10 @@ func (m Model) taskToRow(t task.Task) atable.Row { return atable.Row{ style.Render(strconv.Itoa(t.ID)), - formatPriority(t.Priority, m.priWidth), + m.formatPriority(t.Priority, m.priWidth), style.Render(age), style.Render(urg), - formatDue(t.Due, m.dueWidth), + m.formatDue(t.Due, m.dueWidth), style.Render(tags), style.Render(annStr), style.Render(t.Description), @@ -753,7 +758,7 @@ func (m Model) taskToRow(t task.Task) atable.Row { // formatDue returns a formatted due date string. Dates due today or tomorrow // are returned as "today" or "tomorrow" respectively. Past due dates are // highlighted in red. -func formatDue(s string, width int) string { +func (m Model) formatDue(s string, width int) string { if s == "" { return "" } @@ -776,20 +781,20 @@ func formatDue(s string, width int) string { } style := lipgloss.NewStyle().Width(width) if days < 0 { - style = style.Background(lipgloss.Color("1")) + style = style.Background(lipgloss.Color(m.theme.OverdueBG)) } return style.Render(val) } -func formatPriority(p string, width int) string { +func (m Model) formatPriority(p string, width int) string { style := lipgloss.NewStyle().Width(width) switch p { case "L": - style = style.Background(lipgloss.Color("10")) + style = style.Background(lipgloss.Color(m.theme.PrioLowBG)) case "M": - style = style.Background(lipgloss.Color("12")) + style = style.Background(lipgloss.Color(m.theme.PrioMedBG)) case "H": - style = style.Background(lipgloss.Color("9")) + style = style.Background(lipgloss.Color(m.theme.PrioHighBG)) default: return p } @@ -809,19 +814,19 @@ func (m Model) priorityView() string { } style := lipgloss.NewStyle() if i == m.priorityIndex { - style = style.Foreground(lipgloss.Color("229")).Background(lipgloss.Color("57")) + style = style.Foreground(lipgloss.Color(m.theme.SelectedFG)).Background(lipgloss.Color(m.theme.SelectedBG)) } parts = append(parts, style.Render(label)) } return "priority: " + strings.Join(parts, " ") } -func highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) string { +func (m Model) highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) string { if re == nil || !re.MatchString(raw) { return base.Render(raw) } - highlight := lipgloss.NewStyle().Background(lipgloss.Color("226")).Foreground(lipgloss.Color("21")) + highlight := lipgloss.NewStyle().Background(lipgloss.Color(m.theme.SearchBG)).Foreground(lipgloss.Color(m.theme.SearchFG)) var b strings.Builder last := 0 for _, loc := range re.FindAllStringIndex(raw, -1) { @@ -837,9 +842,9 @@ func highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) string { return b.String() } -func highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display string) string { +func (m Model) highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display string) string { if re != nil && re.MatchString(raw) { - highlight := lipgloss.NewStyle().Background(lipgloss.Color("226")).Foreground(lipgloss.Color("21")) + highlight := lipgloss.NewStyle().Background(lipgloss.Color(m.theme.SearchBG)).Foreground(lipgloss.Color(m.theme.SearchFG)) return highlight.Copy().Inherit(base).Render(display) } return base.Render(display) @@ -848,7 +853,7 @@ func highlightCellMatch(base lipgloss.Style, re *regexp.Regexp, raw, display str func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selectedCol int) atable.Row { rowStyle := lipgloss.NewStyle() if t.Start != "" { - rowStyle = rowStyle.Background(lipgloss.Color("6")) + rowStyle = rowStyle.Background(lipgloss.Color(m.theme.StartBG)) } age := "" @@ -876,19 +881,19 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty } idStr := getStyle(0).Render(strconv.Itoa(t.ID)) - priStr := formatPriority(t.Priority, m.priWidth) + priStr := m.formatPriority(t.Priority, m.priWidth) ageStr := getStyle(2).Render(age) - dueStr := formatDue(t.Due, m.dueWidth) + dueStr := m.formatDue(t.Due, m.dueWidth) urgStr := getStyle(3).Render(urg) - tagStr := highlightCell(getStyle(5), re, tags) + tagStr := m.highlightCell(getStyle(5), re, tags) annRaw := strings.Join(anns, "; ") annCount := "" if n := len(anns); n > 0 { annCount = strconv.FormatInt(int64(n), 16) } - annStr := highlightCellMatch(getStyle(6), re, annRaw, annCount) - descStr := highlightCell(getStyle(7), re, t.Description) + annStr := m.highlightCellMatch(getStyle(6), re, annRaw, annCount) + descStr := m.highlightCell(getStyle(7), re, t.Description) return atable.Row{ idStr, @@ -914,7 +919,7 @@ func (m Model) expandedCellView() string { case 0: val = strconv.Itoa(t.ID) case 1: - val = ansi.Strip(formatPriority(t.Priority, m.priWidth)) + val = ansi.Strip(m.formatPriority(t.Priority, m.priWidth)) case 2: if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { days := int(time.Since(ts).Hours() / 24) @@ -923,7 +928,7 @@ func (m Model) expandedCellView() string { case 3: val = fmt.Sprintf("%.1f", t.Urgency) case 4: - val = ansi.Strip(formatDue(t.Due, m.dueWidth)) + val = ansi.Strip(m.formatDue(t.Due, m.dueWidth)) case 5: val = strings.Join(t.Tags, " ") case 6: @@ -1074,3 +1079,10 @@ func (m *Model) applyColumns() { } m.tbl.SetColumns(cols) } + +func (m *Model) applyTheme() { + m.tblStyles.Header = m.tblStyles.Header.Foreground(lipgloss.Color(m.theme.HeaderFG)) + m.tblStyles.Selected = m.tblStyles.Selected.Foreground(lipgloss.Color(m.theme.SelectedFG)).Background(lipgloss.Color(m.theme.SelectedBG)) + m.tblStyles.Highlight = m.tblStyles.Highlight.Background(lipgloss.Color(m.theme.RowBG)).Foreground(lipgloss.Color(m.theme.RowFG)) + m.tbl.SetStyles(m.tblStyles) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..9d81aab --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,106 @@ +package ui + +import ( + "math/rand" + "strconv" +) + +// Theme holds color configuration for the UI. +type Theme struct { + HeaderFG string + SelectedFG string + SelectedBG string + RowFG string + RowBG string + StatusFG string + StatusBG string + StartBG string + OverdueBG string + PrioLowBG string + PrioMedBG string + PrioHighBG string + SearchFG string + SearchBG string +} + +// DefaultTheme returns the color theme used by Task Samurai. +func DefaultTheme() Theme { + return Theme{ + HeaderFG: "205", + SelectedFG: "229", + SelectedBG: "57", + RowFG: "0", + RowBG: "57", + StatusFG: "229", + StatusBG: "57", + StartBG: "6", + OverdueBG: "1", + PrioLowBG: "10", + PrioMedBG: "12", + PrioHighBG: "9", + SearchFG: "21", + SearchBG: "226", + } +} + +func RandomTheme() Theme { + th := Theme{ + HeaderFG: randColor(), + SelectedBG: randColor(), + RowBG: randColor(), + StatusBG: randColor(), + StartBG: randColor(), + OverdueBG: randColor(), + PrioLowBG: randColor(), + PrioMedBG: randColor(), + PrioHighBG: randColor(), + SearchBG: randColor(), + } + th.SelectedFG = contrastColor(th.SelectedBG) + th.RowFG = contrastColor(th.RowBG) + th.StatusFG = contrastColor(th.StatusBG) + th.SearchFG = contrastColor(th.SearchBG) + return th +} + +func randColor() string { + return strconv.Itoa(rand.Intn(256)) +} + +func contrastColor(bg string) string { + i, err := strconv.Atoi(bg) + if err != nil { + return "0" + } + if brightness(i) > 128 { + return "0" + } + return "15" +} + +func brightness(i int) float64 { + r, g, b := xtermRGB(i) + return 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b) +} + +func xtermRGB(i int) (int, int, int) { + if i < 16 { + var table = [16][3]int{ + {0, 0, 0}, {205, 0, 0}, {0, 205, 0}, {205, 205, 0}, + {0, 0, 238}, {205, 0, 205}, {0, 205, 205}, {229, 229, 229}, + {127, 127, 127}, {255, 0, 0}, {0, 255, 0}, {255, 255, 0}, + {92, 92, 255}, {255, 0, 255}, {0, 255, 255}, {255, 255, 255}, + } + rgb := table[i] + return rgb[0], rgb[1], rgb[2] + } + if i >= 16 && i <= 231 { + i -= 16 + r := (i / 36) * 51 + g := (i % 36 / 6) * 51 + b := (i % 6) * 51 + return r, g, b + } + v := (i-232)*10 + 8 + return v, v, v +} -- cgit v1.2.3 From 4a47bd5907c1330a5bbedc13e384877c6ac71668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:04:04 +0300 Subject: Document all hotkeys --- README.md | 6 ++++-- internal/ui/table.go | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8830cf0..351a292 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are - `b/pgup`: page up - `f/pgdn/space`: page down - `u` or `ctrl+u`: half page up -- `d` or `ctrl+d`: half page down +- `ctrl+d`: half page down - `g/home/0`: go to start - `G/end`: go to end -- `enter`: expand/collapse the current cell +- `enter` or `i`: expand/collapse or edit the current cell depending on the column ### Task actions @@ -39,6 +39,8 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are - `a`: annotate task - `A`: replace annotations - `p`: set priority +- `t`: randomize theme +- `T`: reset theme ### Search diff --git a/internal/ui/table.go b/internal/ui/table.go index f5e614a..bcfa017 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -634,6 +634,7 @@ func (m Model) View() string { if m.showHelp { return lipgloss.JoinVertical(lipgloss.Left, m.tbl.HelpView(), + "enter/i: edit or expand cell", "E: edit task", "s: toggle start/stop", "D: mark task done", @@ -643,7 +644,10 @@ func (m Model) View() string { "a: annotate task", "A: replace annotations", "p: set priority", + "t: randomize theme", + "T: reset theme", "/, ?: search", + "n/N: next/prev search match", "esc: close help/search", "q: quit", "H: help", // show help toggle line -- cgit v1.2.3 From 449f343c7da6071599c5c8266f3f9b43e0b0a315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:08:17 +0300 Subject: Add filter editing hotkey --- README.md | 3 ++- internal/atable/table.go | 4 ++-- internal/ui/table.go | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 351a292..fd24b9e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are - `↑/k` and `↓/j`: move up and down - `←/h` and `→/l`: move left and right - `b/pgup`: page up -- `f/pgdn/space`: page down +- `pgdn/space`: page down - `u` or `ctrl+u`: half page up - `ctrl+d`: half page down - `g/home/0`: go to start @@ -49,6 +49,7 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are ### Misc +- `f`: change filter - `H`: toggle help - `q` or `esc`: close search/help or quit (press `q` when nothing is open) diff --git a/internal/atable/table.go b/internal/atable/table.go index c22d479..44f1c9f 100644 --- a/internal/atable/table.go +++ b/internal/atable/table.go @@ -85,8 +85,8 @@ func DefaultKeyMap() KeyMap { key.WithHelp("b/pgup", "page up"), ), PageDown: key.NewBinding( - key.WithKeys("f", "pgdown", spacebar), - key.WithHelp("f/pgdn", "page down"), + key.WithKeys("pgdown", spacebar), + key.WithHelp("pgdn", "page down"), ), HalfPageUp: key.NewBinding( key.WithKeys("u", "ctrl+u"), diff --git a/internal/ui/table.go b/internal/ui/table.go index bcfa017..3b1dacf 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -54,6 +54,9 @@ type Model struct { dueID int dueDate time.Time + filterEditing bool + filterInput textinput.Model + searching bool searchInput textinput.Model searchRegex *regexp.Regexp @@ -112,6 +115,8 @@ func New(filters []string) (Model, error) { m.dueDate = time.Now() m.searchInput = textinput.New() m.searchInput.Prompt = "search: " + m.filterInput = textinput.New() + m.filterInput.Prompt = "filter: " m.defaultTheme = DefaultTheme() m.theme = m.defaultTheme @@ -349,6 +354,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.filterEditing { + switch msg.Type { + case tea.KeyEnter: + m.filters = strings.Fields(m.filterInput.Value()) + m.filterEditing = false + m.filterInput.Blur() + m.reload() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + m.filterEditing = false + m.filterInput.Blur() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } if m.searching { switch msg.Type { case tea.KeyEnter: @@ -511,6 +535,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + case "f": + m.filterEditing = true + m.filterInput.SetValue(strings.Join(m.filters, " ")) + m.filterInput.Focus() + m.updateTableHeight() + return m, nil case "t": m.theme = RandomTheme() m.applyTheme() @@ -644,6 +674,7 @@ func (m Model) View() string { "a: annotate task", "A: replace annotations", "p: set priority", + "f: change filter", "t: randomize theme", "T: reset theme", "/, ?: search", @@ -695,6 +726,12 @@ func (m Model) View() string { m.tagsInput.View(), ) } + if m.filterEditing { + view = lipgloss.JoinVertical(lipgloss.Left, + view, + m.filterInput.View(), + ) + } if m.searching { view = lipgloss.JoinVertical(lipgloss.Left, view, @@ -980,7 +1017,7 @@ func (m *Model) updateTableHeight() { if m.cellExpanded { h-- } - if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing { + if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.filterEditing { h-- } if h < 1 { -- cgit v1.2.3 From d9e09559ad92df8284ac526a8ea4de359a761497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:16:30 +0300 Subject: Add blinking done effect and update tag hotkey --- README.md | 3 +- internal/ui/table.go | 88 +++++++++++++++++++++++++++++++++++++---------- internal/ui/table_test.go | 8 +++++ 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index fd24b9e..a1ab82e 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are - `a`: annotate task - `A`: replace annotations - `p`: set priority -- `t`: randomize theme -- `T`: reset theme +- `t`: edit tags ### Search diff --git a/internal/ui/table.go b/internal/ui/table.go index 3b1dacf..d0c62a0 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -72,6 +72,11 @@ type Model struct { undoStack []string + blinkID int + blinkRow int + blinkOn bool + blinkCount int + cellExpanded bool windowHeight int @@ -96,6 +101,11 @@ type Model struct { // editDoneMsg is emitted when the external editor process finishes. type editDoneMsg struct{ err error } +type blinkMsg struct{} + +const blinkInterval = 250 * time.Millisecond +const blinkCycles = 8 + // editCmd returns a command that edits the task and sends an // editDoneMsg once the process is complete. func editCmd(id int) tea.Cmd { @@ -103,6 +113,10 @@ func editCmd(id int) tea.Cmd { return tea.ExecProcess(c, func(err error) tea.Msg { return editDoneMsg{err: err} }) } +func blinkCmd() tea.Cmd { + return tea.Tick(blinkInterval, func(time.Time) tea.Msg { return blinkMsg{} }) +} + // New creates a new UI model with the provided rows. func New(filters []string) (Model, error) { m := Model{filters: filters} @@ -223,6 +237,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = msg.err m.reload() return m, nil + case blinkMsg: + if m.blinkID != 0 { + m.blinkOn = !m.blinkOn + m.blinkCount++ + m.updateBlinkRow() + if m.blinkCount >= blinkCycles { + id := m.blinkID + 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 + } + } + task.Done(id) + m.reload() + return m, nil + } + return m, blinkCmd() + } + return m, nil case tea.KeyMsg: if m.annotating { switch msg.Type { @@ -460,14 +497,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[0]) if id, err := strconv.Atoi(idStr); err == nil { - task.Done(id) - for _, tsk := range m.tasks { - if tsk.ID == id { - m.undoStack = append(m.undoStack, tsk.UUID) - break - } - } - m.reload() + m.blinkID = id + m.blinkRow = m.tbl.Cursor() + m.blinkOn = true + m.blinkCount = 0 + m.updateBlinkRow() + return m, blinkCmd() } } case "U": @@ -542,14 +577,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil case "t": - m.theme = RandomTheme() - m.applyTheme() - m.reload() - return m, nil - case "T": - m.theme = m.defaultTheme - m.applyTheme() - m.reload() + if row := m.tbl.SelectedRow(); row != nil { + idStr := ansi.Strip(row[0]) + if id, err := strconv.Atoi(idStr); err == nil { + m.tagsID = id + m.tagsEditing = true + m.tagsInput.SetValue("") + m.tagsInput.Focus() + m.updateTableHeight() + return m, nil + } + } return m, nil case "/", "?": m.searching = true @@ -675,8 +713,7 @@ func (m Model) View() string { "A: replace annotations", "p: set priority", "f: change filter", - "t: randomize theme", - "T: reset theme", + "t: edit tags", "/, ?: search", "n/N: next/prev search match", "esc: close help/search", @@ -764,6 +801,9 @@ func (m Model) taskToRow(t task.Task) atable.Row { if t.Start != "" { style = style.Background(lipgloss.Color(m.theme.StartBG)) } + if t.ID == m.blinkID && m.blinkOn { + style = style.Reverse(true) + } age := "" if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { @@ -896,6 +936,9 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty if t.Start != "" { rowStyle = rowStyle.Background(lipgloss.Color(m.theme.StartBG)) } + if t.ID == m.blinkID && m.blinkOn { + rowStyle = rowStyle.Reverse(true) + } age := "" if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil { @@ -1007,6 +1050,15 @@ func (m *Model) updateSelectionHighlight(prevRow, newRow, prevCol, newCol int) { m.tbl.SetRows(rows) } +func (m *Model) updateBlinkRow() { + if m.blinkRow < 0 || m.blinkRow >= len(m.tasks) || m.tbl.Rows() == nil { + return + } + rows := m.tbl.Rows() + rows[m.blinkRow] = m.taskToRowSearch(m.tasks[m.blinkRow], m.searchRegex, m.tblStyles, -1) + m.tbl.SetRows(rows) +} + // updateTableHeight recalculates the table height based on the current window // size and which auxiliary views are open. func (m *Model) updateTableHeight() { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 89e1af6..3bf6f3e 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -164,6 +164,10 @@ func TestDoneHotkey(t *testing.T) { mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) m = mv.(Model) + for i := 0; i < blinkCycles; i++ { + mv, _ = m.Update(blinkMsg{}) + m = mv.(Model) + } data, err := os.ReadFile(doneFile) if err != nil { @@ -209,6 +213,10 @@ func TestUndoHotkey(t *testing.T) { mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) m = mv.(Model) + for i := 0; i < blinkCycles; i++ { + mv, _ = m.Update(blinkMsg{}) + m = mv.(Model) + } mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'U'}}) m = mv.(Model) -- cgit v1.2.3 From 588c4b772cc782a2c190029e220290c89a08c8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:24:49 +0300 Subject: Add theme toggling hotkeys --- README.md | 2 ++ internal/ui/table.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index a1ab82e..f138830 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are ### Misc - `f`: change filter +- `c`: random theme +- `C`: reset theme - `H`: toggle help - `q` or `esc`: close search/help or quit (press `q` when nothing is open) diff --git a/internal/ui/table.go b/internal/ui/table.go index d0c62a0..ae3af66 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -589,6 +589,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil + case "c": + m.theme = RandomTheme() + m.applyTheme() + return m, nil + case "C": + m.theme = m.defaultTheme + m.applyTheme() + return m, nil case "/", "?": m.searching = true m.searchInput.SetValue("") @@ -714,6 +722,8 @@ func (m Model) View() string { "p: set priority", "f: change filter", "t: edit tags", + "c: random theme", + "C: reset theme", "/, ?: search", "n/N: next/prev search match", "esc: close help/search", -- cgit v1.2.3 From 28c46b7680ad168fd8d1aa9af2b37a64513a0fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:38:44 +0300 Subject: Add recurrence editing column and hotkey --- README.md | 2 +- internal/ui/table.go | 104 +++++++++++++++++++++++++++++++++++----------- internal/ui/table_test.go | 30 ++++++------- 3 files changed, 93 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f138830..04b9cca 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are - `D`: mark task done - `U`: undo last done - `d`: set due date -- `r`: random due date +- `r`: edit recurrence - `a`: annotate task - `A`: replace annotations - `p`: set priority diff --git a/internal/ui/table.go b/internal/ui/table.go index ae3af66..1f06e7e 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -54,6 +54,10 @@ type Model struct { dueID int dueDate time.Time + recurEditing bool + recurID int + recurInput textinput.Model + filterEditing bool filterInput textinput.Model @@ -81,14 +85,15 @@ type Model struct { windowHeight int - idWidth int - priWidth int - ageWidth int - urgWidth int - dueWidth int - tagsWidth int - descWidth int - annWidth int + idWidth int + priWidth int + ageWidth int + urgWidth int + dueWidth int + recurWidth int + tagsWidth int + descWidth int + annWidth int total int inProgress int @@ -126,6 +131,8 @@ func New(filters []string) (Model, error) { m.descInput.Prompt = "description: " m.tagsInput = textinput.New() m.tagsInput.Prompt = "tags: " + m.recurInput = textinput.New() + m.recurInput.Prompt = "recur: " m.dueDate = time.Now() m.searchInput = textinput.New() m.searchInput.Prompt = "search: " @@ -147,11 +154,12 @@ func (m *Model) newTable(rows []atable.Row) (atable.Model, atable.Styles) { {Title: "ID", Width: m.idWidth}, {Title: "Pri", Width: m.priWidth}, {Title: "Age", Width: m.ageWidth}, - {Title: "Urg", Width: m.urgWidth}, {Title: "Due", Width: m.dueWidth}, + {Title: "Recur", Width: m.recurWidth}, {Title: "Tags", Width: m.tagsWidth}, {Title: "Annotations", Width: m.annWidth}, {Title: "Description", Width: m.descWidth}, + {Title: "Urg", Width: m.urgWidth}, } t := atable.New( atable.WithColumns(cols), @@ -370,6 +378,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.recurEditing { + switch msg.Type { + case tea.KeyEnter: + task.SetRecurrence(m.recurID, m.recurInput.Value()) + m.recurEditing = false + m.recurInput.Blur() + m.reload() + m.updateTableHeight() + return m, nil + case tea.KeyEsc: + m.recurEditing = false + m.recurInput.Blur() + m.updateTableHeight() + return m, nil + } + var cmd tea.Cmd + m.recurInput, cmd = m.recurInput.Update(msg) + return m, cmd + } if m.prioritySelecting { switch msg.Type { case tea.KeyEnter: @@ -527,10 +554,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if row := m.tbl.SelectedRow(); row != nil { idStr := ansi.Strip(row[0]) if id, err := strconv.Atoi(idStr); err == nil { - days := rand.Intn(31) + 7 - due := time.Now().AddDate(0, 0, days).Format("2006-01-02") - task.SetDueDate(id, due) - m.reload() + m.recurID = id + m.recurEditing = true + m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur) + m.recurInput.Focus() + m.updateTableHeight() + return m, nil } } case "p": @@ -646,7 +675,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.updateTableHeight() return m, nil - case 4: + case 3: m.dueID = id if ts, err := time.Parse("20060102T150405Z", m.tasks[m.tbl.Cursor()].Due); err == nil { m.dueDate = ts @@ -656,6 +685,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dueEditing = true m.updateTableHeight() return m, nil + case 4: + m.recurID = id + m.recurEditing = true + m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur) + m.recurInput.Focus() + m.updateTableHeight() + return m, nil case 5: m.tagsID = id m.tagsEditing = true @@ -716,7 +752,7 @@ func (m Model) View() string { "D: mark task done", "U: undo done", "d: set due date", - "r: random due date", + "r: edit recurrence", "a: annotate task", "A: replace annotations", "p: set priority", @@ -773,6 +809,12 @@ func (m Model) View() string { m.tagsInput.View(), ) } + if m.recurEditing { + view = lipgloss.JoinVertical(lipgloss.Left, + view, + m.recurInput.View(), + ) + } if m.filterEditing { view = lipgloss.JoinVertical(lipgloss.Left, view, @@ -823,6 +865,7 @@ func (m Model) taskToRow(t task.Task) atable.Row { tags := strings.Join(t.Tags, " ") urg := fmt.Sprintf("%.1f", t.Urgency) + recur := t.Recur var anns []string for _, a := range t.Annotations { @@ -838,11 +881,12 @@ func (m Model) taskToRow(t task.Task) atable.Row { style.Render(strconv.Itoa(t.ID)), m.formatPriority(t.Priority, m.priWidth), style.Render(age), - style.Render(urg), m.formatDue(t.Due, m.dueWidth), + style.Render(recur), style.Render(tags), style.Render(annStr), style.Render(t.Description), + style.Render(urg), } } @@ -958,6 +1002,7 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty tags := strings.Join(t.Tags, " ") urg := fmt.Sprintf("%.1f", t.Urgency) + recur := t.Recur var anns []string for _, a := range t.Annotations { @@ -978,8 +1023,7 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty priStr := m.formatPriority(t.Priority, m.priWidth) ageStr := getStyle(2).Render(age) dueStr := m.formatDue(t.Due, m.dueWidth) - urgStr := getStyle(3).Render(urg) - + recurStr := m.highlightCell(getStyle(4), re, recur) tagStr := m.highlightCell(getStyle(5), re, tags) annRaw := strings.Join(anns, "; ") annCount := "" @@ -988,16 +1032,18 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty } annStr := m.highlightCellMatch(getStyle(6), re, annRaw, annCount) descStr := m.highlightCell(getStyle(7), re, t.Description) + urgStr := getStyle(8).Render(urg) return atable.Row{ idStr, priStr, ageStr, - urgStr, dueStr, + recurStr, tagStr, annStr, descStr, + urgStr, } } @@ -1020,9 +1066,9 @@ func (m Model) expandedCellView() string { val = fmt.Sprintf("%dd", days) } case 3: - val = fmt.Sprintf("%.1f", t.Urgency) - case 4: val = ansi.Strip(m.formatDue(t.Due, m.dueWidth)) + case 4: + val = t.Recur case 5: val = strings.Join(t.Tags, " ") case 6: @@ -1033,6 +1079,8 @@ func (m Model) expandedCellView() string { val = strings.Join(anns, "; ") case 7: val = t.Description + case 8: + val = fmt.Sprintf("%.1f", t.Urgency) } header := "" cols := m.tbl.Columns() @@ -1079,7 +1127,7 @@ func (m *Model) updateTableHeight() { if m.cellExpanded { h-- } - if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.filterEditing { + if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing { h-- } if h < 1 { @@ -1114,6 +1162,7 @@ func (m *Model) computeColumnWidths() { maxAge := 0 maxUrg := 0 maxDue := 0 + maxRecur := 1 maxTags := 0 maxAnn := 1 for _, t := range m.tasks { @@ -1135,6 +1184,9 @@ func (m *Model) computeColumnWidths() { if l := len(due); l > maxDue { maxDue = l } + if l := len(t.Recur); l > maxRecur { + maxRecur = l + } tags := strings.Join(t.Tags, " ") if l := len(tags); l > maxTags { maxTags = l @@ -1150,6 +1202,7 @@ func (m *Model) computeColumnWidths() { m.ageWidth = maxAge m.urgWidth = maxUrg m.dueWidth = maxDue + m.recurWidth = maxRecur m.tagsWidth = maxTags m.annWidth = maxAnn @@ -1157,8 +1210,8 @@ func (m *Model) computeColumnWidths() { if total == 0 { total = 80 } - base := m.idWidth + m.priWidth + m.ageWidth + m.urgWidth + m.dueWidth + m.tagsWidth + m.annWidth - base += 7 // spaces between columns + base := m.idWidth + m.priWidth + m.ageWidth + m.dueWidth + m.recurWidth + m.tagsWidth + m.annWidth + m.urgWidth + base += 8 // spaces between columns m.descWidth = total - base if m.descWidth < 1 { m.descWidth = 1 @@ -1174,11 +1227,12 @@ func (m *Model) applyColumns() { {Title: "ID", Width: m.idWidth}, {Title: "Pri", Width: m.priWidth}, {Title: "Age", Width: m.ageWidth}, - {Title: "Urg", Width: m.urgWidth}, {Title: "Due", Width: m.dueWidth}, + {Title: "Recur", Width: m.recurWidth}, {Title: "Tags", Width: m.tagsWidth}, {Title: "Annotations", Width: m.annWidth}, {Title: "Description", Width: m.descWidth}, + {Title: "Urg", Width: m.urgWidth}, } m.tbl.SetColumns(cols) } diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 3bf6f3e..18a471b 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -289,17 +289,17 @@ func TestDueDateHotkey(t *testing.T) { } } -func TestRandomDueDateHotkey(t *testing.T) { +func TestRecurrenceHotkey(t *testing.T) { tmp := t.TempDir() taskPath := filepath.Join(tmp, "task") - dueFile := filepath.Join(tmp, "due.txt") + recFile := filepath.Join(tmp, "recur.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 \"$@\" > " + dueFile + "\n" + "echo \"$@\" > " + recFile + "\n" if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { t.Fatal(err) @@ -323,24 +323,20 @@ func TestRandomDueDateHotkey(t *testing.T) { mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) m = mv.(Model) - - data, err := os.ReadFile(dueFile) - if err != nil { - t.Fatalf("read due: %v", err) + for _, r := range "daily" { + 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) - parts := strings.Split(strings.TrimSpace(string(data)), " ") - if len(parts) != 3 { - t.Fatalf("unexpected command: %q", data) - } - dueStr := strings.TrimPrefix(parts[2], "due:") - dueTime, err := time.Parse("2006-01-02", dueStr) + data, err := os.ReadFile(recFile) if err != nil { - t.Fatalf("parse due: %v", err) + t.Fatalf("read recur: %v", err) } - days := int(time.Until(dueTime).Hours() / 24) - if days < 7 || days > 37 { - t.Fatalf("due date out of range: %d", days) + + if strings.TrimSpace(string(data)) != "1 modify recur:daily" { + t.Fatalf("recur not set: %q", data) } } -- cgit v1.2.3 From c5d91ea346ac91e58ba8f2b40e6289cc926fb257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20B=C3=BCtow?= <1224732+snonux@users.noreply.github.com> Date: Sat, 21 Jun 2025 23:45:43 +0300 Subject: Place priority column first --- internal/ui/table.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/ui/table.go b/internal/ui/table.go index 1f06e7e..3e189fa 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -151,8 +151,8 @@ func New(filters []string) (Model, error) { func (m *Model) newTable(rows []atable.Row) (atable.Model, atable.Styles) { cols := []atable.Column{ - {Title: "ID", Width: m.idWidth}, {Title: "Pri", Width: m.priWidth}, + {Title: "ID", Width: m.idWidth}, {Title: "Age", Width: m.ageWidth}, {Title: "Due", Width: m.dueWidth}, {Title: "Recur", Width: m.recurWidth}, @@ -496,14 +496,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "e", "E": if row := m.tbl.SelectedRow(); row != nil { - idStr := ansi.Strip(row[0]) + idStr := ansi.Strip(row[1]) if id, err := strconv.Atoi(idStr); err == nil { return m, editCmd(id)