From b618e3a382a7ac502b8faf7b2699bcdfbf46e8aa Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 16 Mar 2026 23:25:09 -0700 Subject: [PATCH] feat: implemented colorscheme commands. Tested There are some odd things being done in the testing files, that should get reviewed. --- internal/action/find_test.go | 4 + internal/action/interface.go | 4 + internal/command/handlers.go | 57 ++++++ internal/command/handlers_test.go | 307 ++++++++++++++++++++++++++++++ internal/command/registry.go | 12 ++ internal/editor/view.go | 11 +- internal/style/style.go | 12 +- 7 files changed, 400 insertions(+), 7 deletions(-) diff --git a/internal/action/find_test.go b/internal/action/find_test.go index 7ec4315..bfbbb67 100644 --- a/internal/action/find_test.go +++ b/internal/action/find_test.go @@ -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 } diff --git a/internal/action/interface.go b/internal/action/interface.go index 451c78b..c032631 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -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 diff --git a/internal/command/handlers.go b/internal/command/handlers.go index b4e3f3a..da041d7 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -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 +} diff --git a/internal/command/handlers_test.go b/internal/command/handlers_test.go index 85c1263..59669a7 100644 --- a/internal/command/handlers_test.go +++ b/internal/command/handlers_test.go @@ -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 diff --git a/internal/command/registry.go b/internal/command/registry.go index 350f35b..5c49b05 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -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, + }) } diff --git a/internal/editor/view.go b/internal/editor/view.go index 0f3ce93..6ead1e7 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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) - view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), dif)) + // 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() } diff --git a/internal/style/style.go b/internal/style/style.go index c5be718..a7e0d20 100755 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -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()))