diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-28 10:13:52 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-28 12:50:12 +0200 |
| commit | afbdcef805dc572ee2f3a79a52fde99818715bd4 (patch) | |
| tree | cf45d6b721736399602e20923035d63d84b86287 | |
| parent | 72b5a6f7b2100d228a0f16171c6c08b8f311f0d4 (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.go | 247 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 128 | ||||
| -rw-r--r-- | internal/ui/table.go | 94 | ||||
| -rw-r--r-- | internal/ui/taskdetail.go | 405 |
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 { |
