summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-28 10:13:52 +0200
committerPaul Buetow <paul@buetow.org>2026-02-28 12:50:12 +0200
commitafbdcef805dc572ee2f3a79a52fde99818715bd4 (patch)
treecf45d6b721736399602e20923035d63d84b86287
parent72b5a6f7b2100d228a0f16171c6c08b8f311f0d4 (diff)
refactor(ui): break up large functions in ui package
Go/MEDIUM task (UUID d8b51046): several functions exceeded 50 lines. Extract logical sections into focused helpers: renderTaskDetail() (238→20 lines): delegates to detailStyles(), renderDetailFieldRows() + per-field helpers (Priority, Tags, Due, Project, Recur), renderDetailDescription(), renderDetailAnnotations(), renderDetailFooter(). handleDetailFieldEdit() (128→32 lines): uses switch + shared activation helpers (activatePriorityEdit, activateDueEdit, activateTagsEdit, activateProjectEdit, activateRecurEdit, handleDetailDynamicFields). handleEnterOrEdit() (120→42 lines): reuses the same shared helpers from handlers.go; adds a taskStr closure to remove repetitive nil-guards. View() (82→25 lines): extracts appendInlineInputOverlay() that iterates the active editing overlays and appends the focused widget to the layout. Also fix hardcoded Description blink index (was always 9); introduce detailDescriptionFieldIndex() which returns 9 or 10 depending on whether the task has a Recurrence field, matching the dynamic render position. Clarify that Annotations are read-only in the detail view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/ui/handlers.go247
-rw-r--r--internal/ui/keyhandlers.go128
-rw-r--r--internal/ui/table.go94
-rw-r--r--internal/ui/taskdetail.go405
4 files changed, 407 insertions, 467 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 1bc65c0..d447c31 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -611,133 +611,160 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
-// handleDetailFieldEdit starts editing for the current field in detail view
+// handleDetailFieldEdit starts editing for the currently-selected field in the
+// detail view. Fields 0-2 (ID, UUID, Status) and 6, 8 (Start, Entry) are
+// read-only; all others delegate to the appropriate activation helper.
func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) {
if m.currentTaskDetail == nil {
return m, nil
}
-
- id := m.currentTaskDetail.ID
-
- // Map detail field index to editable fields
- // 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
-
- // ID, UUID, Status (0-2)
- if m.detailFieldIndex <= 2 {
- return m, nil // Not editable
- }
- fieldPos = 3
-
- // Priority (3)
- if m.detailFieldIndex == fieldPos {
- m.clearEditingModes()
- m.priorityID = id
- m.prioritySelecting = true
-
- // Set current priority index
- switch m.currentTaskDetail.Priority {
- case "H":
- m.priorityIndex = 0
- case "M":
- m.priorityIndex = 1
- case "L":
- m.priorityIndex = 2
- default:
- m.priorityIndex = 3
- }
- m.updateTableHeight()
+ t := m.currentTaskDetail
+ id := t.ID
+
+ // Fixed-position fields (indices always match the fieldXxx constants).
+ switch m.detailFieldIndex {
+ case fieldID, fieldUUID, fieldStatus, fieldStart, fieldEntry:
+ return m, nil // read-only fields
+ case fieldPriority:
+ m.activatePriorityEdit(id, t.Priority)
return m, nil
- }
- fieldPos++
-
- // Tags (4)
- if m.detailFieldIndex == fieldPos {
- m.clearEditingModes()
- m.tagsID = id
- m.tagsEditing = true
- m.tagsInput.SetValue("")
- m.tagsInput.Focus()
- m.updateTableHeight()
+ case fieldTags:
+ m.activateTagsEdit(id)
return m, nil
- }
- fieldPos++
-
- // Due (5)
- if m.detailFieldIndex == fieldPos {
- m.dueID = id
- if m.currentTaskDetail.Due != "" {
- if ts, err := parseTaskDate(m.currentTaskDetail.Due); err == nil {
- m.dueDate = ts
- } else {
- m.dueDate = time.Now()
- }
- } else {
- m.dueDate = time.Now()
- }
- m.clearEditingModes()
- m.dueEditing = true
- m.updateTableHeight()
+ case fieldDue:
+ m.activateDueEdit(id, t.Due)
return m, nil
- }
- fieldPos++
-
- // Start (6)
- if m.detailFieldIndex == fieldPos {
- // Start date is not editable in the original code, only toggled via 's' key
+ case fieldProject:
+ m.activateProjectEdit(id, t.Project)
return m, nil
}
- fieldPos++
-
- // 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 (9) - only if it exists
- if m.currentTaskDetail.Recur != "" {
+
+ // Recurrence and Description occupy dynamic positions: recur is present
+ // only when t.Recur != "", shifting description one slot later.
+ return m.handleDetailDynamicFields(id, t)
+}
+
+// handleDetailDynamicFields handles editing activation for the task fields
+// whose index depends on whether the optional Recur field is present.
+func (m *Model) handleDetailDynamicFields(id int, t *task.Task) (tea.Model, tea.Cmd) {
+ // fieldEntry is 8; the next slot is 9, which holds Recur when present.
+ fieldPos := fieldEntry + 1
+ if t.Recur != "" {
if m.detailFieldIndex == fieldPos {
- m.clearEditingModes()
- m.recurID = id
- m.recurEditing = true
- m.recurInput.SetValue(m.currentTaskDetail.Recur)
- m.recurInput.Focus()
- m.updateTableHeight()
+ m.activateRecurEdit(id, t.Recur)
return m, nil
}
fieldPos++
}
-
- // Description (10 or 11 depending on recurrence)
if m.detailFieldIndex == fieldPos {
- // Launch external editor for description
+ // Launch external editor for description editing.
m.detailDescEditing = true
- desc := ""
- if m.currentTaskDetail != nil {
- desc = m.currentTaskDetail.Description
+ return m, editDescriptionCmd(t.Description)
+ }
+ // Annotations are read-only in the detail view. They can be edited via
+ // the table view's Annotations column (activateAnnotationsEdit).
+ return m, nil
+}
+
+// activatePriorityEdit enables the priority-selector for task id,
+// pre-selecting the option that matches currentPriority.
+func (m *Model) activatePriorityEdit(id int, currentPriority string) {
+ m.clearEditingModes()
+ m.priorityID = id
+ m.prioritySelecting = true
+ switch currentPriority {
+ case "H":
+ m.priorityIndex = 0
+ case "M":
+ m.priorityIndex = 1
+ case "L":
+ m.priorityIndex = 2
+ default:
+ m.priorityIndex = 3
+ }
+ m.updateTableHeight()
+}
+
+// activateDueEdit enables due-date editing for task id, initialising the
+// date picker from currentDue (falls back to now if empty or unparseable).
+func (m *Model) activateDueEdit(id int, currentDue string) {
+ m.dueID = id
+ if currentDue != "" {
+ if ts, err := parseTaskDate(currentDue); err == nil {
+ m.dueDate = ts
+ } else {
+ m.dueDate = time.Now()
}
- return m, editDescriptionCmd(desc)
+ } else {
+ m.dueDate = time.Now()
}
-
- // Annotations are not editable in detail view
+ m.clearEditingModes()
+ m.dueEditing = true
+ m.updateTableHeight()
+}
+
+// activateTagsEdit enables tags editing for task id with an empty input.
+func (m *Model) activateTagsEdit(id int) {
+ m.clearEditingModes()
+ m.tagsID = id
+ m.tagsEditing = true
+ m.tagsInput.SetValue("")
+ m.tagsInput.Focus()
+ m.updateTableHeight()
+}
+
+// activateProjectEdit enables project editing for task id,
+// pre-filling the input with currentProject.
+func (m *Model) activateProjectEdit(id int, currentProject string) {
+ m.clearEditingModes()
+ m.projID = id
+ m.projEditing = true
+ m.projInput.SetValue(currentProject)
+ m.projInput.Focus()
+ m.updateTableHeight()
+}
+
+// activateRecurEdit enables recurrence editing for task id,
+// pre-filling the input with currentRecur.
+func (m *Model) activateRecurEdit(id int, currentRecur string) {
+ m.clearEditingModes()
+ m.recurID = id
+ m.recurEditing = true
+ m.recurInput.SetValue(currentRecur)
+ m.recurInput.Focus()
+ m.updateTableHeight()
+}
+
+// activateAnnotationsEdit enables annotation editing for task id.
+// The current annotations are joined with "; " and pre-filled in the input
+// so the user can revise all annotations in one pass.
+func (m *Model) activateAnnotationsEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) {
+ m.clearEditingModes()
+ m.annotateID = id
+ m.annotating = true
+ m.replaceAnnotations = true
+ if tsk != nil {
+ var anns []string
+ for _, a := range tsk.Annotations {
+ anns = append(anns, a.Description)
+ }
+ m.annotateInput.SetValue(strings.Join(anns, "; "))
+ }
+ m.annotateInput.Focus()
+ m.updateTableHeight()
+ return m, nil
+}
+
+// activateDescriptionEdit enables inline description editing for task id,
+// pre-filling the input with the current description.
+func (m *Model) activateDescriptionEdit(id int, tsk *task.Task) (tea.Model, tea.Cmd) {
+ m.clearEditingModes()
+ m.descID = id
+ m.descEditing = true
+ if tsk != nil {
+ m.descInput.SetValue(tsk.Description)
+ }
+ m.descInput.Focus()
+ m.updateTableHeight()
return m, nil
} \ No newline at end of file
diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go
index 4262b22..3db0725 100644
--- a/internal/ui/keyhandlers.go
+++ b/internal/ui/keyhandlers.go
@@ -615,126 +615,50 @@ func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) {
return m, nil
}
+// handleEnterOrEdit dispatches to the appropriate inline editor based on the
+// column the cursor is on. Shared activation helpers (activatePriorityEdit,
+// activateDueEdit, etc.) are defined in handlers.go to avoid duplication with
+// the detail-view editing path.
func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) {
id, err := m.getSelectedTaskID()
if err != nil {
- // No task selected, toggle cell expansion
+ // No task selected — toggle expanded-cell panel instead.
m.cellExpanded = !m.cellExpanded
m.updateTableHeight()
return m, nil
}
-
- col := m.tbl.ColumnCursor()
- switch col {
- case 0: // Priority
- m.clearEditingModes()
- m.priorityID = id
- m.prioritySelecting = true
-
- // Set current priority index
- task := m.getTaskAtCursor()
- if task != nil {
- switch task.Priority {
- case "H":
- m.priorityIndex = 0
- case "M":
- m.priorityIndex = 1
- case "L":
- m.priorityIndex = 2
- default:
- m.priorityIndex = 3
- }
+
+ tsk := m.getTaskAtCursor()
+ // taskStr extracts a string field from the cursor task, returning ""
+ // when no task is selected so activation helpers get a safe zero value.
+ taskStr := func(get func(*task.Task) string) string {
+ if tsk == nil {
+ return ""
}
- m.updateTableHeight()
- return m, nil
-
+ return get(tsk)
+ }
+
+ switch m.tbl.ColumnCursor() {
+ case 0: // Priority
+ m.activatePriorityEdit(id, taskStr(func(t *task.Task) string { return t.Priority }))
case 3: // Due date
- m.dueID = id
- task := m.getTaskAtCursor()
- if task != nil && task.Due != "" {
- if ts, err := parseTaskDate(task.Due); err == nil {
- m.dueDate = ts
- } else {
- m.dueDate = time.Now()
- }
- } else {
- m.dueDate = time.Now()
- }
- m.clearEditingModes()
- m.dueEditing = true
- m.updateTableHeight()
- return m, nil
-
+ m.activateDueEdit(id, taskStr(func(t *task.Task) string { return t.Due }))
case 4: // Recurrence
- m.clearEditingModes()
- m.recurID = id
- m.recurEditing = true
- task := m.getTaskAtCursor()
- if task != nil {
- m.recurInput.SetValue(task.Recur)
- }
- m.recurInput.Focus()
- m.updateTableHeight()
- return m, nil
-
+ m.activateRecurEdit(id, taskStr(func(t *task.Task) string { return t.Recur }))
case 5: // 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
-
+ m.activateProjectEdit(id, taskStr(func(t *task.Task) string { return t.Project }))
case 6: // Tags
- m.clearEditingModes()
- m.tagsID = id
- m.tagsEditing = true
- m.tagsInput.SetValue("")
- m.tagsInput.Focus()
- m.updateTableHeight()
- return m, nil
-
+ m.activateTagsEdit(id)
case 7: // Annotations
- m.clearEditingModes()
- m.annotateID = id
- m.annotating = true
- m.replaceAnnotations = true
-
- // Get current annotations
- task := m.getTaskAtCursor()
- if task != nil {
- var anns []string
- for _, a := range task.Annotations {
- anns = append(anns, a.Description)
- }
- m.annotateInput.SetValue(strings.Join(anns, "; "))
- }
- m.annotateInput.Focus()
- m.updateTableHeight()
- return m, nil
-
+ return m.activateAnnotationsEdit(id, tsk)
case 8: // Description
- m.clearEditingModes()
- m.descID = id
- m.descEditing = true
- task := m.getTaskAtCursor()
- if task != nil {
- m.descInput.SetValue(task.Description)
- }
- m.descInput.Focus()
- m.updateTableHeight()
- return m, nil
-
+ return m.activateDescriptionEdit(id, tsk)
default:
- // Toggle cell expansion for other columns
+ // Other columns: toggle expanded-cell panel.
m.cellExpanded = !m.cellExpanded
m.updateTableHeight()
- return m, nil
}
+ return m, nil
}
func (m *Model) handleTableNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 97011ff..893f08f 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -558,7 +558,7 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) {
// Reload and start blinking
m.reload()
- return m, m.startDetailBlink(9) // Description field index
+ return m, m.startDetailBlink(m.detailDescriptionFieldIndex())
}
return m, nil
@@ -618,7 +618,6 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) {
// View renders the table UI.
func (m Model) View() string {
if m.showHelp {
- // Update help content before rendering
m.updateHelpContent()
return m.renderHelpScreen()
}
@@ -634,70 +633,35 @@ func (m Model) View() string {
m.statusLine(),
)
if m.cellExpanded {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.expandedCellView(),
- )
- }
- if m.annotating {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.annotateInput.View(),
- )
- }
- if m.dueEditing {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.dueView(true),
- )
- }
- if m.prioritySelecting {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.priorityView(true),
- )
- }
- if m.descEditing {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.descInput.View(),
- )
+ view = lipgloss.JoinVertical(lipgloss.Left, view, m.expandedCellView())
}
- 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.projEditing {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.projInput.View(),
- )
- }
- if m.filterEditing {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.filterInput.View(),
- )
- }
- if m.addingTask {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.addInput.View(),
- )
- }
- if m.searching {
- view = lipgloss.JoinVertical(lipgloss.Left,
- view,
- m.searchInput.View(),
- )
+ return m.appendInlineInputOverlay(view)
+}
+
+// appendInlineInputOverlay appends whichever active inline-editing widget
+// (annotate, due, priority, desc, tags, recur, project, filter, add, search)
+// should be displayed below the table. At most one is active at a time.
+func (m Model) appendInlineInputOverlay(view string) string {
+ type overlay struct {
+ active bool
+ widget string
+ }
+ overlays := []overlay{
+ {m.annotating, m.annotateInput.View()},
+ {m.dueEditing, m.dueView(true)},
+ {m.prioritySelecting, m.priorityView(true)},
+ {m.descEditing, m.descInput.View()},
+ {m.tagsEditing, m.tagsInput.View()},
+ {m.recurEditing, m.recurInput.View()},
+ {m.projEditing, m.projInput.View()},
+ {m.filterEditing, m.filterInput.View()},
+ {m.addingTask, m.addInput.View()},
+ {m.searching, m.searchInput.View()},
+ }
+ for _, o := range overlays {
+ if o.active {
+ view = lipgloss.JoinVertical(lipgloss.Left, view, o.widget)
+ }
}
return view
}
diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go
index 686ace4..1889129 100644
--- a/internal/ui/taskdetail.go
+++ b/internal/ui/taskdetail.go
@@ -55,245 +55,259 @@ const (
fieldCount // Total number of fields
)
-// renderTaskDetail renders the detailed view of a single task
+// renderTaskDetail renders the detailed view of a single task.
+// It delegates each visual section to a focused helper so that the
+// overall structure is easy to follow at a glance.
func (m *Model) renderTaskDetail() string {
if m.currentTaskDetail == nil {
return "No task selected"
}
-
t := m.currentTaskDetail
- // Create styles based on theme
- titleStyle := lipgloss.NewStyle().
+ titleStyle, labelStyle, valueStyle, descStyle := m.detailStyles()
+
+ var lines []string
+ lines = append(lines, titleStyle.Render(fmt.Sprintf("Task %d Details", t.ID)))
+ lines = append(lines, "")
+ lines, nextField := m.renderDetailFieldRows(lines, labelStyle, valueStyle)
+ lines = m.renderDetailDescription(lines, nextField, labelStyle, descStyle)
+ nextField++
+ lines = m.renderDetailAnnotations(lines, nextField, labelStyle, descStyle)
+ lines = m.renderDetailFooter(lines)
+ return strings.Join(lines, "\n")
+}
+
+// detailStyles returns the four lipgloss styles shared by the detail-view helpers.
+func (m *Model) detailStyles() (title, label, value, desc lipgloss.Style) {
+ title = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(m.theme.SelectedFG)).
Background(lipgloss.Color(m.theme.SelectedBG)).
Padding(0, 1)
-
- labelStyle := lipgloss.NewStyle().
+ label = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(m.theme.HeaderFG))
-
- valueStyle := lipgloss.NewStyle().
+ value = lipgloss.NewStyle().
Foreground(lipgloss.Color("252"))
-
- descStyle := lipgloss.NewStyle().
+ desc = lipgloss.NewStyle().
Foreground(lipgloss.Color("250")).
PaddingLeft(2)
+ return
+}
- // Build the detail view
- var lines []string
-
- // Title bar
- title := fmt.Sprintf("Task %d Details", t.ID)
- lines = append(lines, titleStyle.Render(title))
- lines = append(lines, "")
-
- // Task fields
- currentField := 0
- lines = append(lines, m.renderTaskFieldWithIndex("ID", fmt.Sprintf("%d", t.ID), labelStyle, valueStyle, currentField))
- currentField++
- lines = append(lines, m.renderTaskFieldWithIndex("UUID", t.UUID, labelStyle, valueStyle, currentField))
- currentField++
- lines = append(lines, m.renderTaskFieldWithIndex("Status", t.Status, labelStyle, valueStyle, currentField))
- currentField++
+// renderDetailFieldRows appends the fixed and optional task fields (ID through
+// optional Recurrence) to lines and returns the updated slice together with the
+// field index of the next unrendered field (Description).
+func (m *Model) renderDetailFieldRows(lines []string, labelStyle, valueStyle lipgloss.Style) ([]string, int) {
+ t := m.currentTaskDetail
+ cf := 0 // current field counter
+
+ lines = append(lines, m.renderTaskFieldWithIndex("ID", fmt.Sprintf("%d", t.ID), labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderTaskFieldWithIndex("UUID", t.UUID, labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderTaskFieldWithIndex("Status", t.Status, labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderDetailPriorityField(labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderDetailTagsField(labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderDetailDueField(labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderTaskFieldWithIndex("Start", m.formatTaskDate(t.Start), labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderDetailProjectField(labelStyle, valueStyle, cf))
+ cf++
+ lines = append(lines, m.renderTaskFieldWithIndex("Entry", m.formatTaskDate(t.Entry), labelStyle, valueStyle, cf))
+ cf++
+ if t.Recur != "" {
+ lines = append(lines, m.renderDetailRecurField(labelStyle, cf))
+ cf++
+ }
+ return lines, cf
+}
- // Priority with color
+// renderDetailPriorityField renders the Priority row, showing the selection
+// widget when the user is actively changing it.
+func (m *Model) renderDetailPriorityField(labelStyle, valueStyle lipgloss.Style, cf int) string {
+ t := m.currentTaskDetail
if m.prioritySelecting && m.priorityID == t.ID {
- // Show priority selection UI
- lines = append(lines, m.renderEditingField("Priority", m.priorityView(false), labelStyle, currentField))
- } else {
- priorityValue := t.Priority
- if priorityValue == "" {
- priorityValue = "-"
- }
- priorityStyle := valueStyle.Copy()
- switch t.Priority {
- case "H":
- priorityStyle = priorityStyle.Background(lipgloss.Color(m.theme.PrioHighBG))
- priorityValue = "H (High)"
- case "M":
- priorityStyle = priorityStyle.Background(lipgloss.Color(m.theme.PrioMedBG))
- priorityValue = "M (Medium)"
- case "L":
- priorityStyle = priorityStyle.Background(lipgloss.Color(m.theme.PrioLowBG))
- priorityValue = "L (Low)"
- }
- lines = append(lines, m.renderTaskFieldWithIndex("Priority", priorityValue, labelStyle, priorityStyle, currentField))
+ return m.renderEditingField("Priority", m.priorityView(false), labelStyle, cf)
+ }
+ pv := t.Priority
+ if pv == "" {
+ pv = "-"
+ }
+ ps := valueStyle.Copy()
+ switch t.Priority {
+ case "H":
+ ps = ps.Background(lipgloss.Color(m.theme.PrioHighBG))
+ pv = "H (High)"
+ case "M":
+ ps = ps.Background(lipgloss.Color(m.theme.PrioMedBG))
+ pv = "M (Medium)"
+ case "L":
+ ps = ps.Background(lipgloss.Color(m.theme.PrioLowBG))
+ pv = "L (Low)"
}
- currentField++
+ return m.renderTaskFieldWithIndex("Priority", pv, labelStyle, ps, cf)
+}
- // Tags
+// renderDetailTagsField renders the Tags row, showing the text input when
+// the user is actively editing it.
+func (m *Model) renderDetailTagsField(labelStyle, valueStyle lipgloss.Style, cf int) string {
+ t := m.currentTaskDetail
if m.tagsEditing && m.tagsID == t.ID {
- // Show tags editing UI without prompt
- originalPrompt := m.tagsInput.Prompt
+ orig := m.tagsInput.Prompt
m.tagsInput.Prompt = ""
- tagsView := m.tagsInput.View()
- m.tagsInput.Prompt = originalPrompt
- lines = append(lines, m.renderEditingField("Tags", tagsView, labelStyle, currentField))
- } else {
- tagStr := strings.Join(t.Tags, ", ")
- if tagStr == "" {
- tagStr = "-"
- }
- lines = append(lines, m.renderTaskFieldWithIndex("Tags", tagStr, labelStyle, valueStyle, currentField))
+ v := m.tagsInput.View()
+ m.tagsInput.Prompt = orig
+ return m.renderEditingField("Tags", v, labelStyle, cf)
+ }
+ tagStr := strings.Join(t.Tags, ", ")
+ if tagStr == "" {
+ tagStr = "-"
}
- currentField++
+ return m.renderTaskFieldWithIndex("Tags", tagStr, labelStyle, valueStyle, cf)
+}
- // Dates
+// renderDetailDueField renders the Due row, showing the date picker when
+// the user is actively editing it.
+func (m *Model) renderDetailDueField(labelStyle, valueStyle lipgloss.Style, cf int) string {
+ t := m.currentTaskDetail
if m.dueEditing && m.dueID == t.ID {
- // Show due date editing UI
- lines = append(lines, m.renderEditingField("Due", m.dueView(false), labelStyle, currentField))
- } else {
- lines = append(lines, m.renderTaskFieldWithIndex("Due", m.formatTaskDate(t.Due), labelStyle, valueStyle, currentField))
+ return m.renderEditingField("Due", m.dueView(false), labelStyle, cf)
}
- currentField++
- lines = append(lines, m.renderTaskFieldWithIndex("Start", m.formatTaskDate(t.Start), labelStyle, valueStyle, currentField))
- currentField++
-
- // Project
+ return m.renderTaskFieldWithIndex("Due", m.formatTaskDate(t.Due), labelStyle, valueStyle, cf)
+}
+
+// renderDetailProjectField renders the Project row, showing the text input
+// when the user is actively editing it.
+func (m *Model) renderDetailProjectField(labelStyle, valueStyle lipgloss.Style, cf int) string {
+ t := m.currentTaskDetail
if m.projEditing && m.projID == t.ID {
- // Show project editing UI without prompt
- originalPrompt := m.projInput.Prompt
+ orig := 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))
+ v := m.projInput.View()
+ m.projInput.Prompt = orig
+ return m.renderEditingField("Project", v, labelStyle, cf)
}
- currentField++
-
- lines = append(lines, m.renderTaskFieldWithIndex("Entry", m.formatTaskDate(t.Entry), labelStyle, valueStyle, currentField))
- currentField++
+ pv := t.Project
+ if pv == "" {
+ pv = "-"
+ }
+ return m.renderTaskFieldWithIndex("Project", pv, labelStyle, valueStyle, cf)
+}
- // Recurrence
- if t.Recur != "" {
- if m.recurEditing && m.recurID == t.ID {
- // Show recurrence editing UI without prompt
- originalPrompt := m.recurInput.Prompt
- m.recurInput.Prompt = ""
- recurView := m.recurInput.View()
- m.recurInput.Prompt = originalPrompt
- lines = append(lines, m.renderEditingField("Recurrence", recurView, labelStyle, currentField))
- } else {
- lines = append(lines, m.renderTaskFieldWithIndex("Recurrence", t.Recur, labelStyle, valueStyle, currentField))
- }
- currentField++
+// renderDetailRecurField renders the Recurrence row, showing the text input
+// when the user is actively editing it.
+func (m *Model) renderDetailRecurField(labelStyle lipgloss.Style, cf int) string {
+ t := m.currentTaskDetail
+ if m.recurEditing && m.recurID == t.ID {
+ orig := m.recurInput.Prompt
+ m.recurInput.Prompt = ""
+ v := m.recurInput.View()
+ m.recurInput.Prompt = orig
+ return m.renderEditingField("Recurrence", v, labelStyle, cf)
}
+ return m.renderTaskFieldWithIndex("Recurrence", t.Recur, labelStyle, lipgloss.NewStyle(), cf)
+}
- // Description - with full space
+// renderDetailDescription appends the Description section (label + wrapped body)
+// to lines, applying selection/blink highlighting and search match colouring.
+func (m *Model) renderDetailDescription(lines []string, cf int, labelStyle, descStyle lipgloss.Style) []string {
+ t := m.currentTaskDetail
lines = append(lines, "")
- descLabelStyle := labelStyle.Copy()
- descValueStyle := descStyle.Copy()
- // Apply blinking if this field is blinking
- if m.detailBlinkField == currentField && m.detailBlinkOn {
- blinkBG := lipgloss.Color("226") // Bright yellow
- descLabelStyle = descLabelStyle.Background(blinkBG).Foreground(lipgloss.Color("0"))
- descValueStyle = descValueStyle.Background(blinkBG).Foreground(lipgloss.Color("0"))
- } else if m.detailFieldIndex == currentField {
- descLabelStyle = descLabelStyle.Background(lipgloss.Color(m.theme.SelectedBG))
- descValueStyle = descValueStyle.Background(lipgloss.Color(m.theme.SelectedBG))
+
+ ls, vs := labelStyle.Copy(), descStyle.Copy()
+ if m.detailBlinkField == cf && m.detailBlinkOn {
+ bg := lipgloss.Color("226")
+ ls = ls.Background(bg).Foreground(lipgloss.Color("0"))
+ vs = vs.Background(bg).Foreground(lipgloss.Color("0"))
+ } else if m.detailFieldIndex == cf {
+ ls = ls.Background(lipgloss.Color(m.theme.SelectedBG))
+ vs = vs.Background(lipgloss.Color(m.theme.SelectedBG))
}
- lines = append(lines, descLabelStyle.Render("Description:"))
+ lines = append(lines, ls.Render("Description:"))
+
if m.detailDescEditing {
- // Show editing indicator
- editingStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("226")).
- Italic(true)
- lines = append(lines, editingStyle.Render(" [Editing in external editor...]"))
- } else if t.Description != "" {
- // Calculate available width for description (terminal width - margins)
- availableWidth := m.tbl.Width() - 4
- if availableWidth < 20 {
- availableWidth = 20 // Minimum width
+ lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Italic(true).
+ Render(" [Editing in external editor...]"))
+ return lines
+ }
+ if t.Description == "" {
+ return append(lines, vs.Render("-"))
+ }
+ w := m.tbl.Width() - 4
+ if w < 20 {
+ w = 20
+ }
+ for _, l := range wordWrap(t.Description, w) {
+ d := l
+ if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(l) {
+ d = m.highlightMatches(l, m.detailSearchRegex)
}
-
- // Wrap the description text
- desc := t.Description
- wrappedLines := wordWrap(desc, availableWidth)
-
- // Apply search highlighting and render each wrapped line
- for _, line := range wrappedLines {
- displayLine := line
- if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(line) {
- displayLine = m.highlightMatches(line, m.detailSearchRegex)
+ lines = append(lines, vs.Render(d))
+ }
+ return lines
+}
+
+// renderDetailAnnotations appends the Annotations section to lines when the
+// task has annotations, applying selection highlighting and search colouring.
+func (m *Model) renderDetailAnnotations(lines []string, cf int, labelStyle, descStyle lipgloss.Style) []string {
+ t := m.currentTaskDetail
+ if len(t.Annotations) == 0 {
+ return lines
+ }
+ lines = append(lines, "")
+ ls, vs := labelStyle.Copy(), descStyle.Copy()
+ if m.detailFieldIndex == cf {
+ ls = ls.Background(lipgloss.Color(m.theme.SelectedBG))
+ vs = vs.Background(lipgloss.Color(m.theme.SelectedBG))
+ }
+ lines = append(lines, ls.Render("Annotations:"))
+ w := m.tbl.Width() - 4
+ if w < 20 {
+ w = 20
+ }
+ for _, ann := range t.Annotations {
+ text := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ann.Description)
+ for i, l := range wordWrap(text, w) {
+ d := l
+ if i > 0 {
+ d = " " + d
}
- lines = append(lines, descValueStyle.Render(displayLine))
- }
- } else {
- lines = append(lines, descValueStyle.Render("-"))
- }
- currentField++
-
- // Annotations
- if len(t.Annotations) > 0 {
- lines = append(lines, "")
- annLabelStyle := labelStyle.Copy()
- annValueStyle := descStyle.Copy()
- if m.detailFieldIndex == currentField {
- annLabelStyle = annLabelStyle.Background(lipgloss.Color(m.theme.SelectedBG))
- annValueStyle = annValueStyle.Background(lipgloss.Color(m.theme.SelectedBG))
- }
- lines = append(lines, annLabelStyle.Render("Annotations:"))
- // Calculate available width for annotations
- availableWidth := m.tbl.Width() - 4
- if availableWidth < 20 {
- availableWidth = 20 // Minimum width
- }
-
- for _, ann := range t.Annotations {
- annText := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ann.Description)
- wrappedAnnLines := wordWrap(annText, availableWidth)
-
- // Apply search highlighting and render each wrapped line
- for i, line := range wrappedAnnLines {
- displayLine := line
- if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(line) {
- displayLine = m.highlightMatches(line, m.detailSearchRegex)
- }
- // Add some indentation for continuation lines
- if i > 0 {
- displayLine = " " + displayLine
- }
- lines = append(lines, annValueStyle.Render(displayLine))
+ if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(l) {
+ d = m.highlightMatches(d, m.detailSearchRegex)
}
+ lines = append(lines, vs.Render(d))
}
}
+ return lines
+}
- // Instructions at bottom
- lines = append(lines, "")
- lines = append(lines, "")
- instructionStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("245")).
- Italic(true)
- // Check if we're in any editing mode
+// renderDetailFooter appends the instruction lines and optional search input
+// at the bottom of the detail view.
+func (m *Model) renderDetailFooter(lines []string) []string {
+ lines = append(lines, "", "")
+ ist := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true)
if m.prioritySelecting || m.tagsEditing || m.dueEditing || m.recurEditing || m.detailDescEditing {
- lines = append(lines, instructionStyle.Render("Editing mode - Follow on-screen prompts"))
+ lines = append(lines, ist.Render("Editing mode - Follow on-screen prompts"))
} else {
- lines = append(lines, instructionStyle.Render("Press ESC or q to return to table view"))
- lines = append(lines, instructionStyle.Render("Use ↑/k and ↓/j to navigate fields"))
- lines = append(lines, instructionStyle.Render("Press i or Enter to edit (Priority, Tags, Due, Recurrence, Description)"))
+ lines = append(lines, ist.Render("Press ESC or q to return to table view"))
+ lines = append(lines, ist.Render("Use ↑/k and ↓/j to navigate fields"))
+ lines = append(lines, ist.Render("Press i or Enter to edit (Priority, Tags, Due, Recurrence, Description)"))
if m.detailSearching {
- lines = append(lines, instructionStyle.Render("Type to search, Enter to confirm"))
+ lines = append(lines, ist.Render("Type to search, Enter to confirm"))
} else {
- lines = append(lines, instructionStyle.Render("Press / to search"))
+ lines = append(lines, ist.Render("Press / to search"))
}
}
-
- // Add search input if searching
if m.detailSearching {
- searchStyle := lipgloss.NewStyle().
- Foreground(lipgloss.Color("248")).
- PaddingTop(1)
- lines = append(lines, searchStyle.Render("Search: "+m.detailSearchInput.View()))
+ lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("248")).PaddingTop(1).
+ Render("Search: "+m.detailSearchInput.View()))
}
-
- return strings.Join(lines, "\n")
+ return lines
}
// renderEditingField renders a field that is currently being edited
@@ -362,6 +376,17 @@ func (m *Model) refreshCurrentTaskDetail() {
m.currentTaskDetail = nil
}
+// detailDescriptionFieldIndex returns the navigable field index for the
+// Description field. When the task has a non-empty Recur the Recurrence row
+// occupies index fieldRecur (9), pushing Description to index 10. Without
+// Recur, Description is at index 9.
+func (m *Model) detailDescriptionFieldIndex() int {
+ if m.currentTaskDetail != nil && m.currentTaskDetail.Recur != "" {
+ return fieldRecur + 1 // 10
+ }
+ return fieldRecur // 9
+}
+
// getDetailFieldCount returns the actual number of navigable fields for the current task
func (m *Model) getDetailFieldCount() int {
if m.currentTaskDetail == nil {