diff --git a/internal/action/mock.go b/internal/action/mock.go index 6380b1b..ddb8679 100644 --- a/internal/action/mock.go +++ b/internal/action/mock.go @@ -23,7 +23,6 @@ type MockModel struct { CommandHistoryList []string CommandHistoryCur int LastFindVal core.LastFindCommand - CurrentThemeName string ThemesMap map[string]theme.EditorTheme LastChangeKeysList []string } @@ -41,14 +40,13 @@ func NewMockModel() *MockModel { Build() return &MockModel{ - WindowsList: []*core.Window{&win}, - ActiveWindowVal: &win, - BuffersList: []*core.Buffer{&buf}, - SettingsVal: core.NewDefaultSettings(), - ModeVal: core.NormalMode, - RegistersMap: core.DefaultRegisters(), - CurrentThemeName: "default", - ThemesMap: map[string]theme.EditorTheme{"default": {}}, + WindowsList: []*core.Window{&win}, + ActiveWindowVal: &win, + BuffersList: []*core.Buffer{&buf}, + SettingsVal: core.NewDefaultSettings(), + ModeVal: core.NormalMode, + RegistersMap: core.DefaultRegisters(), + ThemesMap: map[string]theme.EditorTheme{"default": {}}, } } @@ -61,28 +59,26 @@ func NewMockModelWithBuffer(buf *core.Buffer) *MockModel { Build() return &MockModel{ - WindowsList: []*core.Window{&win}, - ActiveWindowVal: &win, - BuffersList: []*core.Buffer{buf}, - SettingsVal: core.NewDefaultSettings(), - ModeVal: core.NormalMode, - RegistersMap: core.DefaultRegisters(), - CurrentThemeName: "default", - ThemesMap: map[string]theme.EditorTheme{"default": {}}, + WindowsList: []*core.Window{&win}, + ActiveWindowVal: &win, + BuffersList: []*core.Buffer{buf}, + SettingsVal: core.NewDefaultSettings(), + ModeVal: core.NormalMode, + RegistersMap: core.DefaultRegisters(), + ThemesMap: map[string]theme.EditorTheme{"default": {}}, } } // NewMockModelWithWindow creates a mock with a custom window. func NewMockModelWithWindow(win *core.Window) *MockModel { return &MockModel{ - WindowsList: []*core.Window{win}, - ActiveWindowVal: win, - BuffersList: []*core.Buffer{win.Buffer}, - SettingsVal: core.NewDefaultSettings(), - ModeVal: core.NormalMode, - RegistersMap: core.DefaultRegisters(), - CurrentThemeName: "default", - ThemesMap: map[string]theme.EditorTheme{"default": {}}, + WindowsList: []*core.Window{win}, + ActiveWindowVal: win, + BuffersList: []*core.Buffer{win.Buffer}, + SettingsVal: core.NewDefaultSettings(), + ModeVal: core.NormalMode, + RegistersMap: core.DefaultRegisters(), + ThemesMap: map[string]theme.EditorTheme{"default": {}}, } } @@ -126,17 +122,17 @@ func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal } func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s } func (m *MockModel) Theme() (string, theme.EditorTheme) { if m.ThemesMap != nil { - if t, ok := m.ThemesMap[m.CurrentThemeName]; ok { - return m.CurrentThemeName, t + if t, ok := m.ThemesMap[m.SettingsVal.CurrentTheme]; ok { + return m.SettingsVal.CurrentTheme, t } if t, ok := m.ThemesMap["default"]; ok { return "default", t } } - return m.CurrentThemeName, theme.EditorTheme{} + return m.SettingsVal.CurrentTheme, theme.EditorTheme{} } -func (m *MockModel) SetTheme(name string) { m.CurrentThemeName = name } +func (m *MockModel) SetTheme(name string) { m.SettingsVal.CurrentTheme = name } func (m *MockModel) Themes() map[string]theme.EditorTheme { if m.ThemesMap == nil { m.ThemesMap = map[string]theme.EditorTheme{} diff --git a/internal/core/settings.go b/internal/core/settings.go index 0c2b9ed..617bfb6 100644 --- a/internal/core/settings.go +++ b/internal/core/settings.go @@ -2,8 +2,8 @@ package core // EditorSettings: Configuration options for editor display and behavior. type EditorSettings struct { - TabStop int - // TODO: Colors + TabStop int + CurrentTheme string } // NewDefaultSettings: Creates a Settings struct with sensible defaults for @@ -11,5 +11,7 @@ type EditorSettings struct { func NewDefaultSettings() EditorSettings { return EditorSettings{ TabStop: 2, + // TODO: This should be "default" but until we have a startup config, this is fine + CurrentTheme: "kanagawa", } } diff --git a/internal/editor/model.go b/internal/editor/model.go index edf4f47..61774f2 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -51,9 +51,9 @@ type Model struct { registers map[rune]core.Register // name -> register // Visual styles - currentTheme string // Name of current theme - themes map[string]theme.EditorTheme - syntax syntax.Engine + // currentTheme string // Name of current theme + themes map[string]theme.EditorTheme + syntax syntax.Engine // Dot operator state lastChangeKeys []string @@ -346,15 +346,15 @@ func (m *Model) SetSettings(s core.EditorSettings) { // Themes // ================================================== func (m *Model) Theme() (string, theme.EditorTheme) { - t, ok := m.themes[m.currentTheme] + t, ok := m.themes[m.settings.CurrentTheme] if ok { - return m.currentTheme, t + return m.settings.CurrentTheme, t } return "default", m.themes["default"] } func (m *Model) SetTheme(name string) { - m.currentTheme = name + m.settings.CurrentTheme = name if m.syntax == nil { return diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go index 1f02664..377d2eb 100644 --- a/internal/editor/model_builder.go +++ b/internal/editor/model_builder.go @@ -44,7 +44,6 @@ func NewModelBuilder() *ModelBuilder { settings: core.NewDefaultSettings(), registers: core.DefaultRegisters(), syntax: syntax.NewTreeSitterEngine(editorTheme), - currentTheme: "default", themes: embededThemes, }, } diff --git a/internal/syntax/registry_test.go b/internal/syntax/registry_test.go index 8c8a264..bdebc8f 100644 --- a/internal/syntax/registry_test.go +++ b/internal/syntax/registry_test.go @@ -2,44 +2,109 @@ package syntax import "testing" -func TestLanguageRegistryResolveByFiletype(t *testing.T) { +func TestLanguageRegistryResolve(t *testing.T) { r := newLanguageRegistry() - res, ok, err := r.resolve("go", "") + tests := []struct { + name string + args struct { + filetype string + filename string + } + expected string + wantErr bool + }{ + { + name: "resolve by filetype", + args: struct { + filetype string + filename string + }{filetype: "go"}, + expected: "go", + wantErr: false, + }, + { + name: "resolve by extension", + args: struct { + filetype string + filename string + }{filename: "main.js"}, + expected: "javascript", + wantErr: false, + }, + { + name: "filetype has precedence over extension", + args: struct { + filetype string + filename string + }{filetype: "python", filename: "main.go"}, + expected: "python", + wantErr: false, + }, + { + name: "normalizes case and whitespace", + args: struct { + filetype string + filename string + }{filetype: " Go "}, + expected: "go", + wantErr: false, + }, + { + name: "unknown language does not resolve", + args: struct { + filetype string + filename string + }{filetype: "txt", filename: "notes.txt"}, + expected: "", + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res, ok, err := r.resolve(tc.args.filetype, tc.args.filename) + if (err != nil) != tc.wantErr { + t.Fatalf("resolve error = %v, wantErr=%v", err, tc.wantErr) + } + + if tc.expected == "" { + if ok || res != nil { + t.Fatalf("expected unresolved language, got ok=%v res=%+v", ok, res) + } + return + } + + if !ok || res == nil { + t.Fatalf("expected language %q to resolve", tc.expected) + } + if res.id != tc.expected { + t.Fatalf("resolved id mismatch: got %q want %q", res.id, tc.expected) + } + }) + } +} + +func TestLanguageRegistryResolveReusesCompiledAssets(t *testing.T) { + r := newLanguageRegistry() + + first, ok, err := r.resolve("go", "") if err != nil { t.Fatalf("resolve error: %v", err) } - if !ok || res == nil { - t.Fatalf("expected go to resolve") + if !ok || first == nil { + t.Fatalf("expected first resolution to succeed") } - if res.id != "go" { - t.Fatalf("expected go id, got %q", res.id) - } -} -func TestLanguageRegistryResolveByExtension(t *testing.T) { - r := newLanguageRegistry() - - res, ok, err := r.resolve("", "main.js") + second, ok, err := r.resolve("golang", "") if err != nil { t.Fatalf("resolve error: %v", err) } - if !ok || res == nil { - t.Fatalf("expected javascript to resolve") + if !ok || second == nil { + t.Fatalf("expected second resolution to succeed") } - if res.id != "javascript" { - t.Fatalf("expected javascript id, got %q", res.id) - } -} - -func TestLanguageRegistryUnknown(t *testing.T) { - r := newLanguageRegistry() - - res, ok, err := r.resolve("txt", "notes.txt") - if err != nil { - t.Fatalf("expected no error for unknown language, got: %v", err) - } - if ok || res != nil { - t.Fatalf("expected unknown language to not resolve") + + if first != second { + t.Fatalf("expected compiled assets to be reused for same language id") } } diff --git a/internal/syntax/treesitter_behavior_test.go b/internal/syntax/treesitter_behavior_test.go index 44ddfa6..34449db 100644 --- a/internal/syntax/treesitter_behavior_test.go +++ b/internal/syntax/treesitter_behavior_test.go @@ -6,6 +6,7 @@ import ( "testing" "git.gophernest.net/azpect/TextEditor/internal/core" + "git.gophernest.net/azpect/TextEditor/internal/theme" "git.gophernest.net/azpect/TextEditor/internal/theme/themes" "github.com/charmbracelet/lipgloss" ) @@ -231,6 +232,141 @@ func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) { } } +func TestTreeSitterEngineThemeChangeRebuildsWithNewCaptureStylesAfterInvalidation(t *testing.T) { + b := core.NewBufferBuilder(). + WithFilename("sample.go"). + WithFiletype("go"). + WithLines([]string{"package main", "func main() {", " return", "}"}). + Build() + buf := &b + + themeA := themes.NewDefaultTheme() + themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#ffffff"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa")) + + engine := NewTreeSitterEngine(themeA) + engine.PrepareBuffer(buf, themeA) + + line := buf.Line(0) + keywordIdx := strings.Index(line, "package") + if keywordIdx < 0 { + t.Fatalf("test setup failed: expected package keyword") + } + + before := engine.LineStyleMap(buf, 0, themeA) + beforeStyle := before[keywordIdx] + if styleEquivalent(beforeStyle, themeA.Line) { + t.Fatalf("expected keyword style to differ from base style before theme switch") + } + + engine.InvalidateBuffer(buf) + engine.PrepareBuffer(buf, themeB) + + after := engine.LineStyleMap(buf, 0, themeB) + afterStyle := after[keywordIdx] + if styleEquivalent(afterStyle, themeB.Line) { + t.Fatalf("expected keyword style to differ from base style after theme switch") + } + if styleEquivalent(beforeStyle, afterStyle) { + t.Fatalf("expected keyword style to change after theme switch and invalidation") + } +} + +func TestTreeSitterEngineThemeChangeRebuildsFallbackLineStylesAfterInvalidation(t *testing.T) { + b := core.NewBufferBuilder(). + WithFilename("notes.txt"). + WithFiletype("txt"). + WithLines([]string{"plain text"}). + Build() + buf := &b + + themeA := themes.NewDefaultTheme() + themeB := makeThemeWithCaptureOverrides(lipgloss.Color("#101010"), lipgloss.Color("#ff00ff"), lipgloss.Color("#00ffaa")) + + engine := NewTreeSitterEngine(themeA) + mapA := engine.LineStyleMap(buf, 0, themeA) + if len(mapA) == 0 { + t.Fatalf("expected non-empty style map for text line") + } + if !styleEquivalent(mapA[0], themeA.Line) { + t.Fatalf("expected fallback style to use first theme line style") + } + + engine.InvalidateBuffer(buf) + mapB := engine.LineStyleMap(buf, 0, themeB) + if !styleEquivalent(mapB[0], themeB.Line) { + t.Fatalf("expected fallback style to use second theme line style after invalidation") + } + if styleEquivalent(mapA[0], mapB[0]) { + t.Fatalf("expected fallback style to change after theme switch and invalidation") + } +} + +func TestTreeSitterEngineUnsupportedApplyEditFallsBackToDefaultStyles(t *testing.T) { + b := core.NewBufferBuilder(). + WithFilename("notes.txt"). + WithFiletype("txt"). + WithLines([]string{"just text"}). + Build() + buf := &b + + editorTheme := themes.NewDefaultTheme() + engine := NewTreeSitterEngine(editorTheme) + + engine.PrepareBuffer(buf, editorTheme) + baseline := engine.LineStyleMap(buf, 0, editorTheme) + if len(baseline) == 0 { + t.Fatalf("expected baseline style map") + } + + var edit *core.BufferEdit + buf.OnChange = func(change core.BufferChange) { + edit = change.Edit + } + buf.SetLine(0, "still plain text") + if edit == nil { + t.Fatalf("expected edit metadata from SetLine") + } + + engine.ApplyEdit(buf, edit) + engine.PrepareBuffer(buf, editorTheme) + + after := engine.LineStyleMap(buf, 0, editorTheme) + if len(after) != len([]rune(buf.Line(0))) { + t.Fatalf("style map length mismatch after unsupported apply edit") + } + for i := range after { + if !styleEquivalent(after[i], editorTheme.Line) { + t.Fatalf("expected fallback line style at rune %d after unsupported apply edit", i) + } + } +} + +func makeThemeWithCaptureOverrides(lineFg, keywordFg, stringFg lipgloss.Color) theme.EditorTheme { + t := themes.NewDefaultTheme() + t.Line = t.Line.Foreground(lineFg) + t.Syntax.Group = cloneStyleMap(t.Syntax.Group) + t.Syntax.Exact = cloneStyleMap(t.Syntax.Exact) + t.Syntax.Group["keyword"] = lipgloss.NewStyle().Foreground(keywordFg) + t.Syntax.Group["string"] = lipgloss.NewStyle().Foreground(stringFg) + for key := range t.Syntax.Exact { + if strings.HasPrefix(key, "keyword") { + t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(keywordFg) + } + if strings.HasPrefix(key, "string") { + t.Syntax.Exact[key] = lipgloss.NewStyle().Foreground(stringFg) + } + } + return t +} + +func cloneStyleMap(in map[string]lipgloss.Style) map[string]lipgloss.Style { + out := make(map[string]lipgloss.Style, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + func styleEquivalent(a, b lipgloss.Style) bool { return styleSignature(a) == styleSignature(b) } diff --git a/internal/syntax/treesitter_internal_test.go b/internal/syntax/treesitter_internal_test.go index 60f8a05..7f21a23 100644 --- a/internal/syntax/treesitter_internal_test.go +++ b/internal/syntax/treesitter_internal_test.go @@ -3,48 +3,281 @@ package syntax import "testing" func TestMergeRanges(t *testing.T) { - in := []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}} - out := mergeRanges(in) + tests := []struct { + name string + input []lineRange + expected []lineRange + wantErr bool + }{ + { + name: "overlapping and unsorted ranges are merged", + input: []lineRange{{start: 5, end: 8}, {start: 1, end: 2}, {start: 2, end: 4}, {start: 10, end: 10}}, + expected: []lineRange{{start: 1, end: 8}, {start: 10, end: 10}}, + wantErr: false, + }, + { + name: "adjacent ranges are merged", + input: []lineRange{{start: 0, end: 1}, {start: 2, end: 3}}, + expected: []lineRange{{start: 0, end: 3}}, + wantErr: false, + }, + { + name: "empty input returns nil", + input: nil, + expected: nil, + wantErr: false, + }, + } - if len(out) != 2 { - t.Fatalf("expected 2 merged ranges, got %d", len(out)) - } - if out[0].start != 1 || out[0].end != 8 { - t.Fatalf("unexpected first merged range: %+v", out[0]) - } - if out[1].start != 10 || out[1].end != 10 { - t.Fatalf("unexpected second merged range: %+v", out[1]) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := mergeRanges(tc.input) + + if tc.wantErr { + t.Fatalf("unexpected wantErr=true for mergeRanges") + } + + if len(got) != len(tc.expected) { + t.Fatalf("unexpected merged range count: got %d want %d", len(got), len(tc.expected)) + } + for i := range got { + if got[i] != tc.expected[i] { + t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i]) + } + } + }) } } func TestNormalizedDirtyRanges(t *testing.T) { - ranges := []lineRange{{start: -2, end: 1}, {start: 3, end: 99}} - out := normalizedDirtyRanges(ranges, 5) + tests := []struct { + name string + args struct { + ranges []lineRange + lineCount int + } + expected []lineRange + wantErr bool + }{ + { + name: "clamps negative and overflowing ranges", + args: struct { + ranges []lineRange + lineCount int + }{ + ranges: []lineRange{{start: -2, end: 1}, {start: 3, end: 99}}, + lineCount: 5, + }, + expected: []lineRange{{start: 0, end: 1}, {start: 3, end: 4}}, + wantErr: false, + }, + { + name: "drops invalid clamped ranges", + args: struct { + ranges []lineRange + lineCount int + }{ + ranges: []lineRange{{start: 8, end: 9}}, + lineCount: 5, + }, + expected: nil, + wantErr: false, + }, + { + name: "merges adjacent ranges after clamping", + args: struct { + ranges []lineRange + lineCount int + }{ + ranges: []lineRange{{start: -3, end: 0}, {start: 1, end: 2}}, + lineCount: 4, + }, + expected: []lineRange{{start: 0, end: 2}}, + wantErr: false, + }, + } - if len(out) != 2 { - t.Fatalf("expected 2 normalized ranges, got %d", len(out)) - } - if out[0].start != 0 || out[0].end != 1 { - t.Fatalf("unexpected first normalized range: %+v", out[0]) - } - if out[1].start != 3 || out[1].end != 4 { - t.Fatalf("unexpected second normalized range: %+v", out[1]) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := normalizedDirtyRanges(tc.args.ranges, tc.args.lineCount) + if tc.wantErr { + t.Fatalf("unexpected wantErr=true for normalizedDirtyRanges") + } + + if len(got) != len(tc.expected) { + t.Fatalf("unexpected normalized range count: got %d want %d", len(got), len(tc.expected)) + } + for i := range got { + if got[i] != tc.expected[i] { + t.Fatalf("range %d mismatch: got %+v want %+v", i, got[i], tc.expected[i]) + } + } + }) } } func TestByteColToRuneIndexUTF8(t *testing.T) { - line := []byte("aéb") + tests := []struct { + name string + args struct { + line []byte + col int + } + expected int + wantErr bool + }{ + { + name: "zero column maps to first rune", + args: struct { + line []byte + col int + }{line: []byte("aéb"), col: 0}, + expected: 0, + wantErr: false, + }, + { + name: "middle byte offset on multibyte rune", + args: struct { + line []byte + col int + }{line: []byte("aéb"), col: 1}, + expected: 1, + wantErr: false, + }, + { + name: "end of multibyte rune maps after rune", + args: struct { + line []byte + col int + }{line: []byte("aéb"), col: 3}, + expected: 2, + wantErr: false, + }, + { + name: "column at line end maps to rune length", + args: struct { + line []byte + col int + }{line: []byte("aéb"), col: len([]byte("aéb"))}, + expected: 3, + wantErr: false, + }, + } - if got := byteColToRuneIndex(line, 0); got != 0 { - t.Fatalf("expected 0, got %d", got) - } - if got := byteColToRuneIndex(line, 1); got != 1 { - t.Fatalf("expected 1, got %d", got) - } - if got := byteColToRuneIndex(line, 3); got != 2 { - t.Fatalf("expected 2, got %d", got) - } - if got := byteColToRuneIndex(line, len(line)); got != 3 { - t.Fatalf("expected 3, got %d", got) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := byteColToRuneIndex(tc.args.line, tc.args.col) + if tc.wantErr { + t.Fatalf("unexpected wantErr=true for byteColToRuneIndex") + } + if got != tc.expected { + t.Fatalf("unexpected rune index: got %d want %d", got, tc.expected) + } + }) + } +} + +func TestAddDirtyRangeNormalizesAndMerges(t *testing.T) { + tests := []struct { + name string + args struct { + initial []lineRange + start int + end int + } + expected []lineRange + wantErr bool + }{ + { + name: "swaps start and end when reversed", + args: struct { + initial []lineRange + start int + end int + }{initial: nil, start: 7, end: 3}, + expected: []lineRange{{start: 3, end: 7}}, + wantErr: false, + }, + { + name: "clamps negative values", + args: struct { + initial []lineRange + start int + end int + }{initial: nil, start: -5, end: -1}, + expected: []lineRange{{start: 0, end: 0}}, + wantErr: false, + }, + { + name: "merges with existing adjacent range", + args: struct { + initial []lineRange + start int + end int + }{initial: []lineRange{{start: 1, end: 2}}, start: 3, end: 4}, + expected: []lineRange{{start: 1, end: 4}}, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + bc := &bufferCache{dirty: append([]lineRange{}, tc.args.initial...)} + addDirtyRange(bc, tc.args.start, tc.args.end) + if tc.wantErr { + t.Fatalf("unexpected wantErr=true for addDirtyRange") + } + if len(bc.dirty) != len(tc.expected) { + t.Fatalf("unexpected dirty range count: got %d want %d", len(bc.dirty), len(tc.expected)) + } + for i := range bc.dirty { + if bc.dirty[i] != tc.expected[i] { + t.Fatalf("dirty range %d mismatch: got %+v want %+v", i, bc.dirty[i], tc.expected[i]) + } + } + }) + } +} + +func TestRowInRanges(t *testing.T) { + tests := []struct { + name string + input struct { + row int + ranges []lineRange + } + expected bool + wantErr bool + }{ + { + name: "row inside range", + input: struct { + row int + ranges []lineRange + }{row: 3, ranges: []lineRange{{start: 1, end: 4}}}, + expected: true, + wantErr: false, + }, + { + name: "row outside all ranges", + input: struct { + row int + ranges []lineRange + }{row: 8, ranges: []lineRange{{start: 1, end: 4}, {start: 10, end: 12}}}, + expected: false, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := rowInRanges(tc.input.row, tc.input.ranges) + if tc.wantErr { + t.Fatalf("unexpected wantErr=true for rowInRanges") + } + if got != tc.expected { + t.Fatalf("unexpected result: got %v want %v", got, tc.expected) + } + }) } }