diff --git a/editline/editline.go b/editline/editline.go index 6729a79..0f059b4 100644 --- a/editline/editline.go +++ b/editline/editline.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/knz/bubbline/complete" "github.com/knz/bubbline/editline/internal/textarea" + "github.com/knz/bubbline/highlight" rw "github.com/mattn/go-runewidth" "github.com/muesli/reflow/wordwrap" ) @@ -195,6 +196,9 @@ type Model struct { // Only takes effect at Reset() or Focus(). ShowLineNumbers bool + // Highlighter is the syntax highlighting function to use. + Highlighter highlight.Highlighter + // externalEditorExt is the extension to use when creating a temporary file for // an external editor. externalEditorExt string @@ -342,6 +346,7 @@ func (m *Model) Focus() tea.Cmd { m.text.ShowLineNumbers = m.ShowLineNumbers m.text.FocusedStyle = m.FocusedStyle.Editor m.text.BlurredStyle = m.BlurredStyle.Editor + m.text.Highlighter = m.Highlighter m.updatePrompt() m.hctrl.pattern.PromptStyle = m.FocusedStyle.SearchInput.PromptStyle m.hctrl.pattern.TextStyle = m.FocusedStyle.SearchInput.TextStyle diff --git a/editline/internal/textarea/textarea.go b/editline/internal/textarea/textarea.go index 5f60df1..05cf4bf 100644 --- a/editline/internal/textarea/textarea.go +++ b/editline/internal/textarea/textarea.go @@ -18,7 +18,9 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/knz/bubbline/highlight" rw "github.com/mattn/go-runewidth" + "github.com/muesli/reflow/ansi" ) const ( @@ -190,6 +192,10 @@ type Model struct { // there's no limit. MaxWidth int + // Highlighter is an interface that takes a line of text and returns + // a slice of styled tokens. + Highlighter highlight.Highlighter + // If promptFunc is set, it replaces Prompt as a generator for // prompt strings at the beginning of each line. promptFunc func(line int) string @@ -1102,8 +1108,6 @@ func (m Model) View() string { var style lipgloss.Style lineInfo := m.LineInfo() - var newLines int - displayLine := 0 for l, line := range m.value { wrappedLines := wrap(line, m.width) @@ -1115,6 +1119,7 @@ func (m Model) View() string { } for wl, wrappedLine := range wrappedLines { + // Standard prompt and line number rendering prompt := m.getPromptString(displayLine) prompt = m.style.Prompt.Render(prompt) s.WriteString(style.Render(prompt)) @@ -1132,35 +1137,69 @@ func (m Model) View() string { } } - strwidth := rw.StringWidth(string(wrappedLine)) - padding := m.width - strwidth - // If the trailing space causes the line to be wider than the - // width, we should not draw it to the screen since it will result - // in an extra space at the end of the line which can look off when - // the cursor line is showing. - if strwidth > m.width { - // The character causing the line to be wider than the width is - // guaranteed to be a space since any other character would - // have been wrapped. - wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) - padding -= m.width - strwidth - } - if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) - if m.col >= len(line) && lineInfo.CharOffset >= m.width { + // Token-Based Rendering + isCursorLine := (m.row == l && lineInfo.RowOffset == wl) + plainLine := string(wrappedLine) + + // We build the line's visible content in a temporary builder to measure it later. + var lineContentBuilder strings.Builder + + if isCursorLine && m.Highlighter != nil { + tokens := m.Highlighter.Highlight(plainLine) + charCount := 0 + cursorPlaced := false + + for _, token := range tokens { + if !cursorPlaced && charCount+len(token.Value) > lineInfo.ColumnOffset { + // The cursor is in this token. Split it. + splitIndex := lineInfo.ColumnOffset - charCount + if splitIndex < 0 { + splitIndex = 0 + } + + beforeCursor := token.Value[:splitIndex] + atCursor := "" + afterCursor := "" + if len(token.Value) > splitIndex { + atCursor = string(token.Value[splitIndex]) + afterCursor = token.Value[splitIndex+1:] + } + + lineContentBuilder.WriteString(token.Style.Render(beforeCursor)) + m.Cursor.SetChar(atCursor) + lineContentBuilder.WriteString(m.Cursor.View()) + lineContentBuilder.WriteString(token.Style.Render(afterCursor)) + + cursorPlaced = true + } else { + lineContentBuilder.WriteString(token.Style.Render(token.Value)) + } + charCount += rw.StringWidth(token.Value) + } + if !cursorPlaced && charCount == lineInfo.ColumnOffset { m.Cursor.SetChar(" ") - s.WriteString(m.Cursor.View()) - } else { - m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) - s.WriteString(style.Render(m.Cursor.View())) - s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) + lineContentBuilder.WriteString(m.Cursor.View()) + } + + } else if m.Highlighter != nil { + tokens := m.Highlighter.Highlight(plainLine) + for _, token := range tokens { + lineContentBuilder.WriteString(token.Style.Render(token.Value)) } } else { - s.WriteString(style.Render(string(wrappedLine))) + lineContentBuilder.WriteString(plainLine) } - s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) + + // Now that the line's content is built, measure its visible width using the ansi package. + lineContentString := lineContentBuilder.String() + visibleWidth := ansi.PrintableRuneWidth(lineContentString) + + s.WriteString(lineContentString) + + // Calculate and write padding based on the correct visible width. + padding := m.width - visibleWidth + s.WriteString(strings.Repeat(" ", max(0, padding))) s.WriteRune('\n') - newLines++ } } diff --git a/examples/live-highlight/main.go b/examples/live-highlight/main.go new file mode 100644 index 0000000..e95b8dc --- /dev/null +++ b/examples/live-highlight/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/knz/bubbline" + "github.com/knz/bubbline/highlight" +) + +// simpleHighlighter is an example implementation of the highlight.Highlighter interface. +// It provides live highlighting for a few keywords. +type simpleHighlighter struct { + // Define complete styles for each token type. + styleKeyword lipgloss.Style + styleNumber lipgloss.Style + styleDefault lipgloss.Style +} + +// newSimpleHighlighter creates our highlighter with its styles pre-defined. +func newSimpleHighlighter() *simpleHighlighter { + return &simpleHighlighter{ + styleKeyword: lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true), // Blue and Bold + styleNumber: lipgloss.NewStyle().Foreground(lipgloss.Color("35")), // Magenta + styleDefault: lipgloss.NewStyle(), // Use the terminal's default foreground color + } +} + +// Highlight tokenizes the line and applies styles. +func (h *simpleHighlighter) Highlight(line string) []highlight.Token { + var tokens []highlight.Token + + // This logic preserves spaces by finding words and the gaps between them. + var lastPos int + for _, word := range strings.Fields(line) { + idx := strings.Index(line[lastPos:], word) + // Add the whitespace before the word as a plain token. + if idx > 0 { + tokens = append(tokens, highlight.Token{ + Value: line[lastPos : lastPos+idx], + Style: h.styleDefault, + }) + } + + // --- Start with the default style for every word --- + finalStyle := h.styleDefault + + // --- Apply a specific style only if it matches a category --- + upperWord := strings.ToUpper(word) + if upperWord == "SELECT" || upperWord == "FROM" || upperWord == "WHERE" { + finalStyle = h.styleKeyword + } else if _, err := strconv.Atoi(word); err == nil { + finalStyle = h.styleNumber + } + + // Add the word itself with its determined style. + tokens = append(tokens, highlight.Token{ + Value: word, + Style: finalStyle, + }) + lastPos += idx + len(word) + } + // Add any trailing whitespace. + if lastPos < len(line) { + tokens = append(tokens, highlight.Token{ + Value: line[lastPos:], + Style: h.styleDefault, + }) + } + + return tokens +} + +func main() { + fmt.Println("Live highlighter example. Type 'SELECT' or 'FROM' to see live highlighting. Ctrl+D to exit.") + + m := bubbline.New() + + // 1. Instantiate our highlighter using the constructor. + highlighter := newSimpleHighlighter() + + // 2. Set it on the bubbline editor instance. + m.SetHighlighter(highlighter) + + // 3. Run the editor. + for { + val, err := m.GetLine() + + if err == io.EOF { + fmt.Println("\nBye!") + break + } + if err != nil { + fmt.Println("error:", err) + break + } + fmt.Printf("\nYou entered: %q\n", val) + } +} diff --git a/getline.go b/getline.go index 597ac99..16f3765 100644 --- a/getline.go +++ b/getline.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/knz/bubbline/complete" "github.com/knz/bubbline/editline" + "github.com/knz/bubbline/highlight" "github.com/knz/bubbline/history" ) @@ -29,6 +30,11 @@ func New() *Editor { var _ tea.Model = (*Editor)(nil) +// SetHighlighter sets the syntax highlighting implementation for the editor. +func (m *Editor) SetHighlighter(h highlight.Highlighter) { + m.Model.Highlighter = h +} + // Update is part of the tea.Model interface. func (m *Editor) Update(imsg tea.Msg) (tea.Model, tea.Cmd) { switch msg := imsg.(type) { diff --git a/highlight/highlight.go b/highlight/highlight.go new file mode 100644 index 0000000..0481831 --- /dev/null +++ b/highlight/highlight.go @@ -0,0 +1,16 @@ +package highlight + +import "github.com/charmbracelet/lipgloss" + +// Token represents a single styled segment of text. +type Token struct { + Value string + Style lipgloss.Style +} + +// Highlighter is the interface for a syntax highlighter that +// can tokenize a line of text. +type Highlighter interface { + // Highlight takes a line of text and returns a slice of styled Tokens. + Highlight(line string) []Token +}