fix: org syntax highlighting outside of codeblocks and more settings (#12)

This commit is contained in:
Rasmus Wejlgaard 2025-11-12 20:34:09 +00:00 committed by GitHub
parent 6b404cd722
commit 8ed20e48ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 321 additions and 52 deletions

View file

@ -94,6 +94,9 @@ type UIConfig struct {
HelpTextWidth int `toml:"help_text_width"`
MinTerminalWidth int `toml:"min_terminal_width"`
AgendaDays int `toml:"agenda_days"`
OrgSyntaxHighlighting bool `toml:"org_syntax_highlighting"`
ShowIndentationGuides bool `toml:"show_indentation_guides"`
IndentationGuideColor string `toml:"indentation_guide_color"`
}
// DefaultConfig returns the default configuration
@ -164,6 +167,9 @@ func DefaultConfig() *Config {
HelpTextWidth: 22,
MinTerminalWidth: 40,
AgendaDays: 7,
OrgSyntaxHighlighting: true,
ShowIndentationGuides: true,
IndentationGuideColor: "245",
},
}
}
@ -371,10 +377,11 @@ func (c *Config) fillDefaults() {
// Fill states if empty
if len(c.States.States) == 0 {
c.States.States = defaults.States.States
}
if c.States.DefaultNewTaskState == "" {
// Also set the default new task state since the entire states section is missing
c.States.DefaultNewTaskState = defaults.States.DefaultNewTaskState
}
// Note: We don't fill DefaultNewTaskState if States.States is non-empty because
// an empty string is a valid intentional value meaning "no default state".
// Fill UI if zero values
if c.UI.HelpTextWidth == 0 {
@ -386,6 +393,9 @@ func (c *Config) fillDefaults() {
if c.UI.AgendaDays == 0 {
c.UI.AgendaDays = defaults.UI.AgendaDays
}
if c.UI.IndentationGuideColor == "" {
c.UI.IndentationGuideColor = defaults.UI.IndentationGuideColor
}
}
// BuildKeyBinding creates a key.Binding from config

View file

@ -21,6 +21,7 @@ type Item struct {
Tags []string // Tags for this item (e.g., :work:urgent:)
Scheduled *time.Time
Deadline *time.Time
Closed *time.Time // Closed timestamp (when task was marked as done)
Effort string // Effort estimate (e.g., "8h", "2d")
Notes []string // Notes/content under the heading
Children []*Item // Sub-items

View file

@ -16,6 +16,7 @@ import (
var (
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
closedPattern = regexp.MustCompile(`CLOSED:\s*\[([^\]]+)\]`)
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
effortPattern = regexp.MustCompile(`^\s*:EFFORT:\s*(.+)$`)
logbookDrawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
@ -187,6 +188,13 @@ func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) {
}
}
// Check for CLOSED
if matches := closedPattern.FindStringSubmatch(line); matches != nil {
if t, err := parseClockTimestamp(matches[1]); err == nil {
currentItem.Closed = &t
}
}
// Check for EFFORT (inside PROPERTIES drawer)
if matches := effortPattern.FindStringSubmatch(line); matches != nil {
currentItem.Effort = strings.TrimSpace(matches[1])

View file

@ -128,6 +128,7 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
// Write scheduling info if not already in notes
hasScheduled := false
hasDeadline := false
hasClosed := false
hasLogbook := false
hasProperties := false
for _, note := range item.Notes {
@ -137,6 +138,9 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
if strings.Contains(note, "DEADLINE:") {
hasDeadline = true
}
if strings.Contains(note, "CLOSED:") {
hasClosed = true
}
if strings.Contains(note, ":LOGBOOK:") {
hasLogbook = true
}
@ -145,6 +149,13 @@ func writeItem(writer *bufio.Writer, item *model.Item) error {
}
}
if item.Closed != nil && !hasClosed {
closedLine := fmt.Sprintf("CLOSED: [%s]\n", formatClockTimestamp(*item.Closed))
if _, err := writer.WriteString(closedLine); err != nil {
return err
}
}
if item.Scheduled != nil && !hasScheduled {
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled))
if _, err := writer.WriteString(scheduledLine); err != nil {

View file

@ -112,7 +112,7 @@ func (m *uiModel) updateScrollOffset(availableHeight int) {
noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
highlightedNotes := renderNotesWithHighlighting(wrappedNotes)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount

View file

@ -726,6 +726,7 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
// Find current state index
currentIndex := -1
currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state
if currentState == "" {
@ -739,15 +740,51 @@ func (m *uiModel) cycleStateForward(item *model.Item) {
}
}
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle forward
if currentIndex < 0 || currentIndex >= len(stateNames)-1 {
if currentIndex == len(stateNames)-1 {
item.State = model.TodoState("") // Back to empty
newState = "" // Back to empty
} else {
item.State = model.TodoState(stateNames[0]) // First state
newState = stateNames[0] // First state
}
} else {
item.State = model.TodoState(stateNames[currentIndex+1])
newState = stateNames[currentIndex+1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
}
@ -760,6 +797,7 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
// Find current state index
currentIndex := -1
currentState := string(item.State)
lastStateIndex := len(stateNames) - 1
// Handle empty state
if currentState == "" {
@ -773,13 +811,49 @@ func (m *uiModel) cycleStateBackward(item *model.Item) {
}
}
// Store the old state to check if we're transitioning to/from DONE
oldState := currentState
var newState string
// Cycle backward
if currentIndex <= 0 {
item.State = model.TodoState("") // Empty state
newState = "" // Empty state
} else if currentIndex > len(stateNames) {
item.State = model.TodoState(stateNames[len(stateNames)-1])
newState = stateNames[len(stateNames)-1]
} else {
item.State = model.TodoState(stateNames[currentIndex-1])
newState = stateNames[currentIndex-1]
}
// Update the item state
item.State = model.TodoState(newState)
// Manage CLOSED timestamp
wasInDoneState := (oldState == stateNames[lastStateIndex])
isInDoneState := (newState == stateNames[lastStateIndex])
if isInDoneState && !wasInDoneState {
// Moving TO done state - add CLOSED timestamp
now := time.Now()
item.Closed = &now
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
} else if wasInDoneState && !isInDoneState {
// Moving FROM done state - remove CLOSED timestamp
item.Closed = nil
// Remove any existing CLOSED line from notes
var filteredNotes []string
for _, note := range item.Notes {
if !strings.HasPrefix(strings.TrimSpace(note), "CLOSED:") {
filteredNotes = append(filteredNotes, note)
}
}
item.Notes = filteredNotes
}
}

View file

@ -13,7 +13,8 @@ import (
type settingsSection int
const (
settingsSectionTags settingsSection = iota
settingsSectionGeneral settingsSection = iota
settingsSectionTags
settingsSectionStates
settingsSectionKeybindings
)
@ -76,7 +77,7 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Left):
// Previous section
if m.settingsSection > settingsSectionTags {
if m.settingsSection > settingsSectionGeneral {
m.settingsSection--
m.settingsCursor = 0
m.settingsScroll = 0
@ -101,6 +102,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Capture):
// Add new tag or state
switch m.settingsSection {
case settingsSectionGeneral:
// No capture action in General
case settingsSectionTags:
m.addNewTag()
case settingsSectionStates:
@ -128,6 +131,8 @@ func (m *uiModel) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) {
// getSettingsItemCount returns the number of items in the current settings view
func (m *uiModel) getSettingsItemCount() int {
switch m.settingsSection {
case settingsSectionGeneral:
return 3 // Org syntax highlighting toggle, show indentation guides toggle, indentation guide color
case settingsSectionTags:
return len(m.config.Tags.Tags) + 1 // +1 for "Add new tag" option
case settingsSectionStates:
@ -172,6 +177,40 @@ func (m *uiModel) updateSettingsScrollOffset() {
// startSettingsEdit starts editing a settings item
func (m *uiModel) startSettingsEdit() {
switch m.settingsSection {
case settingsSectionGeneral:
// Setting 0: Toggle org syntax highlighting
if m.settingsCursor == 0 {
m.config.UI.OrgSyntaxHighlighting = !m.config.UI.OrgSyntaxHighlighting
if m.config.UI.OrgSyntaxHighlighting {
m.setStatus("Org syntax highlighting enabled (saved)")
} else {
m.setStatus("Org syntax highlighting disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 1: Toggle show indentation guides
if m.settingsCursor == 1 {
m.config.UI.ShowIndentationGuides = !m.config.UI.ShowIndentationGuides
if m.config.UI.ShowIndentationGuides {
m.setStatus("Indentation guides enabled (saved)")
} else {
m.setStatus("Indentation guides disabled (saved)")
}
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
}
}
// Setting 2: Edit indentation guide color
if m.settingsCursor == 2 {
m.textinput.SetValue(m.config.UI.IndentationGuideColor)
m.textinput.Placeholder = "Enter color (e.g., 245, 99)"
m.textinput.Focus()
}
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -237,6 +276,23 @@ func (m *uiModel) startSettingsEdit() {
// saveSettingsEdit saves the edited value and auto-saves to disk
func (m *uiModel) saveSettingsEdit() {
switch m.settingsSection {
case settingsSectionGeneral:
// Setting 2: Indentation guide color
if m.settingsCursor == 2 {
newColor := strings.TrimSpace(m.textinput.Value())
if newColor != "" {
m.config.UI.IndentationGuideColor = newColor
m.setStatus(fmt.Sprintf("Indentation guide color set to '%s' (saved)", newColor))
// Auto-save
if err := m.config.Save(); err != nil {
m.setStatus(fmt.Sprintf("Error auto-saving config: %v", err))
} else {
// Reload styles
m.styles = newStyleMapFromConfig(m.config)
}
}
}
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -350,6 +406,9 @@ func (m *uiModel) saveSettingsEdit() {
// deleteSettingsItem deletes the current settings item and auto-saves
func (m *uiModel) deleteSettingsItem() {
switch m.settingsSection {
case settingsSectionGeneral:
// Cannot delete general settings
return
case settingsSectionTags:
if m.settingsCursor >= len(m.config.Tags.Tags) {
return
@ -437,6 +496,12 @@ func (m *uiModel) viewSettings() string {
activeTabStyle := lipgloss.NewStyle().Padding(0, 2).Bold(true).Foreground(lipgloss.Color(m.config.Colors.Title))
tabs := ""
if m.settingsSection == settingsSectionGeneral {
tabs += activeTabStyle.Render("[General]")
} else {
tabs += tabStyle.Render("General")
}
tabs += " "
if m.settingsSection == settingsSectionTags {
tabs += activeTabStyle.Render("[Tags]")
} else {
@ -459,6 +524,8 @@ func (m *uiModel) viewSettings() string {
// Instructions
var instructions string
switch m.settingsSection {
case settingsSectionGeneral:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Toggle setting\nctrl+s: Save • q/,: Exit"
case settingsSectionTags:
instructions = "←/→: Switch tabs • ↑/↓: Navigate • Enter: Edit • D: Delete\nc: Add new tag • ctrl+s: Save • q/,: Exit"
case settingsSectionStates:
@ -470,6 +537,8 @@ func (m *uiModel) viewSettings() string {
// Render the appropriate section
switch m.settingsSection {
case settingsSectionGeneral:
content.WriteString(m.viewSettingsGeneral())
case settingsSectionTags:
content.WriteString(m.viewSettingsTags())
case settingsSectionStates:
@ -488,6 +557,68 @@ func (m *uiModel) viewSettings() string {
return content.String()
}
// viewSettingsGeneral renders the general settings section
func (m *uiModel) viewSettingsGeneral() string {
var content strings.Builder
// Calculate visible window
reservedLines := 10
if m.textinput.Focused() {
reservedLines += 3
}
availableHeight := m.height - reservedLines
if availableHeight < 3 {
availableHeight = 3
}
enabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Bold(true)
disabledStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
colorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.config.UI.IndentationGuideColor))
// Setting 0: Org syntax highlighting toggle
line := ""
if m.settingsCursor == 0 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Org syntax highlighting: "
if m.config.UI.OrgSyntaxHighlighting {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 1: Show indentation guides toggle
line = ""
if m.settingsCursor == 1 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Show indentation guides: "
if m.config.UI.ShowIndentationGuides {
line += enabledStyle.Render("Enabled")
} else {
line += disabledStyle.Render("Disabled")
}
content.WriteString(line + "\n")
// Setting 2: Indentation guide color
line = ""
if m.settingsCursor == 2 && !m.textinput.Focused() {
line += "▶ "
} else {
line += " "
}
line += "Indentation guide color: "
line += colorStyle.Render(m.config.UI.IndentationGuideColor)
content.WriteString(line + "\n")
return content.String()
}
// viewSettingsTags renders the tags section
func (m *uiModel) viewSettingsTags() string {
var content strings.Builder

View file

@ -148,17 +148,24 @@ func (m uiModel) View() string {
for i, item := range items {
lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
// 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 := renderNotesWithHighlighting(wrappedNotes)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
lineCount += len(highlightedNotes)
}
itemLineCount[i] = lineCount
@ -215,17 +222,24 @@ func (m uiModel) View() string {
// Render remaining notes
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
// 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 := renderNotesWithHighlighting(wrappedNotes)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for noteIdx := linesToSkip - 1; noteIdx < len(highlightedNotes) && itemLines < availableHeight; noteIdx++ {
content.WriteString(indent)
content.WriteString(" " + highlightedNotes[noteIdx])
@ -245,17 +259,24 @@ func (m uiModel) View() string {
// Show notes if not folded
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
// Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("235"))
// 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 := renderNotesWithHighlighting(wrappedNotes)
highlightedNotes := m.renderNotesWithHighlighting(wrappedNotes)
for _, note := range highlightedNotes {
if itemLines >= availableHeight {
break
@ -626,8 +647,8 @@ func filterLogbookDrawer(notes []string) []string {
continue
}
// Skip SCHEDULED and DEADLINE lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") {
// Skip SCHEDULED, DEADLINE, and CLOSED lines
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") || strings.HasPrefix(trimmed, "CLOSED:") {
continue
}
@ -661,7 +682,7 @@ func wrapNoteLines(notes []string, width int, indent string) []string {
}
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
func renderNotesWithHighlighting(notes []string) []string {
func (m uiModel) renderNotesWithHighlighting(notes []string) []string {
if len(notes) == 0 {
return notes
}
@ -761,10 +782,16 @@ func renderNotesWithHighlighting(notes []string) []string {
// 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 {
@ -894,7 +921,8 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
var b strings.Builder
// Indentation with subtle visual nesting guides
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // Very subtle gray
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
@ -904,6 +932,12 @@ func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
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 {