org/internal/ui/views.go

1265 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}
// renderLatexMath converts LaTeX math expressions to Unicode for terminal display
func renderLatexMath(latex string) string {
result := latex
// Remove LaTeX math delimiters
result = strings.ReplaceAll(result, `\(`, "")
result = strings.ReplaceAll(result, `\)`, "")
result = strings.ReplaceAll(result, `\[`, "")
result = strings.ReplaceAll(result, `\]`, "")
result = strings.ReplaceAll(result, `$$`, "")
// Remove single $ delimiters (being careful not to remove actual dollar signs in text)
// Simple approach: if line starts/ends with $, remove it
result = strings.TrimSpace(result)
if strings.HasPrefix(result, "$") && strings.HasSuffix(result, "$") && len(result) > 2 {
result = result[1 : len(result)-1]
result = strings.TrimSpace(result)
}
// Map of common LaTeX commands to Unicode equivalents
replacements := map[string]string{
// Greek letters (lowercase)
`\alpha`: "α",
`\beta`: "β",
`\gamma`: "γ",
`\delta`: "δ",
`\epsilon`: "ε",
`\zeta`: "ζ",
`\eta`: "η",
`\theta`: "θ",
`\iota`: "ι",
`\kappa`: "κ",
`\lambda`: "λ",
`\mu`: "μ",
`\nu`: "ν",
`\xi`: "ξ",
`\pi`: "π",
`\rho`: "ρ",
`\sigma`: "σ",
`\tau`: "τ",
`\upsilon`: "υ",
`\phi`: "φ",
`\chi`: "χ",
`\psi`: "ψ",
`\omega`: "ω",
// Greek letters (uppercase)
`\Gamma`: "Γ",
`\Delta`: "Δ",
`\Theta`: "Θ",
`\Lambda`: "Λ",
`\Xi`: "Ξ",
`\Pi`: "Π",
`\Sigma`: "Σ",
`\Upsilon`: "Υ",
`\Phi`: "Φ",
`\Psi`: "Ψ",
`\Omega`: "Ω",
// Math operators
`\times`: "×",
`\div`: "÷",
`\pm`: "±",
`\mp`: "∓",
`\cdot`: "·",
`\star`: "⋆",
`\ast`: "",
`\circ`: "∘",
`\bullet`: "•",
// Relations
`\le`: "≤",
`\ge`: "≥",
`\leq`: "≤",
`\geq`: "≥",
`\ne`: "≠",
`\neq`: "≠",
`\approx`: "≈",
`\equiv`: "≡",
`\sim`: "",
`\simeq`: "≃",
`\propto`: "∝",
// Arrows
`\to`: "→",
`\rightarrow`: "→",
`\leftarrow`: "←",
`\Rightarrow`: "⇒",
`\Leftarrow`: "⇐",
`\mapsto`: "↦",
// Set theory
`\in`: "∈",
`\notin`: "∉",
`\subset`: "⊂",
`\supset`: "⊃",
`\subseteq`: "⊆",
`\supseteq`: "⊇",
`\cup`: "",
`\cap`: "∩",
`\emptyset`: "∅",
`\forall`: "∀",
`\exists`: "∃",
// Calculus
`\partial`: "∂",
`\nabla`: "∇",
`\int`: "∫",
`\sum`: "∑",
`\prod`: "∏",
`\infty`: "∞",
// Logic
`\land`: "∧",
`\lor`: "",
`\lnot`: "¬",
`\neg`: "¬",
`\wedge`: "∧",
`\vee`: "",
// Special symbols
`\hbar`: "ℏ",
`\ell`: "",
`\Re`: "",
`\Im`: "",
`\angle`: "∠",
`\triangle`: "△",
`\square`: "□",
`\degree`: "°",
// Superscripts (common ones)
`^0`: "⁰",
`^1`: "¹",
`^2`: "²",
`^3`: "³",
`^4`: "⁴",
`^5`: "⁵",
`^6`: "⁶",
`^7`: "⁷",
`^8`: "⁸",
`^9`: "⁹",
`^+`: "⁺",
`^-`: "⁻",
`^=`: "⁼",
`^(`: "⁽",
`^)`: "⁾",
// Subscripts (common ones)
`_0`: "₀",
`_1`: "₁",
`_2`: "₂",
`_3`: "₃",
`_4`: "₄",
`_5`: "₅",
`_6`: "₆",
`_7`: "₇",
`_8`: "₈",
`_9`: "₉",
`_+`: "₊",
`_-`: "₋",
`_=`: "₌",
`_(`: "₍",
`_)`: "₎",
}
// Apply all replacements
for latexCmd, unicode := range replacements {
result = strings.ReplaceAll(result, latexCmd, unicode)
}
// Handle simple fractions \frac{a}{b} -> a/b
result = handleFractions(result)
// Handle square roots \sqrt{x} -> √(x)
result = handleSquareRoots(result)
// Handle superscripts ^{...} and subscripts _{...}
result = handleSuperscripts(result)
result = handleSubscripts(result)
return result
}
// handleFractions converts \frac{numerator}{denominator} to numerator/denominator
func handleFractions(text string) string {
// Simple regex-free approach for basic fractions
result := text
for {
start := strings.Index(result, `\frac{`)
if start == -1 {
break
}
// Find the numerator
numStart := start + 6
numEnd, numerator := findBracedContent(result, numStart)
if numEnd == -1 {
break
}
// Find the denominator
if numEnd >= len(result) || result[numEnd] != '{' {
break
}
denomEnd, denominator := findBracedContent(result, numEnd+1)
if denomEnd == -1 {
break
}
// Replace \frac{num}{denom} with (num)/(denom)
replacement := "(" + numerator + ")/(" + denominator + ")"
result = result[:start] + replacement + result[denomEnd:]
}
return result
}
// handleSquareRoots converts \sqrt{x} to √(x)
func handleSquareRoots(text string) string {
result := text
for {
start := strings.Index(result, `\sqrt{`)
if start == -1 {
break
}
// Find the content
contentStart := start + 6
contentEnd, content := findBracedContent(result, contentStart)
if contentEnd == -1 {
break
}
// Replace \sqrt{content} with √(content)
replacement := "√(" + content + ")"
result = result[:start] + replacement + result[contentEnd:]
}
return result
}
// findBracedContent finds content within braces starting at position i
// Returns the position after the closing brace and the content
func findBracedContent(text string, start int) (int, string) {
if start >= len(text) {
return -1, ""
}
depth := 1
i := start
for i < len(text) && depth > 0 {
if text[i] == '{' {
depth++
} else if text[i] == '}' {
depth--
if depth == 0 {
return i + 1, text[start:i]
}
}
i++
}
return -1, ""
}
// handleSuperscripts converts ^{...} to Unicode superscripts where possible
func handleSuperscripts(text string) string {
superscriptMap := map[rune]string{
'0': "⁰", '1': "¹", '2': "²", '3': "³", '4': "⁴",
'5': "⁵", '6': "⁶", '7': "⁷", '8': "⁸", '9': "⁹",
'a': "ᵃ", 'b': "ᵇ", 'c': "ᶜ", 'd': "ᵈ", 'e': "ᵉ",
'f': "ᶠ", 'g': "ᵍ", 'h': "ʰ", 'i': "ⁱ", 'j': "ʲ",
'k': "ᵏ", 'l': "ˡ", 'm': "ᵐ", 'n': "ⁿ", 'o': "ᵒ",
'p': "ᵖ", 'r': "ʳ", 's': "ˢ", 't': "ᵗ", 'u': "ᵘ",
'v': "ᵛ", 'w': "ʷ", 'x': "ˣ", 'y': "ʸ", 'z': "ᶻ",
'A': "ᴬ", 'B': "ᴮ", 'D': "ᴰ", 'E': "ᴱ", 'G': "ᴳ",
'H': "ᴴ", 'I': "ᴵ", 'J': "ᴶ", 'K': "ᴷ", 'L': "ᴸ",
'M': "ᴹ", 'N': "ᴺ", 'O': "ᴼ", 'P': "ᴾ", 'R': "ᴿ",
'T': "ᵀ", 'U': "ᵁ", 'V': "ⱽ", 'W': "ᵂ",
'+': "⁺", '-': "⁻", '=': "⁼", '(': "⁽", ')': "⁾",
}
result := text
// Handle ^{...} format
for {
start := strings.Index(result, "^{")
if start == -1 {
break
}
contentStart := start + 2
contentEnd, content := findBracedContent(result, contentStart)
if contentEnd == -1 {
break
}
// Convert content to superscript
var superscript strings.Builder
for _, ch := range content {
if sup, ok := superscriptMap[ch]; ok {
superscript.WriteString(sup)
} else {
// If no superscript version exists, wrap in parentheses
superscript.WriteRune(ch)
}
}
result = result[:start] + superscript.String() + result[contentEnd:]
}
// Handle simple ^x format (single character without braces)
for i := 0; i < len(result)-1; i++ {
if result[i] == '^' && result[i+1] != '{' {
ch := rune(result[i+1])
if sup, ok := superscriptMap[ch]; ok {
result = result[:i] + sup + result[i+2:]
}
}
}
return result
}
// handleSubscripts converts _{...} to Unicode subscripts where possible
func handleSubscripts(text string) string {
subscriptMap := map[rune]string{
'0': "₀", '1': "₁", '2': "₂", '3': "₃", '4': "₄",
'5': "₅", '6': "₆", '7': "₇", '8': "₈", '9': "₉",
'a': "ₐ", 'e': "ₑ", 'h': "ₕ", 'i': "ᵢ", 'j': "ⱼ",
'k': "ₖ", 'l': "ₗ", 'm': "ₘ", 'n': "ₙ", 'o': "ₒ",
'p': "ₚ", 'r': "ᵣ", 's': "ₛ", 't': "ₜ", 'u': "ᵤ",
'v': "ᵥ", 'x': "ₓ",
'+': "₊", '-': "₋", '=': "₌", '(': "₍", ')': "₎",
}
result := text
// Handle _{...} format
for {
start := strings.Index(result, "_{")
if start == -1 {
break
}
contentStart := start + 2
contentEnd, content := findBracedContent(result, contentStart)
if contentEnd == -1 {
break
}
// Convert content to subscript
var subscript strings.Builder
for _, ch := range content {
if sub, ok := subscriptMap[ch]; ok {
subscript.WriteString(sub)
} else {
// If no subscript version exists, wrap in parentheses
subscript.WriteRune(ch)
}
}
result = result[:start] + subscript.String() + result[contentEnd:]
}
// Handle simple _x format (single character without braces)
for i := 0; i < len(result)-1; i++ {
if result[i] == '_' && result[i+1] != '{' {
ch := rune(result[i+1])
if sub, ok := subscriptMap[ch]; ok {
result = result[:i] + sub + result[i+2:]
}
}
}
return result
}
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()
}