diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-28 10:54:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-28 10:54:37 +0300 |
| commit | 43a0dcbd6617ccf28abfbb50b98bf9fbf8383835 (patch) | |
| tree | ec7bfddbcfad075edf22f7c921e5518432e49109 | |
| parent | ea0fbdc5a168b22296588259c6e821dffcdf7d1a (diff) | |
feat: implement detailed task view with improved readability
- Add new task detail view accessible via Enter key
- Show all task fields in vertical layout with full descriptions
- Implement search functionality within task detail view
- Use lighter text colors for better readability (252 for values, 250 for descriptions)
- Add ESC/Q key handlers to return to table view
- Keep 'i' key for original expand/edit functionality
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | internal/ui/handlers.go | 54 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 36 | ||||
| -rw-r--r-- | internal/ui/table.go | 10 | ||||
| -rw-r--r-- | internal/ui/taskdetail.go | 196 | ||||
| -rwxr-xr-x | tasksamurai | bin | 5843389 -> 5895662 bytes |
5 files changed, 295 insertions, 1 deletions
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index c02e92b..dce6bba 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -438,6 +438,9 @@ func (m *Model) handleBlinkingState(msg tea.Msg) (tea.Model, tea.Cmd) { // handleEditingModes checks if we're in any editing mode and handles it func (m *Model) handleEditingModes(msg tea.KeyMsg) (handled bool, model tea.Model, cmd tea.Cmd) { switch { + case m.showTaskDetail: + model, cmd = m.handleTaskDetailMode(msg) + return true, model, cmd case m.annotating: model, cmd = m.handleAnnotationMode(msg) return true, model, cmd @@ -489,4 +492,55 @@ func (m *Model) getTaskAtCursor() *task.Task { return nil } return &m.tasks[cursor] +} + +// handleTaskDetailMode handles keyboard input in task detail view +func (m *Model) handleTaskDetailMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.detailSearching { + var cmd tea.Cmd + switch msg.Type { + case tea.KeyEnter: + pattern := m.detailSearchInput.Value() + if pattern != "" { + re, err := compileAndCacheRegex(pattern) + if err == nil { + m.detailSearchRegex = re + } else { + m.detailSearchRegex = nil + m.statusMsg = fmt.Sprintf("Invalid regex: %v", err) + } + } else { + m.detailSearchRegex = nil + } + m.detailSearching = false + m.detailSearchInput.Blur() + return m, nil + case tea.KeyEsc, tea.KeyCtrlC: + m.detailSearching = false + m.detailSearchInput.Blur() + return m, nil + default: + m.detailSearchInput, cmd = m.detailSearchInput.Update(msg) + return m, cmd + } + } + + // Normal task detail view mode + switch msg.String() { + case "q", "esc": + return m.handleQuitOrEscape() + case "/", "?": + m.detailSearching = true + m.detailSearchInput.SetValue("") + m.detailSearchInput.Focus() + return m, nil + case "n": + // Next search match - not implemented yet but could be added + return m, nil + case "N": + // Previous search match - not implemented yet but could be added + return m, nil + } + + return m, nil }
\ No newline at end of file diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index d5d0f94..fe8c91e 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "codeberg.org/snonux/tasksamurai/internal/task" @@ -79,7 +80,9 @@ func (m *Model) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleNextSearchMatch() case "N": return m.handlePrevSearchMatch() - case "enter", "i": + case "enter": + return m.handleShowTaskDetail() + case "i": return m.handleEnterOrEdit() default: // Pass through to table for navigation @@ -93,6 +96,14 @@ func (m *Model) handleToggleHelp() (tea.Model, tea.Cmd) { } func (m *Model) handleQuitOrEscape() (tea.Model, tea.Cmd) { + if m.showTaskDetail { + m.showTaskDetail = false + m.currentTaskDetail = nil + m.detailSearching = false + m.detailSearchRegex = nil + m.detailSearchInput.SetValue("") + return m, nil + } if m.cellExpanded { m.cellExpanded = false m.updateTableHeight() @@ -434,6 +445,29 @@ func (m *Model) handlePrevHelpSearchMatch() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { + id, err := m.getSelectedTaskID() + if err != nil { + return m, nil + } + + // Find the task with this ID + for i := range m.tasks { + if m.tasks[i].ID == id { + m.showTaskDetail = true + m.currentTaskDetail = &m.tasks[i] + m.detailSearching = false + m.detailSearchRegex = nil + m.detailSearchInput = textinput.New() + m.detailSearchInput.Placeholder = "Search..." + m.detailSearchInput.Width = 30 + break + } + } + + return m, nil +} + func (m *Model) handleEnterOrEdit() (tea.Model, tea.Cmd) { id, err := m.getSelectedTaskID() if err != nil { diff --git a/internal/ui/table.go b/internal/ui/table.go index 7a1e179..b863c30 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -120,6 +120,13 @@ type Model struct { disco bool // disco mode changes theme on every task modification statusMsg string // temporary status message shown in status bar + + // Task detail view fields + showTaskDetail bool + currentTaskDetail *task.Task + detailSearching bool + detailSearchInput textinput.Model + detailSearchRegex *regexp.Regexp } // editDoneMsg is emitted when the external editor process finishes. @@ -395,6 +402,9 @@ func (m Model) View() string { if m.showHelp { return m.renderHelpScreen() } + if m.showTaskDetail { + return m.renderTaskDetail() + } view := lipgloss.JoinVertical(lipgloss.Left, m.topStatusLine(), m.tbl.View(), diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go new file mode 100644 index 0000000..f332c8d --- /dev/null +++ b/internal/ui/taskdetail.go @@ -0,0 +1,196 @@ +package ui + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" + +) + +// renderTaskDetail renders the detailed view of a single task +func (m *Model) renderTaskDetail() string { + if m.currentTaskDetail == nil { + return "No task selected" + } + + t := m.currentTaskDetail + + // Create styles based on theme + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(m.theme.SelectedFG)). + Background(lipgloss.Color(m.theme.SelectedBG)). + Padding(0, 1) + + labelStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(m.theme.HeaderFG)) + + valueStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + descStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("250")). + PaddingLeft(2) + + // 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 + lines = append(lines, m.renderTaskField("ID", fmt.Sprintf("%d", t.ID), labelStyle, valueStyle)) + lines = append(lines, m.renderTaskField("UUID", t.UUID, labelStyle, valueStyle)) + lines = append(lines, m.renderTaskField("Status", t.Status, labelStyle, valueStyle)) + + // Priority with color + 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.renderTaskField("Priority", priorityValue, labelStyle, priorityStyle)) + + // Tags + tagStr := strings.Join(t.Tags, ", ") + if tagStr == "" { + tagStr = "-" + } + lines = append(lines, m.renderTaskField("Tags", tagStr, labelStyle, valueStyle)) + + // Dates + lines = append(lines, m.renderTaskField("Due", m.formatTaskDate(t.Due), labelStyle, valueStyle)) + lines = append(lines, m.renderTaskField("Start", m.formatTaskDate(t.Start), labelStyle, valueStyle)) + // End field doesn't exist in Task struct, removed + lines = append(lines, m.renderTaskField("Entry", m.formatTaskDate(t.Entry), labelStyle, valueStyle)) + // Modified field doesn't exist in Task struct, removed + + // Recurrence + if t.Recur != "" { + lines = append(lines, m.renderTaskField("Recurrence", t.Recur, labelStyle, valueStyle)) + } + + // Description - with full space + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Description:")) + if t.Description != "" { + // Highlight search matches if searching + desc := t.Description + if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(desc) { + desc = m.highlightMatches(desc, m.detailSearchRegex) + } + lines = append(lines, descStyle.Render(desc)) + } else { + lines = append(lines, descStyle.Render("-")) + } + + // Annotations + if len(t.Annotations) > 0 { + lines = append(lines, "") + lines = append(lines, labelStyle.Render("Annotations:")) + for _, ann := range t.Annotations { + annText := fmt.Sprintf("[%s] %s", m.formatTaskDate(ann.Entry), ann.Description) + // Highlight search matches + if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(annText) { + annText = m.highlightMatches(annText, m.detailSearchRegex) + } + lines = append(lines, descStyle.Render(annText)) + } + } + + // Instructions at bottom + lines = append(lines, "") + lines = append(lines, "") + instructionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Italic(true) + lines = append(lines, instructionStyle.Render("Press ESC or Q to return to table view")) + if m.detailSearching { + lines = append(lines, instructionStyle.Render("Type to search, Enter to confirm")) + } else { + lines = append(lines, instructionStyle.Render("Press / to search, N/n to navigate matches")) + } + + // 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())) + } + + return strings.Join(lines, "\n") +} + +// renderTaskField renders a single field in the task detail view +func (m *Model) renderTaskField(label, value string, labelStyle, valueStyle lipgloss.Style) string { + if value == "" { + value = "-" + } + // Highlight search matches + if m.detailSearchRegex != nil && m.detailSearchRegex.MatchString(value) { + value = m.highlightMatches(value, m.detailSearchRegex) + } + return fmt.Sprintf("%s %s", labelStyle.Render(label+":"), valueStyle.Render(value)) +} + +// formatTaskDate formats a task date for display +func (m *Model) formatTaskDate(dateStr string) string { + if dateStr == "" { + return "-" + } + // Try to parse and format nicely + if ts, err := parseTaskDate(dateStr); err == nil { + return ts.Format("2006-01-02 15:04") + } + return dateStr +} + +// highlightMatches highlights regex matches in a string +func (m *Model) highlightMatches(text string, re *regexp.Regexp) string { + highlightStyle := lipgloss.NewStyle(). + Background(lipgloss.Color(m.theme.SearchBG)). + Foreground(lipgloss.Color(m.theme.SearchFG)) + + matches := re.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + + var result strings.Builder + lastEnd := 0 + + for _, match := range matches { + start, end := match[0], match[1] + // Add text before match + if start > lastEnd { + result.WriteString(text[lastEnd:start]) + } + // Add highlighted match + result.WriteString(highlightStyle.Render(text[start:end])) + lastEnd = end + } + + // Add remaining text + if lastEnd < len(text) { + result.WriteString(text[lastEnd:]) + } + + return result.String() +}
\ No newline at end of file diff --git a/tasksamurai b/tasksamurai Binary files differindex 97b5e72..d77153b 100755 --- a/tasksamurai +++ b/tasksamurai |
