diff options
| author | Paul Bütow <1224732+snonux@users.noreply.github.com> | 2025-06-21 23:54:08 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-21 23:54:08 +0300 |
| commit | 05124d641791083612043159f36fada257e03468 (patch) | |
| tree | 224f417c19f324674f5a240dc98583725061049a | |
| parent | fde95e1ca2acb202b3e2698562ab0a3ca360c0cc (diff) | |
| parent | c1585e94cf4a0eeb8ebd49103291119b97e097a8 (diff) | |
Merge pull request #81 from snonux/codex/fix-row-alignment-issue
Fix spacing in highlighted table rows
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | internal/atable/table.go | 25 | ||||
| -rw-r--r-- | internal/task/task.go | 56 | ||||
| -rw-r--r-- | internal/ui/table.go | 643 | ||||
| -rw-r--r-- | internal/ui/table_test.go | 59 | ||||
| -rw-r--r-- | internal/ui/theme.go | 106 |
6 files changed, 787 insertions, 113 deletions
@@ -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 |
