mirror of
https://github.com/RWejlgaard/org.git
synced 2026-05-06 04:34:45 +00:00
1097 lines
32 KiB
Go
1097 lines
32 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 modeSetScheduled:
|
|
return m.viewSetScheduled()
|
|
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()
|
|
case modeRename:
|
|
return m.viewRename()
|
|
}
|
|
|
|
// 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 {
|
|
// Build indentation for notes
|
|
var notePrefix strings.Builder
|
|
if m.config.UI.ShowIndentationGuides {
|
|
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
|
for j := 1; j <= item.Level; j++ {
|
|
notePrefix.WriteString(guideStyle.Render("· "))
|
|
}
|
|
} else {
|
|
// No visual guides, just use spaces
|
|
for j := 1; j <= item.Level; j++ {
|
|
notePrefix.WriteString(" ")
|
|
}
|
|
}
|
|
indent := notePrefix.String()
|
|
noteIndent := indent + " "
|
|
filteredNotes := filterLogbookDrawer(item.Notes)
|
|
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
|
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
|
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 {
|
|
// Build indentation for notes
|
|
var notePrefix strings.Builder
|
|
if m.config.UI.ShowIndentationGuides {
|
|
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
|
for i := 1; i <= item.Level; i++ {
|
|
notePrefix.WriteString(guideStyle.Render("· "))
|
|
}
|
|
} else {
|
|
// No visual guides, just use spaces
|
|
for i := 1; i <= item.Level; i++ {
|
|
notePrefix.WriteString(" ")
|
|
}
|
|
}
|
|
indent := notePrefix.String()
|
|
noteIndent := indent + " "
|
|
filteredNotes := filterLogbookDrawer(item.Notes)
|
|
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
|
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
|
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 {
|
|
// Build indentation for notes
|
|
var notePrefix strings.Builder
|
|
if m.config.UI.ShowIndentationGuides {
|
|
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
|
for i := 1; i <= item.Level; i++ {
|
|
notePrefix.WriteString(guideStyle.Render("· "))
|
|
}
|
|
} else {
|
|
// No visual guides, just use spaces
|
|
for i := 1; i <= item.Level; i++ {
|
|
notePrefix.WriteString(" ")
|
|
}
|
|
}
|
|
indent := notePrefix.String()
|
|
noteIndent := indent + " "
|
|
filteredNotes := filterLogbookDrawer(item.Notes)
|
|
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
|
|
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
|
|
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 {
|
|
return m.viewSetDate("Set Deadline", "Leave empty to clear deadline")
|
|
}
|
|
|
|
func (m uiModel) viewSetScheduled() string {
|
|
return m.viewSetDate("Set Scheduled Date", "Leave empty to clear scheduled date")
|
|
}
|
|
|
|
func (m uiModel) viewSetDate(title, helpMsg string) 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(title))
|
|
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(helpMsg))
|
|
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.SetScheduled, 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, DEADLINE, and CLOSED lines
|
|
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") || strings.HasPrefix(trimmed, "CLOSED:") {
|
|
continue
|
|
}
|
|
|
|
filtered = append(filtered, note)
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// wrapNoteLines wraps note lines to fit within the specified width
|
|
func wrapNoteLines(notes []string, width int, indent string) []string {
|
|
var wrapped []string
|
|
for _, note := range notes {
|
|
// Don't wrap code block delimiters or drawer markers
|
|
trimmed := strings.TrimSpace(note)
|
|
if strings.HasPrefix(trimmed, "#+BEGIN_SRC") ||
|
|
strings.HasPrefix(trimmed, "#+END_SRC") ||
|
|
strings.HasPrefix(trimmed, "```") ||
|
|
trimmed == ":LOGBOOK:" ||
|
|
trimmed == ":PROPERTIES:" ||
|
|
trimmed == ":END:" {
|
|
wrapped = append(wrapped, note)
|
|
continue
|
|
}
|
|
|
|
// Wrap the note line
|
|
wrappedLines := wrapText(note, width, indent)
|
|
wrapped = append(wrapped, wrappedLines...)
|
|
}
|
|
return wrapped
|
|
}
|
|
|
|
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
|
|
func (m uiModel) 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 {
|
|
// Apply org-mode syntax highlighting to non-code text if enabled
|
|
if m.config.UI.OrgSyntaxHighlighting {
|
|
highlighted := highlightCode(note, "org")
|
|
result = append(result, highlighted)
|
|
} 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")
|
|
}
|
|
|
|
// wrapText wraps text to fit within the specified width, accounting for indent
|
|
func wrapText(text string, width int, indent string) []string {
|
|
if width <= 0 {
|
|
return []string{text}
|
|
}
|
|
|
|
// Calculate available width after indent
|
|
indentWidth := lipgloss.Width(indent)
|
|
availableWidth := width - indentWidth
|
|
if availableWidth <= 10 {
|
|
// If very little space, just return the original text
|
|
return []string{text}
|
|
}
|
|
|
|
var result []string
|
|
var currentLine strings.Builder
|
|
currentWidth := 0
|
|
|
|
// Split by whitespace while preserving leading/trailing spaces
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
// Preserve empty lines
|
|
return []string{text}
|
|
}
|
|
|
|
for i, word := range words {
|
|
wordWidth := lipgloss.Width(word)
|
|
|
|
// If this is the first word on the line
|
|
if currentWidth == 0 {
|
|
// Handle words longer than available width
|
|
if wordWidth > availableWidth {
|
|
// Split the word across multiple lines
|
|
for len(word) > 0 {
|
|
if availableWidth <= 0 {
|
|
availableWidth = 10 // Fallback
|
|
}
|
|
chunkSize := availableWidth
|
|
if chunkSize > len(word) {
|
|
chunkSize = len(word)
|
|
}
|
|
result = append(result, word[:chunkSize])
|
|
word = word[chunkSize:]
|
|
}
|
|
continue
|
|
}
|
|
currentLine.WriteString(word)
|
|
currentWidth = wordWidth
|
|
} else {
|
|
// Check if adding this word (plus a space) would exceed the width
|
|
spaceAndWordWidth := currentWidth + 1 + wordWidth
|
|
if spaceAndWordWidth > availableWidth {
|
|
// Start a new line
|
|
result = append(result, currentLine.String())
|
|
currentLine.Reset()
|
|
|
|
// Handle words longer than available width
|
|
if wordWidth > availableWidth {
|
|
for len(word) > 0 {
|
|
chunkSize := availableWidth
|
|
if chunkSize > len(word) {
|
|
chunkSize = len(word)
|
|
}
|
|
result = append(result, word[:chunkSize])
|
|
word = word[chunkSize:]
|
|
}
|
|
currentWidth = 0
|
|
continue
|
|
}
|
|
|
|
currentLine.WriteString(word)
|
|
currentWidth = wordWidth
|
|
} else {
|
|
// Add word to current line
|
|
currentLine.WriteString(" ")
|
|
currentLine.WriteString(word)
|
|
currentWidth = spaceAndWordWidth
|
|
}
|
|
}
|
|
|
|
// If this is the last word, add the line
|
|
if i == len(words)-1 && currentLine.Len() > 0 {
|
|
result = append(result, currentLine.String())
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
|
|
var b strings.Builder
|
|
|
|
// Indentation with subtle visual nesting guides
|
|
if m.config.UI.ShowIndentationGuides {
|
|
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
|
|
for i := 1; i < item.Level; i++ {
|
|
if i == item.Level-1 {
|
|
// Last level before the item - use subtle dot connector
|
|
b.WriteString(guideStyle.Render("· "))
|
|
} else {
|
|
// Parent levels - use subtle dot
|
|
b.WriteString(guideStyle.Render("· "))
|
|
}
|
|
}
|
|
} else {
|
|
// No visual guides, just use spaces for indentation
|
|
for i := 1; i < item.Level; i++ {
|
|
b.WriteString(" ")
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// viewRename renders the rename item view
|
|
func (m uiModel) viewRename() string {
|
|
var content strings.Builder
|
|
|
|
content.WriteString(m.styles.titleStyle.Render("Rename Item") + "\n\n")
|
|
|
|
if m.editingItem != nil {
|
|
content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Title)) + "\n\n")
|
|
}
|
|
|
|
content.WriteString(m.textinput.View() + "\n\n")
|
|
|
|
content.WriteString(m.styles.statusStyle.Render("Enter new title for the item") + "\n\n")
|
|
content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n")
|
|
|
|
return content.String()
|
|
}
|