feat: implemented colorscheme commands. Tested
All checks were successful
Run Test Suite / test (push) Successful in 39s

There are some odd things being done in the testing files, that should
get reviewed.
This commit is contained in:
Hayden Hargreaves 2026-03-16 23:25:09 -07:00
parent 76fa55440e
commit b618e3a382
7 changed files with 400 additions and 7 deletions

View File

@ -4,6 +4,7 @@ import (
"testing"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
)
// ==================================================
@ -22,6 +23,7 @@ type mockModel struct {
commandCursor int
commandOutput *core.CommandOutput
lastFind core.LastFindCommand
styles style.Styles
}
func newMockModel() *mockModel {
@ -103,6 +105,8 @@ func (m *mockModel) Mode() core.Mode { return m.mode }
func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode }
func (m *mockModel) Settings() core.EditorSettings { return m.settings }
func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s }
func (m *mockModel) Styles() style.Styles { return m.styles }
func (m *mockModel) SetStyles(s style.Styles) { m.styles = s }
// Registers
func (m *mockModel) Registers() map[rune]core.Register { return m.registers }

View File

@ -2,6 +2,7 @@ package action
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
tea "github.com/charmbracelet/bubbletea"
)
@ -38,6 +39,7 @@ type Model interface {
CommandCursor() int
SetCommandCursor(cur int)
CommandOutput() *core.CommandOutput
// DO NOT FORGET TO CALL SetMode()
SetCommandOutput(out *core.CommandOutput)
// ==================================================
@ -48,6 +50,8 @@ type Model interface {
Settings() core.EditorSettings
SetSettings(s core.EditorSettings)
Styles() style.Styles
SetStyles(s style.Styles)
// ==================================================
// Registers

View File

@ -11,6 +11,8 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea"
)
@ -854,3 +856,58 @@ func parseSetOption(m action.Model, opt string) error {
return nil
}
// --------------------------------------------------
// Colorscheme Commands
// --------------------------------------------------
func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
_ = force
// No args, just print the current scheme
if len(args) == 0 {
s := m.Styles().ChromaStyle
if s == nil {
return nil
}
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{s.Name},
Inline: true,
IsError: false,
})
return nil
}
// Args given, set the scheme
name := strings.Join(args, " ")
chromaStyle := styles.Registry[name]
if chromaStyle == nil {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)},
Inline: true,
IsError: true,
})
return nil
}
m.SetStyles(style.ChromaStyles(chromaStyle))
return nil
}
func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
colors := styles.Names()
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":colorschemes",
Lines: colors,
Inline: false,
IsError: false,
})
return nil
}

View File

