diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-28 14:22:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-28 14:22:57 +0300 |
| commit | 57e433ade2e450ebaab1efeef34bd0f02823f2e9 (patch) | |
| tree | cdb0ebd2ad92283810363516da35b16e7f9eb765 /internal | |
| parent | 1d4ab9102d46f9f1f41946aa600a1404f54696f9 (diff) | |
feat: add project field support to TaskSamurai
Add comprehensive project field support including:
- Project column in table view with automatic width calculation
- 'J' hotkey to edit project in both table and detail views
- Project field in task detail view (between Start and Entry)
- Search functionality includes project names
- Enter key on project column allows inline editing
The project field integrates seamlessly with Taskwarrior's native
project support and follows the same UI patterns as other editable
fields in the application.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/task/task.go | 6 | ||||
| -rw-r--r-- | internal/ui/handlers.go | 49 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 46 | ||||
| -rw-r--r-- | internal/ui/table.go | 64 | ||||
| -rw-r--r-- | internal/ui/taskdetail.go | 19 |
5 files changed, 158 insertions, 26 deletions
diff --git a/internal/task/task.go b/internal/task/task.go index 86c93f4..2c7f8e8 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -26,6 +26,7 @@ type Task struct { ID int `json:"id"` UUID string `json:"uuid"` Description string `json:"description"` + Project string `json:"project"` Tags []string `json:"tags"` Status string `json:"status"` Start string `json:"start"` @@ -298,6 +299,11 @@ func SetDescription(id int, desc string) error { return modifyTask(id, "description:"+desc) } +// SetProject changes the project of the task with the given id. +func SetProject(id int, project string) error { + return modifyTask(id, "project:"+project) +} + // Annotate adds an annotation to the task with the given id. func Annotate(id int, text string) error { if id <= 0 { diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 576519c..1bc65c0 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -227,6 +227,28 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return model, cmd } +// handleProjectMode handles project editing +func (m *Model) handleProjectMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + onEnter := func(value string) error { + return task.SetProject(m.projID, value) + } + + onExit := func() { + m.projEditing = false + m.reload() + } + + model, cmd := m.handleTextInput(msg, &m.projInput, onEnter, onExit) + if msg.Type == tea.KeyEnter { + if m.showTaskDetail { + // In detail view, blink the project field + return model, m.startDetailBlink(fieldProject) // Project field index in detail view + } + return model, m.startBlink(m.projID, false) + } + return model, cmd +} + // handlePriorityMode handles priority selection func (m *Model) handlePriorityMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { @@ -477,6 +499,9 @@ func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Mode case m.recurEditing: model, cmd = m.handleRecurrenceMode(msg) return true, model, cmd + case m.projEditing: + model, cmd = m.handleProjectMode(msg) + return true, model, cmd case m.prioritySelecting: model, cmd = m.handlePriorityMode(msg) return true, model, cmd @@ -595,7 +620,7 @@ func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { id := m.currentTaskDetail.ID // Map detail field index to editable fields - // fieldPriority = 3, fieldTags = 4, fieldDue = 5, fieldStart = 6, fieldRecur = 8 or 9 (depending on if fields exist) + // fieldPriority = 3, fieldTags = 4, fieldDue = 5, fieldStart = 6, fieldProject = 7, fieldRecur = 9 or 10 (depending on if fields exist) // Count fields up to current position to handle dynamic fields fieldPos := 0 @@ -666,13 +691,29 @@ func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { } fieldPos++ - // Entry (7) + // Project (7) + if m.detailFieldIndex == fieldPos { + m.clearEditingModes() + m.projID = id + m.projEditing = true + if m.currentTaskDetail.Project != "" { + m.projInput.SetValue(m.currentTaskDetail.Project) + } else { + m.projInput.SetValue("") + } + m.projInput.Focus() + m.updateTableHeight() + return m, nil + } + fieldPos++ + + // Entry (8) if m.detailFieldIndex == fieldPos { return m, nil // Not editable } fieldPos++ - // Recurrence (8) - only if it exists + // Recurrence (9) - only if it exists if m.currentTaskDetail.Recur != "" { if m.detailFieldIndex == fieldPos { m.clearEditingModes() @@ -686,7 +727,7 @@ func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { fieldPos++ } - // Description (9) + // Description (10 or 11 depending on recurrence) if m.detailFieldIndex == fieldPos { // Launch external editor for description m.detailDescEditing = true diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 419bc14..204d08f 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -85,6 +85,8 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleAddTask() case "t": return m.handleEditTags() + case "J": + return m.handleEditProject() case "c": return m.handleRandomTheme() case "C": @@ -387,6 +389,28 @@ func (m *Model) handleEditTags() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleEditProject() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + m.clearEditingModes() + m.projID = id + m.projEditing = true + + // Get current project value + task := m.getTaskAtCursor() + if task != nil { + m.projInput.SetValue(task.Project) + } else { + m.projInput.SetValue("") + } + m.projInput.Focus() + m.updateTableHeight() + return m, nil +} + func (m *Model) handleRandomTheme() (tea.Model, tea.Cmd) { m.theme = RandomTheme() m.applyTheme() @@ -539,7 +563,19 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil - case 3: // Due date + case 3: // Project + m.clearEditingModes() + m.projID = id + m.projEditing = true + task := m.getTaskAtCursor() + if task != nil { + m.projInput.SetValue(task.Project) + } + m.projInput.Focus() + m.updateTableHeight() + return m, nil + + case 4: // Due date m.dueID = id task := m.getTaskAtCursor() if task != nil && task.Due != "" { @@ -556,7 +592,7 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil - case 4: // Recurrence + case 5: // Recurrence m.clearEditingModes() m.recurID = id m.recurEditing = true @@ -568,7 +604,7 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil - case 5: // Tags + case 6: // Tags m.clearEditingModes() m.tagsID = id m.tagsEditing = true @@ -577,7 +613,7 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil - case 6: // Annotations + case 7: // Annotations m.clearEditingModes() m.annotateID = id m.annotating = true @@ -596,7 +632,7 @@ func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { m.updateTableHeight() return m, nil - case 7: // Description + case 8: // Description m.clearEditingModes() m.descID = id m.descEditing = true diff --git a/internal/ui/table.go b/internal/ui/table.go index a430dcf..9def986 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -63,6 +63,10 @@ type Model struct { recurID int recurInput textinput.Model + projEditing bool + projID int + projInput textinput.Model + filterEditing bool filterInput textinput.Model @@ -113,6 +117,7 @@ type Model struct { tagsWidth int descWidth int annWidth int + projWidth int total int inProgress int @@ -215,6 +220,7 @@ func (m *Model) clearEditingModes() { m.tagsEditing = false m.dueEditing = false m.recurEditing = false + m.projEditing = false m.filterEditing = false m.addingTask = false m.searching = false @@ -270,6 +276,8 @@ func New(filters []string, browserCmd string) (Model, error) { m.tagsInput.Prompt = "tags: " m.recurInput = textinput.New() m.recurInput.Prompt = "recur: " + m.projInput = textinput.New() + m.projInput.Prompt = "project: " m.dueDate = time.Now() m.searchInput = textinput.New() m.searchInput.Prompt = "search: " @@ -296,6 +304,7 @@ func (m *Model) newTable(rows []atable.Row) (atable.Model, atable.Styles) { {Title: "Pri", Width: m.priWidth}, {Title: "ID", Width: m.idWidth}, {Title: "Age", Width: m.ageWidth}, + {Title: "Project", Width: m.projWidth}, {Title: "Due", Width: m.dueWidth}, {Title: "Recur", Width: m.recurWidth}, {Title: "Tags", Width: m.tagsWidth}, @@ -346,16 +355,19 @@ func (m *Model) reload() error { for i, tsk := range tasks { rows = append(rows, m.taskToRowSearch(tsk, m.searchRegex, m.tblStyles, -1)) if m.searchRegex != nil { + if m.searchRegex.MatchString(tsk.Project) { + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 3}) + } tags := strings.Join(tsk.Tags, " ") if m.searchRegex.MatchString(tags) { - m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 5}) + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 6}) } if m.searchRegex.MatchString(tsk.Description) { - m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 7}) + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 8}) } for _, a := range tsk.Annotations { if m.searchRegex.MatchString(a.Description) { - m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 6}) + m.searchMatches = append(m.searchMatches, cellMatch{row: i, col: 7}) break } } @@ -616,6 +628,12 @@ func (m Model) View() string { m.recurInput.View(), ) } + if m.projEditing { + view = lipgloss.JoinVertical(lipgloss.Left, + view, + m.projInput.View(), + ) + } if m.filterEditing { view = lipgloss.JoinVertical(lipgloss.Left, view, @@ -688,6 +706,7 @@ func (m Model) buildHelpContent() string { m.formatHelpLine("r", "set random due date", keyStyle, descStyle), m.formatHelpLine("R", "edit recurrence", keyStyle, descStyle), m.formatHelpLine("t", "edit tags", keyStyle, descStyle), + m.formatHelpLine("J", "edit project", keyStyle, descStyle), m.formatHelpLine("a, A", "add/replace annotations", keyStyle, descStyle), m.formatHelpLine("o", "open URL from description", keyStyle, descStyle), "") @@ -880,6 +899,7 @@ func (m Model) taskToRow(t task.Task) atable.Row { m.formatPriority(t.Priority, m.priWidth), style.Render(strconv.Itoa(t.ID)), style.Render(age), + style.Render(t.Project), m.formatDue(t.Due, m.dueWidth), style.Render(recur), style.Render(tags), @@ -1034,22 +1054,24 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty priStr := m.formatPriority(t.Priority, m.priWidth) idStr := getStyle(1).Render(strconv.Itoa(t.ID)) ageStr := getStyle(2).Render(age) + projStr := m.highlightCell(getStyle(3), re, t.Project) dueStr := m.formatDue(t.Due, m.dueWidth) - recurStr := m.highlightCell(getStyle(4), re, recur) - tagStr := m.highlightCell(getStyle(5), re, tags) + recurStr := m.highlightCell(getStyle(5), re, recur) + tagStr := m.highlightCell(getStyle(6), re, tags) annRaw := strings.Join(anns, "; ") 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(m.formatUrgency(urg, m.urgWidth)) + annStr := m.highlightCellMatch(getStyle(7), re, annRaw, annCount) + descStr := m.highlightCell(getStyle(8), re, t.Description) + urgStr := getStyle(9).Render(m.formatUrgency(urg, m.urgWidth)) return atable.Row{ priStr, idStr, ageStr, + projStr, dueStr, recurStr, tagStr, @@ -1062,7 +1084,7 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty func (m Model) expandedCellView() string { row := m.tbl.Cursor() col := m.tbl.ColumnCursor() - if row < 0 || row >= len(m.tasks) || col < 0 || col > 8 { + if row < 0 || row >= len(m.tasks) || col < 0 || col > 9 { return "" } t := m.tasks[row] @@ -1078,20 +1100,22 @@ func (m Model) expandedCellView() string { val = fmt.Sprintf("%dd", days) } case 3: - val = ansi.Strip(m.formatDue(t.Due, m.dueWidth)) + val = t.Project case 4: - val = t.Recur + val = ansi.Strip(m.formatDue(t.Due, m.dueWidth)) case 5: - val = strings.Join(t.Tags, " ") + val = t.Recur case 6: + val = strings.Join(t.Tags, " ") + 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 = t.Description + case 9: val = fmt.Sprintf("%.1f", t.Urgency) } header := "" @@ -1139,7 +1163,7 @@ func (m *Model) updateTableHeight() { if m.cellExpanded { h-- } - if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.filterEditing || m.addingTask { + if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.projEditing || m.filterEditing || m.addingTask { h-- } if h < 1 { @@ -1177,6 +1201,7 @@ func (m *Model) computeColumnWidths() { maxRecur := 1 maxTags := 0 maxAnn := 1 + maxProj := 1 for _, t := range m.tasks { if l := len(strconv.Itoa(t.ID)); l > maxID { maxID = l @@ -1199,6 +1224,9 @@ func (m *Model) computeColumnWidths() { if l := len(t.Recur); l > maxRecur { maxRecur = l } + if l := len(t.Project); l > maxProj { + maxProj = l + } tags := strings.Join(t.Tags, " ") if l := len(tags); l > maxTags { maxTags = l @@ -1217,13 +1245,14 @@ func (m *Model) computeColumnWidths() { m.recurWidth = maxRecur m.tagsWidth = maxTags m.annWidth = maxAnn + m.projWidth = maxProj total := m.tbl.Width() if total == 0 { total = 80 } - base := m.idWidth + m.priWidth + m.ageWidth + m.dueWidth + m.recurWidth + m.tagsWidth + m.annWidth + m.urgWidth - base += 8 // spaces between columns + base := m.idWidth + m.priWidth + m.ageWidth + m.dueWidth + m.recurWidth + m.tagsWidth + m.annWidth + m.urgWidth + m.projWidth + base += 9 // spaces between columns m.descWidth = total - base if m.descWidth < 1 { m.descWidth = 1 @@ -1239,6 +1268,7 @@ func (m *Model) applyColumns() { {Title: "Pri", Width: m.priWidth}, {Title: "ID", Width: m.idWidth}, {Title: "Age", Width: m.ageWidth}, + {Title: "Project", Width: m.projWidth}, {Title: "Due", Width: m.dueWidth}, {Title: "Recur", Width: m.recurWidth}, {Title: "Tags", Width: m.tagsWidth}, diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go index 70c06a0..c7a5b89 100644 --- a/internal/ui/taskdetail.go +++ b/internal/ui/taskdetail.go @@ -17,6 +17,7 @@ const ( fieldTags fieldDue fieldStart + fieldProject fieldEntry fieldRecur fieldDescription @@ -119,6 +120,24 @@ func (m *Model) renderTaskDetail() string { currentField++ lines = append(lines, m.renderTaskFieldWithIndex("Start", m.formatTaskDate(t.Start), labelStyle, valueStyle, currentField)) currentField++ + + // Project + if m.projEditing && m.projID == t.ID { + // Show project editing UI without prompt + originalPrompt := m.projInput.Prompt + m.projInput.Prompt = "" + projView := m.projInput.View() + m.projInput.Prompt = originalPrompt + lines = append(lines, m.renderEditingField("Project", projView, labelStyle, currentField)) + } else { + projectValue := t.Project + if projectValue == "" { + projectValue = "-" + } + lines = append(lines, m.renderTaskFieldWithIndex("Project", projectValue, labelStyle, valueStyle, currentField)) + } + currentField++ + lines = append(lines, m.renderTaskFieldWithIndex("Entry", m.formatTaskDate(t.Entry), labelStyle, valueStyle, currentField)) currentField++ |
