From 77416bc0a4bac07ebed91e4c6051447dd0c5cef9 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Tue, 7 Apr 2026 21:44:34 -0700 Subject: [PATCH] feat: created theme module This means we can finally create some themes! But the treesitter mapping is not complete. --- cmd/gim/main.go | 5 -- internal/action/interface.go | 3 ++ internal/action/mock.go | 10 ++-- internal/editor/model.go | 10 ++++ internal/editor/model_builder.go | 2 + internal/editor/view.go | 64 ++++++++++++------------ internal/style/style.go | 6 +++ internal/theme/theme.go | 78 ++++++++++++++++++++++++++++-- internal/theme/themes/default.go | 83 ++++++++++++++++++++++++++++++++ 9 files changed, 217 insertions(+), 44 deletions(-) create mode 100644 internal/theme/themes/default.go diff --git a/cmd/gim/main.go b/cmd/gim/main.go index 276f763..2ad189c 100644 --- a/cmd/gim/main.go +++ b/cmd/gim/main.go @@ -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) - } - // args := os.Args[1:] diff --git a/internal/action/interface.go b/internal/action/interface.go index e1e78d6..f10edce 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -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 diff --git a/internal/action/mock.go b/internal/action/mock.go index 5b2afa3..8a643b7 100644 --- a/internal/action/mock.go +++ b/internal/action/mock.go @@ -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 } @@ -91,10 +93,10 @@ func (m *MockModel) SetBuffers(bufs []*core.Buffer) { m.BuffersList = bufs } func (m *MockModel) ActiveBuffer() *core.Buffer { return m.ActiveWindowVal.Buffer } // Insert Mode State -func (m *MockModel) InsertKeys() []string { return m.InsertKeysList } -func (m *MockModel) SetInsertKeys(keys []string) { m.InsertKeysList = keys } +func (m *MockModel) InsertKeys() []string { return m.InsertKeysList } +func (m *MockModel) SetInsertKeys(keys []string) { m.InsertKeysList = keys } func (m *MockModel) SetInsertRecording(count int, a Action) {} -func (m *MockModel) ExitInsertMode() {} +func (m *MockModel) ExitInsertMode() {} func (m *MockModel) SetLastFind(char string, forward, inclusive bool) { m.LastFindVal = core.LastFindCommand{Char: char, Forward: forward, Inclusive: inclusive} } @@ -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 } diff --git a/internal/editor/model.go b/internal/editor/model.go index 451678d..2f2561c 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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 } diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index ae48255..9e8c920 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -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(), }, } } diff --git a/internal/editor/view.go b/internal/editor/view.go index a09fae9..a47ff6c 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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 diff --git a/internal/style/style.go b/internal/style/style.go index b80c1bd..df696ea 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -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()). diff --git a/internal/theme/theme.go b/internal/theme/theme.go index fc54ba2..26263c2 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -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()) } diff --git a/internal/theme/themes/default.go b/internal/theme/themes/default.go new file mode 100644 index 0000000..f368803 --- /dev/null +++ b/internal/theme/themes/default.go @@ -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")), + } +}