summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-21 23:54:08 +0300
committerGitHub <noreply@github.com>2025-06-21 23:54:08 +0300
commit05124d641791083612043159f36fada257e03468 (patch)
tree224f417c19f324674f5a240dc98583725061049a
parentfde95e1ca2acb202b3e2698562ab0a3ca360c0cc (diff)
parentc1585e94cf4a0eeb8ebd49103291119b97e097a8 (diff)
Merge pull request #81 from snonux/codex/fix-row-alignment-issue
Fix spacing in highlighted table rows
-rw-r--r--README.md11
-rw-r--r--internal/atable/table.go25
-rw-r--r--internal/task/task.go56
-rw-r--r--internal/ui/table.go643
-rw-r--r--internal/ui/table_test.go59
-rw-r--r--internal/ui/theme.go106
6 files changed, 787 insertions, 113 deletions
diff --git a/README.md b/README.md
index 8830cf0..05b03ca 100644
--- a/README.md
+++ b/README.md
@@ -21,12 +21,12 @@ 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
-- `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
@@ -36,9 +36,11 @@ Task Samurai invokes the `task` command to read and modify tasks. The tasks are
- `U`: undo last done
- `d`: set due date
- `r`: random due date
+- `R`: edit recurrence
- `a`: annotate task
- `A`: replace annotations
- `p`: set priority
+- `t`: edit tags
### Search
@@ -47,6 +49,9 @@ 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/atable/table.go b/internal/atable/table.go
index 3e6cc93..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"),
@@ -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))
@@ -530,7 +529,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)
}
diff --git a/internal/task/task.go b/internal/task/task.go
index 4acfec9..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)
@@ -242,7 +286,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 +322,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 dd862a5..1caf81b 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 = 4
- ageWidth = 6
- urgWidth = 5
- dueWidth = 10
- tagsWidth = 15
- descWidth = 45
- annWidth = 20
-)
-
func init() {
rand.Seed(time.Now().UnixNano())
}
@@ -53,10 +42,25 @@ 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
+ recurEditing bool
+ recurID int
+ recurInput textinput.Model
+
+ filterEditing bool
+ filterInput textinput.Model
+
searching bool
searchInput textinput.Model
searchRegex *regexp.Regexp
@@ -72,18 +76,41 @@ type Model struct {
undoStack []string
+ blinkID int
+ blinkRow int
+ blinkOn bool
+ blinkCount int
+
cellExpanded bool
windowHeight int
+ idWidth int
+ priWidth int
+ ageWidth int
+ urgWidth int
+ dueWidth int
+ recurWidth int
+ tagsWidth int
+ descWidth int
+ annWidth int
+
total int
inProgress int
due int
+
+ theme Theme
+ defaultTheme Theme
}
// 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 {
@@ -91,14 +118,29 @@ 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}
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.recurInput = textinput.New()
+ m.recurInput.Prompt = "recur: "
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
if err := m.reload(); err != nil {
return Model{}, err
@@ -107,16 +149,17 @@ 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: "Description", Width: descWidth},
- {Title: "Annotations", Width: annWidth},
+ {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},
+ {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),
@@ -125,11 +168,12 @@ func 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 {
@@ -143,21 +187,28 @@ 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) {
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
}
}
@@ -167,15 +218,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 +237,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:
@@ -197,6 +245,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 {
@@ -223,6 +294,65 @@ 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:
+ 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()
+ 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:
@@ -248,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:
@@ -269,6 +418,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:
@@ -328,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)
}
}
case "s":
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 {
started := false
for _, tsk := range m.tasks {
@@ -354,16 +522,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "D":
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 {
- 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":
@@ -375,7 +541,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "d":
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 {
m.dueID = id
m.dueEditing = true
@@ -386,7 +552,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "r":
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 {
days := rand.Intn(31) + 7
due := time.Now().AddDate(0, 0, days).Format("2006-01-02")
@@ -394,9 +560,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.reload()
}
}
+ case "R":
+ if row := m.tbl.SelectedRow(); row != nil {
+ idStr := ansi.Strip(row[1])
+ if id, err := strconv.Atoi(idStr); err == nil {
+ 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":
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 {
m.priorityID = id
m.prioritySelecting = true
@@ -407,7 +585,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "a":
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 {
m.annotateID = id
m.annotating = true
@@ -420,7 +598,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "A":
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 {
m.annotateID = id
m.annotating = true
@@ -431,6 +609,33 @@ 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":
+ if row := m.tbl.SelectedRow(); row != nil {
+ idStr := ansi.Strip(row[1])
+ 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 "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("")
@@ -459,7 +664,73 @@ 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[1])
+ if id, err := strconv.Atoi(idStr); err == nil {
+ col := m.tbl.ColumnCursor()
+ switch col {
+ case 0:
+ 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 3:
+ 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 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
+ m.tagsInput.SetValue("")
+ 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
@@ -485,16 +756,23 @@ 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",
"U: undo done",
"d: set due date",
"r: random due date",
+ "R: edit recurrence",
"a: annotate task",
"A: replace annotations",
"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",
"q: quit",
"H: help", // show help toggle line
@@ -530,6 +808,30 @@ 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.recurEditing {
+ view = lipgloss.JoinVertical(lipgloss.Left,
+ view,
+ m.recurInput.View(),
+ )
+ }
+ if m.filterEditing {
+ view = lipgloss.JoinVertical(lipgloss.Left,
+ view,
+ m.filterInput.View(),
+ )
+ }
if m.searching {
view = lipgloss.JoinVertical(lipgloss.Left,
view,
@@ -542,30 +844,28 @@ 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)
}
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")).
+ Foreground(lipgloss.Color(m.theme.StatusFG)).
+ Background(lipgloss.Color(m.theme.StatusBG)).
Width(m.tbl.Width()).
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"))
+ style = style.Background(lipgloss.Color(m.theme.StartBG))
+ }
+ if t.ID == m.blinkID && m.blinkOn {
+ style = style.Reverse(true)
}
age := ""
@@ -576,28 +876,35 @@ func 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 {
anns = append(anns, a.Description)
}
+ annStr := ""
+ if n := len(anns); n > 0 {
+ annStr = strconv.FormatInt(int64(n), 16)
+ }
+
return atable.Row{
+ m.formatPriority(t.Priority, m.priWidth),
style.Render(strconv.Itoa(t.ID)),
- formatPriority(t.Priority, priWidth),
style.Render(age),
- style.Render(urg),
- formatDue(t.Due, dueWidth),
+ m.formatDue(t.Due, m.dueWidth),
+ style.Render(recur),
style.Render(tags),
+ style.Render(annStr),
style.Render(t.Description),
- style.Render(strings.Join(anns, "; ")),
+ style.Render(urg),
}
}
// 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 ""
}
@@ -620,20 +927,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
}
@@ -653,19 +960,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) {
@@ -681,10 +988,21 @@ func highlightCell(base lipgloss.Style, re *regexp.Regexp, raw string) string {
return b.String()
}
-func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selectedCol int) atable.Row {
+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(m.theme.SearchBG)).Foreground(lipgloss.Color(m.theme.SearchFG))
+ return highlight.Copy().Inherit(base).Render(display)
+ }
+ return base.Render(display)
+}
+
+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))
+ }
+ if t.ID == m.blinkID && m.blinkOn {
+ rowStyle = rowStyle.Reverse(true)
}
age := ""
@@ -695,6 +1013,7 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec
tags := strings.Join(t.Tags, " ")
urg := fmt.Sprintf("%.1f", t.Urgency)
+ recur := t.Recur
var anns []string
for _, a := range t.Annotations {
@@ -711,26 +1030,31 @@ func taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Styles, selec
return cellStyle
}
- idStr := getStyle(0).Render(strconv.Itoa(t.ID))
- priStr := formatPriority(t.Priority, priWidth)
+ priStr := m.formatPriority(t.Priority, m.priWidth)
+ idStr := getStyle(1).Render(strconv.Itoa(t.ID))
ageStr := getStyle(2).Render(age)
- dueStr := formatDue(t.Due, dueWidth)
- urgStr := getStyle(3).Render(urg)
-
- tagStr := highlightCell(getStyle(5), re, tags)
- descStr := highlightCell(getStyle(6), re, t.Description)
+ dueStr := m.formatDue(t.Due, m.dueWidth)
+ recurStr := m.highlightCell(getStyle(4), re, recur)
+ tagStr := m.highlightCell(getStyle(5), re, tags)
annRaw := strings.Join(anns, "; ")
- annStr := highlightCell(getStyle(7), re, annRaw)
+ annCount := ""
+ if n := len(anns); n > 0 {
+ annCount = strconv.FormatInt(int64(n), 16)
+ }
+ 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,
+ idStr,
ageStr,
- urgStr,
dueStr,
+ recurStr,
tagStr,
- descStr,
annStr,
+ descStr,
+ urgStr,
}
}
@@ -744,28 +1068,38 @@ func (m Model) expandedCellView() string {
var val string
switch col {
case 0:
- val = strconv.Itoa(t.ID)
+ val = ansi.Strip(m.formatPriority(t.Priority, m.priWidth))
case 1:
- val = ansi.Strip(formatPriority(t.Priority, priWidth))
+ val = strconv.Itoa(t.ID)
case 2:
if ts, err := time.Parse("20060102T150405Z", t.Entry); err == nil {
days := int(time.Since(ts).Hours() / 24)
val = fmt.Sprintf("%dd", days)
}
case 3:
- val = fmt.Sprintf("%.1f", t.Urgency)
+ val = ansi.Strip(m.formatDue(t.Due, m.dueWidth))
case 4:
- val = ansi.Strip(formatDue(t.Due, dueWidth))
+ val = t.Recur
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
+ case 8:
+ val = fmt.Sprintf("%.1f", t.Urgency)
+ }
+ 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)
@@ -777,14 +1111,23 @@ 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)
}
+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() {
@@ -795,7 +1138,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 || m.recurEditing || m.filterEditing {
h--
}
if h < 1 {
@@ -803,3 +1146,111 @@ func (m *Model) u