org/internal/ui/views.go
Rasmus "Pez" Wejlgaard 23c7095477
fix: latex math support (#6)
* fix: latex math support

* move latex junk to another file
2025-11-08 23:22:37 +00:00

888 lines
26 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
"github.com/alecthomas/chroma/v2/quick"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"github.com/rwejlgaard/org/internal/model"
"github.com/rwejlgaard/org/internal/parser"
)
// dynamicKeyMap is a helper type for rendering keybindings with dynamic layout
type dynamicKeyMap struct {
rows [][]key.Binding
}
// ShortHelp for dynamicKeyMap
func (d dynamicKeyMap) ShortHelp() []key.Binding {
return []key.Binding{}
}
// FullHelp for dynamicKeyMap
func (d dynamicKeyMap) FullHelp() [][]key.Binding {
return d.rows
}
// renderFullHelp renders the help with width-aware layout
func (m uiModel) renderFullHelp() string {
bindings := m.keys.getAllBindings()
const minWidth = 40 // Minimum width before stacking
var columnsPerRow int
if m.width < minWidth {
columnsPerRow = 1 // Stack vertically on very narrow terminals
} else if m.width < 80 {
columnsPerRow = 2 // Two columns on narrow terminals
} else if m.width < 120 {
columnsPerRow = 3 // Three columns on medium terminals
} else {
columnsPerRow = 4 // Four columns on wide terminals
}
// Build rows based on columns per row
var rows [][]key.Binding
for i := 0; i < len(bindings); i += columnsPerRow {
end := i + columnsPerRow
if end > len(bindings) {
end = len(bindings)
}
rows = append(rows, bindings[i:end])
}
// Use the help model to render with our dynamic layout
h := help.New()
h.Width = m.width
h.ShowAll = true
// Create a temporary keyMap for rendering
dkm := dynamicKeyMap{rows: rows}
return h.View(dkm)
}
func (m uiModel) View() string {
switch m.mode {
case modeEdit:
return m.viewEditMode()
case modeConfirmDelete:
return m.viewConfirmDelete()
case modeCapture:
return m.viewCapture()
case modeAddSubTask:
return m.viewAddSubTask()
case modeSetDeadline:
return m.viewSetDeadline()
case modeSetPriority:
return m.viewSetPriority()
case modeSetEffort:
return m.viewSetEffort()
case modeHelp:
return m.viewHelp()
case modeSettings:
return m.viewSettings()
case modeSettingsAddTag:
return m.viewSettingsAddTag()
case modeSettingsAddState:
return m.viewSettingsAddState()
case modeTagEdit:
return m.viewTagEdit()
}
// Build footer (status + help)
var footer strings.Builder
// Status message
if time.Now().Before(m.statusExpiry) {
footer.WriteString(m.styles.statusStyle.Render(m.statusMsg))
footer.WriteString("\n")
}
// Help
if m.help.ShowAll {
footer.WriteString(m.renderFullHelp())
} else {
footer.WriteString(m.help.View(m.keys))
}
footerHeight := lipgloss.Height(footer.String())
// Build main content
var content strings.Builder
// Title
title := "Org Mode - List View"
if m.mode == modeAgenda {
title = "Org Mode - Agenda View (Next 7 Days)"
}
if m.reorderMode {
reorderIndicator := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" [REORDER MODE]")
content.WriteString(m.styles.titleStyle.Render(title))
content.WriteString(reorderIndicator)
} else {
content.WriteString(m.styles.titleStyle.Render(title))
}
content.WriteString("\n\n")
// Calculate available height for items (total - title - footer)
availableHeight := m.height - 3 - footerHeight // 3 for title + spacing
if availableHeight < 5 {
availableHeight = 5 // Minimum height
}
// Items
items := m.getVisibleItems()
if len(items) == 0 {
content.WriteString("No items. Press 'c' to capture a new TODO.\n")
}
// Build a map of item index to line count (for scrolling)
itemLineCount := make([]int, len(items))
for i, item := range items {
lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount
}
// Calculate total lines up to cursor
totalLinesBeforeCursor := 0
for i := 0; i < m.cursor && i < len(itemLineCount); i++ {
totalLinesBeforeCursor += itemLineCount[i]
}
// Determine the scroll offset (without modifying m - View should be pure)
scrollOffset := m.scrollOffset
if totalLinesBeforeCursor < scrollOffset {
// Cursor is above visible area, scroll up
scrollOffset = totalLinesBeforeCursor
} else if totalLinesBeforeCursor >= scrollOffset+availableHeight {
// Cursor is below visible area, scroll down
scrollOffset = totalLinesBeforeCursor - availableHeight + 1
}
// Render items starting from scroll offset
itemLines := 0
for i, item := range items {
// Calculate which line this item starts at
itemStartLine := 0
for j := 0; j < i; j++ {
itemStartLine += itemLineCount[j]
}
// Skip items before scroll offset
if itemStartLine+itemLineCount[i] <= scrollOffset {
continue
}
// Stop if we've filled the screen
if itemLines >= availableHeight {
break
}
// Skip partial items at the top if needed
if itemStartLine < scrollOffset {
// This item is partially scrolled off the top
linesToSkip := scrollOffset - itemStartLine
if linesToSkip < itemLineCount[i] {
// Render the visible parts
if linesToSkip == 0 {
line := m.renderItem(item, i == m.cursor)
content.WriteString(line)
content.WriteString("\n")
itemLines++
linesToSkip++
}
// Render remaining notes
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level)
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ {
content.WriteString(indent)
content.WriteString(" " + highlightedNotes[noteIdx])
content.WriteString("\n")
itemLines++
}
}
}
continue
}
// Render the full item
line := m.renderItem(item, i == m.cursor)
content.WriteString(line)
content.WriteString("\n")
itemLines++
// Show notes if not folded
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level)
filteredNotes := filterLogbookDrawer(item.Notes)
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
for _, note := range highlightedNotes {
if itemLines >= availableHeight {
break
}
content.WriteString(indent)
content.WriteString(" " + note)
content.WriteString("\n")
itemLines++
}
}
}
// Combine content and footer with padding
contentHeight := lipgloss.Height(content.String())
paddingNeeded := m.height - contentHeight - footerHeight
if paddingNeeded < 0 {
paddingNeeded = 0
}
var result strings.Builder
result.WriteString(content.String())
if paddingNeeded > 0 {
result.WriteString(strings.Repeat("\n", paddingNeeded))
}
result.WriteString(footer.String())
return result.String()
}
func (m uiModel) viewConfirmDelete() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("⚠ Delete Item"))
content.WriteString("\n\n")
if m.itemToDelete != nil {
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("202")).Bold(true)
content.WriteString(itemStyle.Render(m.itemToDelete.Title))
content.WriteString("\n")
}
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("This will delete the item and all sub-tasks."))
content.WriteString("\n\n")
content.WriteString("Press Y to confirm • N or ESC to cancel")
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewCapture() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("99")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Capture TODO"))
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewAddSubTask() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("99")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Add Sub-Task"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title)))
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetDeadline() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Deadline"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear deadline"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetPriority() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("214")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Priority"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
content.WriteString("\n")
if m.editingItem.Priority != model.PriorityNone {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: [#%s]", m.editingItem.Priority)))
}
}
content.WriteString("\n\n")
// Show priority options with styling
priorityAStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
priorityBStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
priorityCStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
content.WriteString(priorityAStyle.Render("[A] High Priority") + "\n")
content.WriteString(priorityBStyle.Render("[B] Medium Priority") + "\n")
content.WriteString(priorityCStyle.Render("[C] Low Priority") + "\n")
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Space/Enter to clear priority"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewSetEffort() string {
dialogStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("141")).
Padding(1, 2).
Width(60)
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Set Effort"))
content.WriteString("\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
content.WriteString("\n")
if m.editingItem.Effort != "" {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Effort)))
}
}
content.WriteString("\n\n")
content.WriteString(m.textinput.View())
content.WriteString("\n\n")
content.WriteString(m.styles.statusStyle.Render("Examples: 8h, 2d, 1w, 4h30m"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to clear effort"))
content.WriteString("\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel"))
dialog := dialogStyle.Render(content.String())
// Center the dialog horizontally and vertically
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
}
func (m uiModel) viewHelp() string {
// Build the full help content first
var lines []string
// Title
lines = append(lines, m.styles.titleStyle.Render("Keybindings Help"))
lines = append(lines, "")
// Group bindings by category
navigationBindings := []key.Binding{m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right}
itemBindings := []key.Binding{m.keys.ToggleFold, m.keys.EditNotes, m.keys.CycleState}
taskBindings := []key.Binding{m.keys.Capture, m.keys.AddSubTask, m.keys.Delete}
timeBindings := []key.Binding{m.keys.ClockIn, m.keys.ClockOut, m.keys.SetDeadline, m.keys.SetEffort}
organizationBindings := []key.Binding{m.keys.SetPriority, m.keys.TagItem, m.keys.ShiftUp, m.keys.ShiftDown, m.keys.ToggleReorder}
viewBindings := []key.Binding{m.keys.ToggleView, m.keys.Settings, m.keys.Save, m.keys.Help, m.keys.Quit}
// Helper function to render a binding
renderBinding := func(b key.Binding) string {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
help := b.Help()
return fmt.Sprintf(" %s %s", keyStyle.Render(help.Key), descStyle.Render(help.Desc))
}
// Render categories
categoryStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
lines = append(lines, categoryStyle.Render("Navigation"))
for _, binding := range navigationBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Item Actions"))
for _, binding := range itemBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Task Management"))
for _, binding := range taskBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Time Tracking"))
for _, binding := range timeBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("Organization"))
for _, binding := range organizationBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
lines = append(lines, categoryStyle.Render("View & System"))
for _, binding := range viewBindings {
lines = append(lines, renderBinding(binding))
}
lines = append(lines, "")
// Calculate visible area
footerLines := 2 // Footer text
availableHeight := m.height - footerLines
if availableHeight < 5 {
availableHeight = 5
}
totalLines := len(lines)
// Determine which lines to show based on scroll offset
startLine := m.helpScroll
endLine := startLine + availableHeight
if endLine > totalLines {
endLine = totalLines
}
if startLine >= totalLines {
startLine = totalLines - 1
if startLine < 0 {
startLine = 0
}
}
// Build visible content
var content strings.Builder
for i := startLine; i < endLine && i < len(lines); i++ {
content.WriteString(lines[i])
content.WriteString("\n")
}
// Add scroll indicators and footer
var footer strings.Builder
if startLine > 0 || endLine < totalLines {
scrollInfo := fmt.Sprintf("(Scroll: %d-%d of %d lines)", startLine+1, endLine, totalLines)
footer.WriteString(m.styles.statusStyle.Render(scrollInfo))
footer.WriteString(" ")
}
footer.WriteString(m.styles.statusStyle.Render("↑/↓ scroll • ? or ESC to close"))
// Combine content and footer
var result strings.Builder
result.WriteString(content.String())
// Add padding if needed
currentHeight := lipgloss.Height(content.String())
paddingNeeded := availableHeight - currentHeight
if paddingNeeded > 0 {
result.WriteString(strings.Repeat("\n", paddingNeeded))
}
result.WriteString(footer.String())
return result.String()
}
func (m uiModel) viewEditMode() string {
var b strings.Builder
b.WriteString(m.styles.titleStyle.Render("Editing Notes"))
b.WriteString("\n")
if m.editingItem != nil {
b.WriteString(fmt.Sprintf("Item: %s\n", m.editingItem.Title))
}
b.WriteString(m.styles.statusStyle.Render("Press ESC to save and exit"))
b.WriteString("\n\n")
b.WriteString(m.textarea.View())
return b.String()
}
// filterLogbookDrawer removes LOGBOOK and PROPERTIES drawer content and scheduling metadata from notes
func filterLogbookDrawer(notes []string) []string {
var filtered []string
inLogbook := false
inProperties := false
for _, note := range notes {
trimmed := strings.TrimSpace(note)
// Check for start of LOGBOOK drawer
if trimmed == ":LOGBOOK:" {
inLogbook = true
continue
}
// Check for start of PROPERTIES drawer
if trimmed == ":PROPERTIES:" {
inProperties = true
continue
}
// Check for end of drawer
if trimmed == ":END:" {
if inLogbook {
inLogbook = false
continue
}
if inProperties {
inProperties = false
continue
}
}
// Skip lines inside LOGBOOK or PROPERTIES drawer
if inLogbook || inProperties {
continue
}
// Skip SCHEDULED and DEADLINE lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") {
continue
}
filtered = append(filtered, note)
}
return filtered
}
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
func renderNotesWithHighlighting(notes []string) []string {
if len(notes) == 0 {
return notes
}
var result []string
var inCodeBlock bool
var codeLanguage string
var codeLines []string
var codeBlockDelimiter string // Track whether we're in #+BEGIN_SRC or ``` block
for _, note := range notes {
trimmed := strings.TrimSpace(note)
// Check for org-mode style code block start
if strings.HasPrefix(trimmed, "#+BEGIN_SRC") {
inCodeBlock = true
codeBlockDelimiter = "org"
// Extract language
parts := strings.Fields(trimmed)
if len(parts) > 1 {
codeLanguage = strings.ToLower(parts[1])
} else {
codeLanguage = "text"
}
result = append(result, note) // Keep the delimiter visible
codeLines = []string{}
continue
}
// Check for markdown style code block start
if strings.HasPrefix(trimmed, "```") {
if !inCodeBlock {
// Starting a code block
inCodeBlock = true
codeBlockDelimiter = "markdown"
// Extract language
lang := strings.TrimPrefix(trimmed, "```")
if lang != "" {
codeLanguage = strings.ToLower(lang)
} else {
codeLanguage = "text"
}
result = append(result, note) // Keep the delimiter visible
codeLines = []string{}
continue
} else if codeBlockDelimiter == "markdown" {
// Ending a markdown code block
inCodeBlock = false
// Highlight and add the code (or render LaTeX)
if len(codeLines) > 0 {
var processedLines []string
if codeLanguage == "latex" {
// Apply LaTeX-to-Unicode conversion
for _, line := range codeLines {
processedLines = append(processedLines, renderLatexMath(line))
}
} else {
// Apply syntax highlighting
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
processedLines = strings.Split(highlighted, "\n")
}
result = append(result, processedLines...)
}
result = append(result, note) // Keep the delimiter visible
codeLines = []string{}
codeLanguage = ""
codeBlockDelimiter = ""
continue
}
}
// Check for org-mode style code block end
if strings.HasPrefix(trimmed, "#+END_SRC") {
inCodeBlock = false
// Highlight and add the code (or render LaTeX)
if len(codeLines) > 0 {
var processedLines []string
if codeLanguage == "latex" {
// Apply LaTeX-to-Unicode conversion
for _, line := range codeLines {
processedLines = append(processedLines, renderLatexMath(line))
}
} else {
// Apply syntax highlighting
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
processedLines = strings.Split(highlighted, "\n")
}
result = append(result, processedLines...)
}
result = append(result, note) // Keep the delimiter visible
codeLines = []string{}
codeLanguage = ""
codeBlockDelimiter = ""
continue
}
// If in code block, accumulate lines
if inCodeBlock {
codeLines = append(codeLines, note)
} else {
result = append(result, note)
}
}
// Handle case where code block wasn't closed
if inCodeBlock && len(codeLines) > 0 {
var processedLines []string
if codeLanguage == "latex" {
// Apply LaTeX-to-Unicode conversion
for _, line := range codeLines {
processedLines = append(processedLines, renderLatexMath(line))
}
} else {
// Apply syntax highlighting
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
processedLines = strings.Split(highlighted, "\n")
}
result = append(result, processedLines...)
}
return result
}
// highlightCode applies syntax highlighting to code
func highlightCode(code, language string) string {
if code == "" {
return code
}
var buf strings.Builder
err := quick.Highlight(&buf, code, language, "terminal256", "monokai")
if err != nil {
// If highlighting fails, return the original code
return code
}
return strings.TrimRight(buf.String(), "\n")
}
func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
var b strings.Builder
// Indentation for level
indent := strings.Repeat(" ", item.Level-1)
b.WriteString(indent)
// Fold indicator
if len(item.Children) > 0 || len(item.Notes) > 0 {
if item.Folded {
b.WriteString(m.styles.foldedStyle.Render("▶ "))
} else {
b.WriteString(m.styles.foldedStyle.Render("▼ "))
}
} else {
b.WriteString(" ")
}
// State
stateStr := ""
if item.State != model.StateNone {
stateColor := m.config.GetStateColor(string(item.State))
stateStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(stateColor))
stateStr = stateStyle.Render(fmt.Sprintf("[%s]", item.State))
}
b.WriteString(stateStr)
b.WriteString(" ")
// Priority
if item.Priority != model.PriorityNone {
var priorityStyle lipgloss.Style
switch item.Priority {
case model.PriorityA:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
case model.PriorityB:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
case model.PriorityC:
priorityStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
}
b.WriteString(priorityStyle.Render(fmt.Sprintf("[#%s] ", item.Priority)))
}
// Title
b.WriteString(item.Title)
// Tags
if len(item.Tags) > 0 {
b.WriteString(" ")
for _, tag := range item.Tags {
tagColor := m.config.GetTagColor(tag)
tagStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(tagColor))
b.WriteString(tagStyle.Render(fmt.Sprintf(":%s:", tag)))
}
}
// Effort
if item.Effort != "" {
effortStr := fmt.Sprintf(" (Effort: %s)", item.Effort)
effortStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple
b.WriteString(effortStyle.Render(effortStr))
}
// Clock status
if item.IsClockedIn() {
duration := item.GetCurrentClockDuration()
hours := int(duration.Hours())
minutes := int(duration.Minutes()) % 60
clockStr := fmt.Sprintf(" [CLOCKED IN: %dh %dm]", hours, minutes)
clockStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) // Bright green
b.WriteString(clockStyle.Render(clockStr))
}
// Total clocked time (show if there are any clock entries)
if len(item.ClockEntries) > 0 {
totalDuration := item.GetTotalClockDuration()
totalHours := int(totalDuration.Hours())
totalMinutes := int(totalDuration.Minutes()) % 60
// Format the time display based on magnitude
var timeStr string
if totalHours > 0 {
timeStr = fmt.Sprintf("%dh %dm", totalHours, totalMinutes)
} else {
timeStr = fmt.Sprintf("%dm", totalMinutes)
}
totalTimeStr := fmt.Sprintf(" (Time: %s)", timeStr)
totalTimeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple, similar to scheduled
b.WriteString(totalTimeStyle.Render(totalTimeStr))
}
// Scheduling info
now := time.Now()
if item.Scheduled != nil {
schedStr := fmt.Sprintf(" (Scheduled: %s)", parser.FormatOrgDate(*item.Scheduled))
if item.Scheduled.Before(now) {
b.WriteString(m.styles.overdueStyle.Render(schedStr))
} else {
b.WriteString(m.styles.scheduledStyle.Render(schedStr))
}
}
if item.Deadline != nil {
deadlineStr := fmt.Sprintf(" (Deadline: %s)", parser.FormatOrgDate(*item.Deadline))
if item.Deadline.Before(now) {
b.WriteString(m.styles.overdueStyle.Render(deadlineStr))
} else {
b.WriteString(m.styles.scheduledStyle.Render(deadlineStr))
}
}
line := b.String()
if isCursor {
return m.styles.cursorStyle.Render(line)
}
return line
}
// viewTagEdit renders the tag editing view
func (m uiModel) viewTagEdit() string {
var content strings.Builder
content.WriteString(m.styles.titleStyle.Render("Edit Tags") + "\n\n")
if m.editingItem != nil {
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)) + "\n\n")
}
content.WriteString(m.textinput.View() + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Enter tags separated by colons (e.g., work:urgent:important)") + "\n")
content.WriteString(m.styles.statusStyle.Render("Leave empty to remove all tags") + "\n\n")
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n")
return content.String()
}