diff --git a/cmd/gim/main.go b/cmd/gim/main.go index 83a84f5..521a923 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -4,12 +4,17 @@ import ( "os" "git.gophernest.net/azpect/TextEditor/internal/program" + "git.gophernest.net/azpect/TextEditor/internal/theme" tea "github.com/charmbracelet/bubbletea" ) // main: Entry point for the Gim text editor. Creates a buffer and window, // initializes the editor model, and runs the BubbleTea TUI program. func main() { + if err := theme.RegisterAll(); err != nil { + panic(err) + } + // args := os.Args[1:] diff --git a/go.mod b/go.mod index 18d02b4..1b8f938 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.gophernest.net/azpect/TextEditor go 1.25.5 require ( + github.com/alecthomas/chroma/v2 v2.23.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20260209132835-6b065b8ba62c @@ -19,6 +20,7 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 3388b18..1c426f4 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -24,8 +30,12 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/core/window_builder.go b/internal/core/window_builder.go index 53a1de6..e17ac58 100644 --- a/internal/core/window_builder.go +++ b/internal/core/window_builder.go @@ -4,108 +4,108 @@ package core var CurrentWindowId int = 1000 type WindowBuilder struct { - window Window + window Window } // NewWindowBuilder: Creates a new window builder. The window builder implements a // builder pattern to create a window with the defined properties and values. func NewWindowBuilder() *WindowBuilder { - return &WindowBuilder{ - window: Window{ - Id: 0, // This is set when built - Number: 1, // Ignored for now, will be used for splits - Buffer: nil, - Cursor: Position{Line: 0, Col: 0}, - Anchor: Position{Line: 0, Col: 0}, - ScrollY: 0, - Height: 0, - Width: 0, - Options: NewDefaultWinOptions(), - }, - } + return &WindowBuilder{ + window: Window{ + Id: 0, // This is set when built + Number: 1, // Ignored for now, will be used for splits + Buffer: nil, + Cursor: Position{Line: 0, Col: 0}, + Anchor: Position{Line: 0, Col: 0}, + ScrollY: 0, + Height: 0, + Width: 0, + Options: NewDefaultWinOptions(), + }, + } } // WindowBuilder.WithNumber: Attaches a window number to the window that is being built. // Window numbers are position-based and change when windows are rearranged. This is // ignored for now, but will be used when splits are implemented. func (w *WindowBuilder) WithNumber(number int) *WindowBuilder { - w.window.Number = number - return w + w.window.Number = number + return w } // WindowBuilder.WithBuffer: Attaches a buffer to the window that is being built. The // window will display and edit the content of this buffer. func (w *WindowBuilder) WithBuffer(buffer *Buffer) *WindowBuilder { - w.window.Buffer = buffer - return w + w.window.Buffer = buffer + return w } // WindowBuilder.WithCursor: Sets the cursor position in the window that is being built. func (w *WindowBuilder) WithCursor(cursor Position) *WindowBuilder { - w.window.Cursor = cursor - return w + w.window.Cursor = cursor + return w } // WindowBuilder.WithCursorPos: Sets the cursor position in the window that is being built. // This is an alias for WithCursor that accepts line and column separately. func (w *WindowBuilder) WithCursorPos(line, col int) *WindowBuilder { - w.window.Cursor = Position{Line: line, Col: col} - return w + w.window.Cursor = Position{Line: line, Col: col} + return w } // WindowBuilder.WithAnchor: Sets the anchor position in the window that is being built. // The anchor is used for visual mode selections. func (w *WindowBuilder) WithAnchor(anchor Position) *WindowBuilder { - w.window.Anchor = anchor - return w + w.window.Anchor = anchor + return w } // WindowBuilder.WithAnchorPos: Sets the anchor position in the window that is being built. // This is an alias for WithAnchor that accepts line and column separately. func (w *WindowBuilder) WithAnchorPos(line, col int) *WindowBuilder { - w.window.Anchor = Position{Line: line, Col: col} - return w + w.window.Anchor = Position{Line: line, Col: col} + return w } // WindowBuilder.WithScrollY: Sets the vertical scroll offset of the window that is being built. func (w *WindowBuilder) WithScrollY(scrollY int) *WindowBuilder { - w.window.ScrollY = scrollY - return w + w.window.ScrollY = scrollY + return w } // WindowBuilder.WithHeight: Sets the height of the window that is being built. func (w *WindowBuilder) WithHeight(height int) *WindowBuilder { - w.window.Height = height - return w + w.window.Height = height + return w } // WindowBuilder.WithWidth: Sets the width of the window that is being built. func (w *WindowBuilder) WithWidth(width int) *WindowBuilder { - w.window.Width = width - return w + w.window.Width = width + return w } // WindowBuilder.WithDimensions: Sets both width and height of the window that is being built. // This is a convenience method for setting dimensions in one call. func (w *WindowBuilder) WithDimensions(width, height int) *WindowBuilder { - w.window.Width = width - w.window.Height = height - return w + w.window.Width = width + w.window.Height = height + return w } // WindowBuilder.WithOptions: Applies the options to the window that is being built. // This is a convenience method for setting all options in one call. func (w *WindowBuilder) WithOptions(options WinOptions) *WindowBuilder { - w.window.Options = options - return w + w.window.Options = options + return w } // WindowBuilder.Build: Build the final window and return it to the caller. Final // step in the process. This is where the ID is set, so many windows can be "in-progress" // but the ID will be set when they are built. Meaning, this is not thread safe. func (w *WindowBuilder) Build() Window { - w.window.Id = CurrentWindowId - CurrentWindowId++ + w.window.Id = CurrentWindowId + CurrentWindowId++ - return w.window + return w.window } diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index 12fe923..ea7c435 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -4,13 +4,86 @@ import ( "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/input" "git.gophernest.net/azpect/TextEditor/internal/style" + "github.com/alecthomas/chroma/v2/styles" ) type ModelBuilder struct { model Model } +// RPGLE +// abap +// algol +// algol_nu +// arduino +// ashen +// aura-theme-dark +// aura-theme-dark-soft +// autumn +// average +// base16-snazzy +// borland +// bw +// catppuccin-frappe +// catppuccin-latte +// catppuccin-macchiato +// catppuccin-mocha +// colorful +// doom-one +// doom-one2 +// dracula +// emacs +// evergarden +// friendly +// fruity +// github +// github-dark +// gruvbox +// gruvbox-light +// hr_high_contrast +// hrdark +// igor +// lovelace +// manni +// modus-operandi +// modus-vivendi +// monokai +// monokailight +// murphy +// native +// nord +// nordic +// onedark +// onesenterprise +// paraiso-dark +// paraiso-light +// pastie +// perldoc +// pygments +// rainbow_dash +// rose-pine +// rose-pine-dawn +// rose-pine-moon +// rrt +// solarized-dark +// solarized-dark256 +// solarized-light +// swapoff +// tango +// tokyonight-day +// tokyonight-moon +// tokyonight-night +// tokyonight-storm +// trac +// vim +// vs +// vulcan +// witchhazel +// xcode +// xcode-dark func NewModelBuilder() *ModelBuilder { + chromaStyle := styles.Get("kanagawa-wave") + return &ModelBuilder{ model: Model{ buffers: []*core.Buffer{}, @@ -28,7 +101,7 @@ func NewModelBuilder() *ModelBuilder { commandOutput: nil, settings: core.NewDefaultSettings(), registers: core.DefaultRegisters(), - styles: style.DefaultStyles(), + styles: style.ChromaStyles(chromaStyle), }, } } diff --git a/internal/editor/view.go b/internal/editor/view.go index ec30a68..0f3ce93 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -6,6 +6,8 @@ import ( "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/style" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/lipgloss" ) @@ -49,17 +51,25 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo start := w.ScrollY end := w.ScrollY + w.ViewportHeight() + // Chroma stuff + name := strings.ReplaceAll(buf.Filetype, ".", "") + lexer := lexers.Get(name) + lexer = chroma.Coalesce(lexer) // Merge tokens together + // Draw buffer lines for lineNum := start; lineNum < end; lineNum++ { if lineNum < buf.LineCount() { - line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum) + styleMap := styles.MakeStyleMap(lexer, buf.Line(lineNum)) + line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap) view.WriteString(line) + } else { + view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), w.Width)) } view.WriteRune('\n') } // Draw status line - statusBar := drawStatusBar(w, mode) + statusBar := drawStatusBar(w, mode, styles) view.WriteString(statusBar + "\n") return view.String() @@ -67,13 +77,9 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo // drawLine: Renders a single line with syntax highlighting, cursor, and visual selection. // Handles gutter, cursor rendering, and visual mode highlighting. -func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int) string { - runes := []rune(line) - - curStyle := styles.CursorStyle(mode) - visStyle := styles.VisualHighlight - +func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, line string, lineNumber int, styleMap []lipgloss.Style) string { var view strings.Builder + runes := []rune(line) // Draw gutter first gutter := drawGutter(w, styles, options, lineNumber) @@ -84,24 +90,31 @@ func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode // Current char is cursor if col == w.Cursor.Col && lineNumber == w.Cursor.Line { if col < len(runes) { - view.WriteString(curStyle.Render(string(runes[col]))) + cur := styles.CursorStyle(mode, styleMap[col]) + view.WriteString(cur.Render(string(runes[col]))) } else { - view.WriteString(curStyle.Render(" ")) + view.WriteString(styles.DefaultCursorStyle(mode).Render(" ")) } // Not cursor, but not end } else if col < len(runes) { + s := styleMap[col] if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { - view.WriteString(visStyle.Render(string(runes[col]))) + vis := styles.VisualHighlightWithTextColor(s) + view.WriteString(vis.Render(string(runes[col]))) } else { - view.WriteRune(runes[col]) + view.WriteString(s.Render(string(runes[col]))) } // Allow highlight on blank lines or chars } else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { - view.WriteString(visStyle.Render(" ")) + view.WriteString(styles.VisualHighlight.Render(" ")) } } + // Color the overflow + dif := w.Width - lipgloss.Width(line) + view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), dif)) + return view.String() } @@ -153,9 +166,9 @@ func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, cu // drawStatusBar: Renders the status bar with mode and cursor position, // padding the middle with spaces to fill the terminal width. -func drawStatusBar(w *core.Window, mode core.Mode) string { - left := leftBar(w, mode) - right := rightBar(w, mode) +func drawStatusBar(w *core.Window, mode core.Mode, styles style.Styles) string { + left := leftBar(w, mode, styles) + right := rightBar(w, mode, styles) diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right)) @@ -164,12 +177,12 @@ func drawStatusBar(w *core.Window, mode core.Mode) string { return "" } - middle := strings.Repeat(" ", diff) + middle := strings.Repeat(styles.BackgroundStyle.Render(" "), diff) return left + middle + right } // leftBar: Returns the left side of the status bar showing the current mode. -func leftBar(w *core.Window, mode core.Mode) string { +func leftBar(w *core.Window, mode core.Mode, styles style.Styles) string { buf := w.Buffer var flags []string @@ -185,12 +198,13 @@ func leftBar(w *core.Window, mode core.Mode) string { flagStr = "(" + strings.Join(flags, "") + ")" } - return fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr) + bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr) + return styles.LineStyle.Render(bar) } // rightBar: Returns the right side of the status bar showing cursor position // and selection count in visual mode. -func rightBar(w *core.Window, mode core.Mode) (bar string) { +func rightBar(w *core.Window, mode core.Mode, styles style.Styles) (bar string) { if mode.IsVisualMode() { lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1 bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount) @@ -198,41 +212,44 @@ func rightBar(w *core.Window, mode core.Mode) (bar string) { bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1) } buf := w.Buffer - bar = fmt.Sprintf("%s %s", buf.Filetype, bar) + bar = styles.LineStyle.Render(fmt.Sprintf("%s %s", buf.Filetype, bar)) return } // drawCommandBar: Renders the command line showing command input, errors, or // output depending on the current mode and state. func drawCommandBar(m Model) string { + styles := m.Styles() + // Compute left bar (command side) var leftBar string if m.Mode() == core.CommandMode { - leftBar = ":" + leftBar = styles.LineStyle.Render(":") cmd := []rune(m.Command()) cur := m.CommandCursor() for i, r := range cmd { if i == cur { - leftBar += m.Styles().CursorStyle(m.Mode()).Render(string(r)) + leftBar += styles.DefaultCursorStyle(m.Mode()).Render(string(r)) } else { - leftBar += string(r) + leftBar += styles.LineStyle.Render(string(r)) } } // Cursor at end of command if cur >= len(cmd) { - leftBar += m.Styles().CursorStyle(m.Mode()).Render(" ") + leftBar += styles.DefaultCursorStyle(m.Mode()).Render(" ") } // bar = fmt.Sprintf("%s %d", bar, cur) } else if out := m.CommandOutput(); out != nil && len(out.Lines) > 0 && out.Inline { // TODO: This is not perfect, temporary text := strings.Join(out.Lines, " ") if out.IsError { - leftBar = m.Styles().CommandError.Render(text) + leftBar = styles.CommandError.Render(text) } else { - leftBar = text + leftBar = styles.LineStyle.Render(text) } } else if strings.TrimSpace(m.Command()) != "" { - leftBar = fmt.Sprintf(":%s", m.Command()) + content := fmt.Sprintf(":%s", m.Command()) + leftBar = styles.LineStyle.Render(content) // } // Compute right bar @@ -240,12 +257,13 @@ func drawCommandBar(m Model) string { var rightBar string if len(m.input.Pending()) > 0 { width := 10 // Size of the block to display - rightBar = fmt.Sprintf("%-*s", width, m.input.Pending()) + content := fmt.Sprintf("%-*s", width, m.input.Pending()) + rightBar = styles.LineStyle.Render(content) } dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar)) - bar := leftBar + strings.Repeat(" ", max(0, dif)) + rightBar + bar := leftBar + strings.Repeat(styles.BackgroundStyle.Render(" "), max(0, dif)) + rightBar return bar } @@ -310,10 +328,12 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty overlay = append(overlay, styles.CommandOutputBorder.Render(strings.Repeat(" ", termWidth))) if strings.TrimSpace(cmd.Title) != "" { - overlay = append(overlay, cmd.Title) + title := styles.LineStyle.Render(cmd.Title) + overlay = append(overlay, title) } for _, l := range cmd.Lines { - overlay = append(overlay, strings.ReplaceAll(l, "\n", "\\n")) + content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n")) + overlay = append(overlay, content) } overlay = append(overlay, styles.CommandContinueMessage.Render(core.CommandOutputExitMessage)) @@ -321,6 +341,12 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty // which would cause Lipgloss to embed newlines internally and corrupt the line count. // If block-level styles are ever added, this approach must be replaced. + // Add background color to end of each line + for i, l := range overlay { + dif := termWidth - lipgloss.Width(l) + overlay[i] += styles.BackgroundStyle.Render(strings.Repeat(" ", dif)) + } + // Remove 'h' lines from back of view and append overlay h := len(overlay) final := lines[:max(0, len(lines)-h)] diff --git a/internal/program/program_builder.go b/internal/program/program_builder.go index 6076057..16f4cb2 100644 --- a/internal/program/program_builder.go +++ b/internal/program/program_builder.go @@ -46,6 +46,7 @@ func (p *ProgramBuilder) FileProgram(filename string) *ProgramBuilder { WithType(core.FileBuffer). WithFilename(filename). WithFiletype(ext). + Listed(). Build() win := core.NewWindowBuilder(). diff --git a/internal/style/style.go b/internal/style/style.go old mode 100644 new mode 100755 index 247f041..c5be718 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -2,6 +2,7 @@ package style import ( "git.gophernest.net/azpect/TextEditor/internal/core" + "github.com/alecthomas/chroma/v2" "github.com/charmbracelet/lipgloss" ) @@ -28,9 +29,16 @@ type Styles struct { CommandError lipgloss.Style CommandOutputBorder lipgloss.Style CommandContinueMessage lipgloss.Style + + // General Styles + LineStyle lipgloss.Style // This is a simple background with no text coloring + BackgroundStyle lipgloss.Style // This is just the background + + // Chroma data + chromaStyle *chroma.Style } -// DefaultStyles returns the default editor color scheme. +// DefaultStyles: Returns the default editor color scheme. func DefaultStyles() Styles { return Styles{ CursorNormal: lipgloss.NewStyle().Reverse(true), @@ -67,11 +75,86 @@ func DefaultStyles() Styles { CommandContinueMessage: lipgloss.NewStyle(). Foreground(lipgloss.Color("#546fba")), + + chromaStyle: nil, } } -// CursorStyle returns the appropriate cursor style for the given mode. -func (s Styles) CursorStyle(mode core.Mode) lipgloss.Style { +func ChromaStyles(chromaStyle *chroma.Style) Styles { + bgString := chromaStyle.Get(chroma.Background).Background.String() + lineNumbers := chromaStyle.Get(chroma.LineTableTD) + lineHighlight := chromaStyle.Get(chroma.LineHighlight) + + return Styles{ + CursorNormal: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Reverse(true), + + CursorInsert: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Underline(true), + + CursorCommand: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Reverse(true), + + Gutter: lipgloss.NewStyle(). + Background(lipgloss.Color( + darkenColor(lineNumbers.Background, 0.9).String()), + ). + Foreground(lipgloss.Color(lineNumbers.Colour.String())), + + GutterCurrentLine: lipgloss.NewStyle(). + Background(lipgloss.Color( + darkenColor(lineNumbers.Background, 0.9).String()), + ). + Foreground(lipgloss.Color(lineNumbers.Colour.String())), + + VisualHighlight: lipgloss.NewStyle(). + Background(lipgloss.Color(lineHighlight.Background.String())). + Foreground(lipgloss.Color(lineHighlight.Colour.String())), + + VisualAnchor: lipgloss.NewStyle(). + Background(lipgloss.Color(lineHighlight.Background.String())). + Foreground(lipgloss.Color(lineHighlight.Colour.String())), + + StatusBar: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Foreground(lipgloss.Color("243")), + + StatusBarActive: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Foreground(lipgloss.Color("230")), + + CommandError: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Foreground(lipgloss.Color("#e3203a")), + + CommandOutputBorder: lipgloss.NewStyle(). + Background( + lipgloss.Color( + darkenColor( + chromaStyle.Get(chroma.Background).Background, 0.5). + String(), + ), + ), + + CommandContinueMessage: lipgloss.NewStyle(). + Background(lipgloss.Color(bgString)). + Foreground(lipgloss.Color("#546fba")), + + LineStyle: lipgloss.NewStyle(). + Foreground(lipgloss.Color(chromaStyle.Get(chroma.Line).Colour.String())). + Background(lipgloss.Color(bgString)), + + BackgroundStyle: lipgloss.NewStyle().Background(lipgloss.Color(bgString)), + + chromaStyle: chromaStyle, + } +} + +// Styles.DefaultCursorStyle: Returns the appropriate cursor style for the given mode. +func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style { switch mode { case core.InsertMode: return s.CursorInsert @@ -81,3 +164,68 @@ func (s Styles) CursorStyle(mode core.Mode) lipgloss.Style { return s.CursorNormal } } + +// Styles.CursorStyle: Returns a cursor style derived from a chroma style. This function should preferred +// over the DefaultCursorStyle, but in cases where there is no style to apply, the DefaultCursorStyle +// will always work. +func (s Styles) CursorStyle(mode core.Mode, style lipgloss.Style) lipgloss.Style { + switch mode { + case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode: + return lipgloss.NewStyle(). + Background(style.GetForeground()). + Foreground(style.GetBackground()) + default: + return lipgloss.NewStyle(). + Background(s.BackgroundStyle.GetBackground()). + Foreground(style.GetForeground()). + Underline(true) + } +} + +// Styles.VisualHighlightWithTextColor: Works analogously to CursorStyle vs DefaultCursorStyle. When a +// style is available, this function should be used, so the text color will be rendered in front +// of the background. Otherwise, the VisualHighlight property will always work. +func (s Styles) VisualHighlightWithTextColor(style lipgloss.Style) lipgloss.Style { + return lipgloss.NewStyle(). + Background(s.VisualHighlight.GetBackground()). + Foreground(style.GetForeground()) +} + +// Styles.MakeStyleMap: Generates a style map for a single line. A style map is a mapping from +// column a lipgloss style. Cursor styles are not handled by this map, but they can be derived +// by inverting the background and foreground (and rolling back to the default). +func (s Styles) MakeStyleMap(lexer chroma.Lexer, line string) []lipgloss.Style { + m := make([]lipgloss.Style, len(line)) + + iter, err := lexer.Tokenise(nil, line) + if err != nil { + panic(err) + } + + col := 0 + for _, token := range iter.Tokens() { + entry := s.chromaStyle.Get(token.Type) + s := lipgloss.NewStyle(). + Background(lipgloss.Color(entry.Background.String())). + Foreground(lipgloss.Color(entry.Colour.String())) + for _, char := range token.Value { + if char == '\n' { + continue + } + if col < len(m) { + m[col] = s + } + col++ + } + } + + return m +} + +// darkenColor: Uses a factor (0.0 to 1.0) to darken a color using its opacity. +func darkenColor(c chroma.Colour, factor float64) chroma.Colour { + r := uint8(float64(c.Red()) * factor) + g := uint8(float64(c.Green()) * factor) + b := uint8(float64(c.Blue()) * factor) + return chroma.NewColour(r, g, b) +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..527e771 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,43 @@ +package theme + +import ( + "embed" + "fmt" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/styles" +) + +//go:embed themes/* +var themeFS embed.FS + +// RegisterAll: Registers all XML theme files embedded in the themes/ directory +// with chroma's style registry. After calling this, styles.Get() will recognize +// any theme defined in those files. +func RegisterAll() error { + entries, err := themeFS.ReadDir("themes") + if err != nil { + return fmt.Errorf("failed to read embedded themes directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + f, err := themeFS.Open("themes/" + entry.Name()) + if err != nil { + return fmt.Errorf("failed to open theme %s: %w", entry.Name(), err) + } + + style, err := chroma.NewXMLStyle(f) + f.Close() + if err != nil { + return fmt.Errorf("failed to parse theme %s: %w", entry.Name(), err) + } + + styles.Register(style) + } + + return nil +} diff --git a/internal/theme/themes/kanagawa-dragon.xml b/internal/theme/themes/kanagawa-dragon.xml new file mode 100644 index 0000000..114d165 --- /dev/null +++ b/internal/theme/themes/kanagawa-dragon.xml @@ -0,0 +1,83 @@ + diff --git a/internal/theme/themes/kanagawa-lotus.xml b/internal/theme/themes/kanagawa-lotus.xml new file mode 100644 index 0000000..dde3bc8 --- /dev/null +++ b/internal/theme/themes/kanagawa-lotus.xml @@ -0,0 +1,83 @@ + diff --git a/internal/theme/themes/kanagawa-wave.xml b/internal/theme/themes/kanagawa-wave.xml new file mode 100644 index 0000000..cebcda1 --- /dev/null +++ b/internal/theme/themes/kanagawa-wave.xml @@ -0,0 +1,83 @@ +