summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-28 10:54:37 +0300
committerPaul Buetow <paul@buetow.org>2025-06-28 10:54:37 +0300
commit43a0dcbd6617ccf28abfbb50b98bf9fbf8383835 (patch)
treeec7bfddbcfad075edf22f7c921e5518432e49109
parentea0fbdc5a168b22296588259c6e821dffcdf7d1a (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.go54
-rw-r--r--internal/ui/keyhandlers.go36
-rw-r--r--internal/ui/table.go10
-rw-r--r--internal/ui/taskdetail.go196
-rwxr-xr-xtasksamuraibin5843389 -> 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
index 97b5e72..d77153b 100755
--- a/tasksamurai
+++ b/tasksamurai
Binary files differ