@ -9,6 +9,8 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
cStyles "github.com/alecthomas/chroma/v2/styles"
tea "github.com/charmbracelet/bubbletea"
)
@ -28,6 +30,7 @@ type mockModel struct {
commandCursor int
commandOutput *core.CommandOutput
lastFind core.LastFindCommand
styles style.Styles
}
func newMockModel() *mockModel {
@ -98,6 +101,8 @@ func (m *mockModel) Mode() core.Mode { return m.mode }
func (m *mockModel) SetMode(mode core.Mode) { m.mode = mode }
func (m *mockModel) Settings() core.EditorSettings { return m.settings }
func (m *mockModel) SetSettings(s core.EditorSettings) { m.settings = s }
func (m *mockModel) Styles() style.Styles { return m.styles }
func (m *mockModel) SetStyles(s style.Styles) { m.styles = s }
// Registers
func (m *mockModel) Registers() map[rune]core.Register { return m.registers }
@ -5601,3 +5606,305 @@ func TestCmdDeleteBuffer(t *testing.T) {
}
})
}
// ==================================================
// TestCmdColorscheme Tests
// ==================================================
func TestCmdColorscheme(t *testing.T) {
// --------------------------------------------------
// Group 1: Valid name — styles are updated
// --------------------------------------------------
t.Run("valid name updates styles on model", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"onedark"}, false)
name := m.Styles().ChromaStyle.Name
if name != "onedark" {
t.Error("expected styles to change after setting a valid colorscheme")
}
})
t.Run("same valid name applied twice produces same styles", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"monokai"}, false)
first := m.styles.BackgroundStyle.Render(" ")
cmdColorscheme(m, []string{"monokai"}, false)
second := m.styles.BackgroundStyle.Render(" ")
if first != second {
t.Error("expected applying the same colorscheme twice to produce identical styles")
}
})
t.Run("valid name sets no error output", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"monokai"}, false)
if m.commandOutput != nil && m.commandOutput.IsError {
t.Error("expected no error output for a valid colorscheme name")
}
})
t.Run("valid name returns nil tea.Cmd", func(t *testing.T) {
m := newMockModel()
cmd := cmdColorscheme(m, []string{"monokai"}, false)
if cmd != nil {
t.Error("expected nil tea.Cmd for colorscheme command")
}
})
// --------------------------------------------------
// Group 2: Invalid name — error is reported
// --------------------------------------------------
t.Run("unknown name sets error output", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"not-a-real-theme"}, false)
if m.commandOutput == nil {
t.Fatal("expected commandOutput to be set for unknown colorscheme")
}
if !m.commandOutput.IsError {
t.Error("expected commandOutput.IsError to be true for unknown colorscheme")
}
})
t.Run("unknown name does not change styles", func(t *testing.T) {
m := newMockModel()
before := m.styles.BackgroundStyle.Render(" ")
cmdColorscheme(m, []string{"not-a-real-theme"}, false)
if m.styles.BackgroundStyle.Render(" ") != before {
t.Error("expected styles to remain unchanged after unknown colorscheme")
}
})
t.Run("empty string name sets error output", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{""}, false)
if m.commandOutput == nil || !m.commandOutput.IsError {
t.Error("expected error output for empty colorscheme name")
}
})
t.Run("unknown name error output is inline", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"not-a-real-theme"}, false)
if m.commandOutput == nil {
t.Fatal("expected commandOutput to be set")
}
if !m.commandOutput.Inline {
t.Error("expected error commandOutput to be inline")
}
})
// --------------------------------------------------
// Group 3: No args — print current scheme
// --------------------------------------------------
t.Run("no args sets no error output", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{}, false)
if m.commandOutput != nil && m.commandOutput.IsError {
t.Error("expected no error output when called with no args")
}
})
t.Run("no args does not change styles", func(t *testing.T) {
m := newMockModel()
before := m.styles.BackgroundStyle.Render(" ")
cmdColorscheme(m, []string{}, false)
if m.styles.BackgroundStyle.Render(" ") != before {
t.Error("expected styles to remain unchanged when no args given")
}
})
t.Run("no args returns nil tea.Cmd", func(t *testing.T) {
m := newMockModel()
cmd := cmdColorscheme(m, []string{}, false)
if cmd != nil {
t.Error("expected nil tea.Cmd when called with no args")
}
})
// --------------------------------------------------
// Group 4: Extra args — only first arg used, no panic
// --------------------------------------------------
t.Run("extra args beyond name do not panic", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"monokai", "extra", "args"}, false)
})
// --------------------------------------------------
// Group 5: Force flag — has no effect on outcome
// --------------------------------------------------
t.Run("force flag with valid name still sets styles", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"monokai"}, true)
name := m.Styles().ChromaStyle.Name
if name != "monokai" {
t.Error("expected styles to change with force=true and valid name")
}
})
t.Run("force flag with invalid name still sets error output", func(t *testing.T) {
m := newMockModel()
cmdColorscheme(m, []string{"not-a-real-theme"}, true)
if m.commandOutput == nil || !m.commandOutput.IsError {
t.Error("expected error output for unknown colorscheme even with force=true")
}
})
}
// ==================================================
// TestCmdListColorschemes Tests
// ==================================================
func TestCmdListColorschemes(t *testing.T) {
t.Run("sets mode to CommandOutputMode", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
if m.mode != core.CommandOutputMode {
t.Errorf("expected mode CommandOutputMode, got %v", m.mode)
}
})
t.Run("sets commandOutput", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
if m.commandOutput == nil {
t.Fatal("expected commandOutput to be set")
}
})
t.Run("commandOutput is not an error", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
if m.commandOutput.IsError {
t.Error("expected commandOutput.IsError to be false")
}
})
t.Run("commandOutput is not inline", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
if m.commandOutput.Inline {
t.Error("expected commandOutput.Inline to be false")
}
})
t.Run("commandOutput title is :colorschemes", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
if m.commandOutput.Title != ":colorschemes" {
t.Errorf("expected title ':colorschemes', got %q", m.commandOutput.Title)
}
})
t.Run("commandOutput lines contains known built-in styles", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
lines := m.commandOutput.Lines
known := []string{"monokai", "github-dark", "dracula"}
for _, name := range known {
found := false
for _, l := range lines {
if l == name {
found = true
break
}
}
if !found {
t.Errorf("expected style %q to appear in colorschemes list", name)
}
}
})
t.Run("commandOutput lines matches styles.Names()", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
expected := cStyles.Names()
if len(m.commandOutput.Lines) != len(expected) {
t.Errorf("expected %d colorschemes, got %d", len(expected), len(m.commandOutput.Lines))
}
})
t.Run("commandOutput lines are non-empty strings", func(t *testing.T) {
m := newMockModel()
cmdListColorschemes(m, []string{}, false)
for i, l := range m.commandOutput.Lines {
if strings.TrimSpace(l) == "" {
t.Errorf("commandOutput.Lines[%d] is blank", i)
}
}
})
t.Run("args and force are ignored", func(t *testing.T) {
m1 := newMockModel()
m2 := newMockModel()
cmdListColorschemes(m1, []string{}, false)
cmdListColorschemes(m2, []string{"monokai", "extra"}, true)
if len(m1.commandOutput.Lines) != len(m2.commandOutput.Lines) {
t.Error("expected args and force to have no effect on list output")
}
})
t.Run("returns nil tea.Cmd", func(t *testing.T) {
m := newMockModel()
cmd := cmdListColorschemes(m, []string{}, false)
if cmd != nil {
t.Error("expected nil tea.Cmd")
}
})
}
var _ = style.DefaultStyles

