feat: created theme module

This means we can finally create some themes! But the treesitter mapping
is not complete.
This commit is contained in:
Hayden Hargreaves 2026-04-07 21:44:34 -07:00
parent 760770c564
commit 77416bc0a4
9 changed files with 217 additions and 44 deletions

View File

@ -4,17 +4,12 @@ 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)
}
// <exe> <filename>
args := os.Args[1:]

View File

@ -3,6 +3,7 @@ package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
@ -56,6 +57,8 @@ type Model interface {
SetSettings(s core.EditorSettings)
Styles() style.Styles
SetStyles(s style.Styles)
Theme() theme.EditorTheme
SetTheme(t theme.EditorTheme)
// ==================================================
// Registers

View File

@ -3,6 +3,7 @@ package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
@ -24,6 +25,7 @@ type MockModel struct {
CommandHistoryCur int
LastFindVal core.LastFindCommand
StylesVal style.Styles
ThemeVal theme.EditorTheme
LastChangeKeysList []string
}
@ -119,6 +121,8 @@ func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
func (m *MockModel) Styles() style.Styles { return m.StylesVal }
func (m *MockModel) SetStyles(s style.Styles) { m.StylesVal = s }
func (m *MockModel) Theme() theme.EditorTheme { return m.ThemeVal }
func (m *MockModel) SetTheme(t theme.EditorTheme) { m.ThemeVal = t }
// Registers
func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap }

View File

@ -9,6 +9,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
"git.gophernest.net/azpect/TextEditor/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
@ -52,6 +53,7 @@ type Model struct {
// Visual styles
styles style.Styles
theme theme.EditorTheme
syntax syntax.Engine
// Dot operator state
@ -351,6 +353,14 @@ func (m *Model) SetStyles(s style.Styles) {
m.styles = s
}
func (m *Model) Theme() theme.EditorTheme {
return m.theme
}
func (m *Model) SetTheme(t theme.EditorTheme) {
m.theme = t
}
func (m *Model) Syntax() syntax.Engine {
return m.syntax
}

View File

@ -5,6 +5,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
)
type ModelBuilder struct {
@ -34,6 +35,7 @@ func NewModelBuilder() *ModelBuilder {
registers: core.DefaultRegisters(),
styles: editorStyles,
syntax: syntax.NewTreeSitterEngine(editorStyles),
theme: themes.NewDefaultTheme(),
},
}
}

View File

