Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions editline/editline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
91 changes: 65 additions & 26 deletions editline/internal/textarea/textarea.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand All @@ -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++
}
}

Expand Down
102 changes: 102 additions & 0 deletions examples/live-highlight/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions getline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions highlight/highlight.go
Original file line number Diff line number Diff line change
@@ -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
}