View File

@ -217,4 +217,16 @@ func (r *Registry) registerDefaults() {
ShortForm: "e",
Handler: cmdEdit,
})
// Color scheme commands
r.Register(Command{
Name: "colorscheme",
ShortForm: "colo",
Handler: cmdColorscheme,
})
r.Register(Command{
Name: "colorschemes",
ShortForm: "colorschemes",
Handler: cmdListColorschemes,
})
}

View File

@ -54,6 +54,9 @@ func viewWindow(w *core.Window, styles style.Styles, options core.WinOptions, mo
// Chroma stuff
name := strings.ReplaceAll(buf.Filetype, ".", "")
lexer := lexers.Get(name)
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer) // Merge tokens together
// Draw buffer lines
@ -111,9 +114,11 @@ func drawLine(w *core.Window, styles style.Styles, options core.WinOptions, mode
}
}
// Color the overflow
dif := w.Width - lipgloss.Width(line)
// 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))
}
return view.String()
}

View File

@ -35,7 +35,7 @@ type Styles struct {
BackgroundStyle lipgloss.Style // This is just the background
// Chroma data
chromaStyle *chroma.Style
ChromaStyle *chroma.Style
}
// DefaultStyles: Returns the default editor color scheme.
@ -76,7 +76,7 @@ func DefaultStyles() Styles {
CommandContinueMessage: lipgloss.NewStyle().
Foreground(lipgloss.Color("#546fba")),
chromaStyle: nil,
ChromaStyle: nil,
}
}
@ -149,7 +149,7 @@ func ChromaStyles(chromaStyle *chroma.Style) Styles {
BackgroundStyle: lipgloss.NewStyle().Background(lipgloss.Color(bgString)),
chromaStyle: chromaStyle,
ChromaStyle: chromaStyle,
}
}
@ -197,6 +197,10 @@ func (s Styles) VisualHighlightWithTextColor(style lipgloss.Style) lipgloss.Styl
func (s Styles) MakeStyleMap(lexer chroma.Lexer, line string) []lipgloss.Style {
m := make([]lipgloss.Style, len(line))
if s.ChromaStyle == nil {
return m
}
iter, err := lexer.Tokenise(nil, line)
if err != nil {
panic(err)
@ -204,7 +208,7 @@ func (s Styles) MakeStyleMap(lexer chroma.Lexer, line string) []lipgloss.Style {
col := 0
for _, token := range iter.Tokens() {
entry := s.chromaStyle.Get(token.Type)
entry := s.ChromaStyle.Get(token.Type)
s := lipgloss.NewStyle().
Background(lipgloss.Color(entry.Background.String())).
Foreground(lipgloss.Color(entry.Colour.String()))