@ -6,8 +6,8 @@ import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
"git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss"
)
@ -21,7 +21,7 @@ func (m Model) View() string {
// Each window has its own line numbers and gutter
// Each window has its own status bar and mode
styles := m.Styles()
t := m.Theme()
options := win.Options
// Adjust gutter to fit line len
@ -29,7 +29,7 @@ func (m Model) View() string {
options.GutterSize = max(options.GutterSize, maxLineLen+2)
// Draw window
view := viewWindow(win, styles, options, m.Mode(), m.Syntax())
view := viewWindow(win, t, options, m.Mode(), m.Syntax())
// Command bar is seperate
cmdBar := drawCommandBar(m)
@ -39,7 +39,7 @@ func (m Model) View() string {
// TODO: This is not idea, but it works for now
cmd := m.CommandOutput()
if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 {
view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth, m.termHeight)
view = overlayCommandOutputWindow(view, cmd, t, m.termWidth, m.termHeight)
}
return view
@ -47,7 +47,7 @@ func (m Model) View() string {
// viewWindow: Renders a single window's content including line numbers and buffer text.
// Each window has its own line numbers, gutter, and viewport dimensions.
func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mode core.Mode, sx syntax.Engine) string {
func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, sx syntax.Engine) string {
buf := w.Buffer
var view strings.Builder
if sx != nil {
@ -65,16 +65,16 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo
if sx != nil {
styleMap = sx.LineStyleMap(buf, lineNum)
}
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap)
line := drawLine(w, t, options, mode, buf.Line(lineNum), lineNum, styleMap)
view.WriteString(line)
} else {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), w.Width))
view.WriteString(strings.Repeat(t.Background.Render(" "), w.Width))
}
view.WriteRune('\n')
}
// Draw status line
statusBar := drawStatusBar(w, mode, styles)
statusBar := drawStatusBar(w, mode, t)
view.WriteString(statusBar + "\n")
return view.String()
@ -82,12 +82,12 @@ 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, styleMap []lipgloss.Style) string {
func drawLine(w *core.Window, t theme.EditorTheme, 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)
gutter := drawGutter(w, t, options, lineNumber)
view.WriteString(gutter)
// Now draw the line content
@ -95,31 +95,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) {
cur := styles.CursorStyle(mode, styleMap[col])
cur := t.Cursor(mode, styleMap[col])
view.WriteString(cur.Render(string(runes[col])))
} else {
view.WriteString(styles.DefaultCursorStyle(mode).Render(" "))
view.WriteString(t.DefaultCursor(mode).Render(" "))
}
// Not cursor, but not end
} else if col < len(runes) {
s := styleMap[col]
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
vis := styles.VisualHighlightWithTextColor(s)
vis := t.VisualHighlightWithTextColor(s)
view.WriteString(vis.Render(string(runes[col])))
} else {
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(styles.VisualHighlight.Render(" "))
view.WriteString(t.VisualHightlight.Render(" "))
}
}
// Pad remainder of line to window width with background color
dif := w.Width - lipgloss.Width(view.String())
if dif > 0 {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), dif))
view.WriteString(strings.Repeat(t.Background.Render(" "), dif))
}
return view.String()
@ -127,7 +127,7 @@ func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode
// drawGutter: Renders the line number gutter with support for both absolute and
// relative line numbers, highlighting the current line differently.
func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, curLine int) string {
func drawGutter(w *core.Window, t theme.EditorTheme, options core.WinOptions, curLine int) string {
if !(options.Number || options.RelativeNumber) {
return ""
}
@ -140,8 +140,8 @@ func drawGutter(w *core.Window, styles style.Styles, options core.WinOptions, cu
lineNumber int
gutter string
gutterStyle = styles.Gutter
gutterStyleCur = styles.GutterCurrentLine
gutterStyle = t.Gutter.Default
gutterStyleCur = t.Gutter.CurrentLine
)
// If we have relative setting, set the numbers relatively
@ -173,9 +173,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, styles style.Styles) string {
left := leftBar(w, mode, styles)
right := rightBar(w, mode, styles)
func drawStatusBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string {
left := leftBar(w, mode, t)
right := rightBar(w, mode, t)
diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right))
@ -184,12 +184,12 @@ func drawStatusBar(w *core.Window, mode core.Mode, styles style.Styles) string {
return ""
}
middle := strings.Repeat(styles.BackgroundStyle.Render(" "), diff)
middle := strings.Repeat(t.Background.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, styles style.Styles) string {
func leftBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string {
buf := w.Buffer
var flags []string
@ -206,12 +206,12 @@ func leftBar(w *core.Window, mode core.Mode, styles style.Styles) string {
}
bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr)
return styles.LineStyle.Render(bar)
return t.Line.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, styles style.Styles) (bar string) {
func rightBar(w *core.Window, mode core.Mode, t theme.EditorTheme) (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)
@ -219,7 +219,7 @@ func rightBar(w *core.Window, mode core.Mode, styles style.Styles) (bar string)
bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1)
}
buf := w.Buffer
bar = styles.LineStyle.Render(fmt.Sprintf("%s %s", buf.Filetype, bar))
bar = t.Line.Render(fmt.Sprintf("%s %s", buf.Filetype, bar))
return
}
@ -321,7 +321,7 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
// overlayCommandOutputWindow: Draw the overlay of the command output window. This will override
// (overlay) the displayed content, so it should be used only when needed.
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int, termHeight int) string {
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, t theme.EditorTheme, termWidth int, termHeight int) string {
// Safety check
if cmd == nil {
return view
@ -332,22 +332,22 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
// Build the overlay
var overlay []string
overlay = append(overlay, styles.CommandOutputBorder.Render(strings.Repeat(" ", termWidth)))
overlay = append(overlay, t.CommandLine.OutputBorder.Render(strings.Repeat(" ", termWidth)))
if strings.TrimSpace(cmd.Title) != "" {
title := styles.LineStyle.Render(cmd.Title)
title := t.Line.Render(cmd.Title)
overlay = append(overlay, title)
}
viewLines := cmd.Viewport(termHeight)
for _, l := range viewLines {
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
content := t.Line.Render(strings.ReplaceAll(l, "\n", "\\n"))
overlay = append(overlay, content)
}
msg := core.CommandOutputExitMessage
if len(cmd.Lines) > len(cmd.Viewport(termHeight)) {
msg += ". " + core.CommandOutputScrollMessage
}
overlay = append(overlay, styles.CommandContinueMessage.Render(msg))
overlay = append(overlay, t.CommandLine.ContinueMessage.Render(msg))
// NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(),
// which would cause Lipgloss to embed newlines internally and corrupt the line count.
@ -356,7 +356,7 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
// 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))
overlay[i] += t.Background.Render(strings.Repeat(" ", dif))
}
// Remove 'h' lines from back of view and append overlay

View File

@ -100,6 +100,8 @@ func DefaultStyles() Styles {
}
// Styles.DefaultCursorStyle: Returns the appropriate cursor style for the given mode.
//
// DEPRECATED: Using EditorTheme.DefaultCursor now
func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
@ -114,6 +116,8 @@ func (s Styles) DefaultCursorStyle(mode core.Mode) lipgloss.Style {
}
// Styles.CursorStyle: Returns a cursor style derived from the text style.
//
// DEPRECATED: Using EditorTheme.Cursor now
func (s Styles) CursorStyle(mode core.Mode, textStyle lipgloss.Style) lipgloss.Style {
switch mode {
case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode:
@ -134,6 +138,8 @@ func (s Styles) CursorStyle(mode core.Mode, textStyle lipgloss.Style) lipgloss.S
}
// Styles.VisualHighlightWithTextColor: Applies visual background while preserving text color.
//
// DEPRECATED: Using EditorTheme.VisualHighlightWithTextColor now
func (s Styles) VisualHighlightWithTextColor(textStyle lipgloss.Style) lipgloss.Style {
return lipgloss.NewStyle().
Background(s.VisualHighlight.GetBackground()).

View File

@ -1,7 +1,77 @@
package theme
// RegisterAll is retained as a no-op for compatibility while Chroma-based
// theme loading is removed.
func RegisterAll() error {
return nil
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"github.com/charmbracelet/lipgloss"
)
type EditorTheme struct {
Cursors CursorTheme
Gutter GutterTheme
VisualHightlight lipgloss.Style
StatusBar StatusBarTheme
CommandLine CommandLineTheme
Line lipgloss.Style
Background lipgloss.Style
}
type CursorTheme struct {
Normal lipgloss.Style
Insert lipgloss.Style
Command lipgloss.Style
Replace lipgloss.Style
}
type GutterTheme struct {
Default lipgloss.Style
CurrentLine lipgloss.Style
}
type StatusBarTheme struct {
Default lipgloss.Style
}
type CommandLineTheme struct {
Error lipgloss.Style
OutputBorder lipgloss.Style
ContinueMessage lipgloss.Style
}
func (t EditorTheme) Cursor(mode core.Mode, textStyle lipgloss.Style) lipgloss.Style {
bg := textStyle.GetBackground()
fg := textStyle.GetForeground()
switch mode {
case core.NormalMode, core.VisualLineMode, core.VisualBlockMode, core.VisualMode:
return lipgloss.NewStyle().
Background(fg).
Foreground(bg)
case core.ReplaceMode, core.WaitingMode:
return textStyle.
Underline(true)
default:
return t.Background.
Foreground(fg).
Underline(true)
}
}
func (t EditorTheme) DefaultCursor(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return t.Cursors.Insert
case core.CommandMode:
return t.Cursors.Command
case core.ReplaceMode:
return t.Cursors.Replace
default:
return t.Cursors.Normal
}
}
// This method is preferred to raw access of EditorTheme.VisualHightlight since
// is has the proper foreground color applied
func (t EditorTheme) VisualHighlightWithTextColor(textStyle lipgloss.Style) lipgloss.Style {
return t.VisualHightlight.
Foreground(textStyle.GetForeground())
}

View File

@ -0,0 +1,83 @@
package themes
import (
"git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss"
)
const background = lipgloss.Color("#1f2335")
const foreground = lipgloss.Color("#dcd7ba")
func NewDefaultTheme() theme.EditorTheme {
hightlight := lipgloss.NewStyle().
Background(lipgloss.Color("#2f334d"))
line := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
background := lipgloss.NewStyle().
Background(background)
return theme.EditorTheme{
Cursors: newDefaultCursorTheme(),
Gutter: newDefaultGutterTheme(),
VisualHightlight: hightlight,
StatusBar: newDefaultStatusBarTheme(),
CommandLine: newDefaultCommandLineTheme(),
Line: line,
Background: background,
}
}
// This is only used for the default cursors, in any other case
// the EditorTheme.Cursor() method is preferred.
func newDefaultCursorTheme() theme.CursorTheme {
base := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
inv := lipgloss.NewStyle().
Foreground(background).
Background(foreground)
return theme.CursorTheme{
Normal: inv,
Insert: base.Underline(true),
Command: inv,
Replace: base.Underline(true),
}
}
func newDefaultGutterTheme() theme.GutterTheme {
base := lipgloss.NewStyle().
Background(lipgloss.Color("#181b2a")).
Foreground(lipgloss.Color("#7e8399"))
return theme.GutterTheme{
Default: base,
CurrentLine: base.Foreground(lipgloss.Color("#f6c384")),
}
}
func newDefaultStatusBarTheme() theme.StatusBarTheme {
bar := lipgloss.NewStyle().
Background(lipgloss.Color("#181b2a")).
Foreground(lipgloss.Color("#8ea4a2"))
return theme.StatusBarTheme{
Default: bar,
}
}
func newDefaultCommandLineTheme() theme.CommandLineTheme {
base := lipgloss.NewStyle().
Foreground(foreground).
Background(background)
return theme.CommandLineTheme{
Error: base.Foreground(lipgloss.Color("#e82424")),
OutputBorder: base.Background(lipgloss.Color("#11131d")),
ContinueMessage: base.Foreground(lipgloss.Color("#7aa2f7")),
}
}