diff --git a/cmd/theme-loader/main.go b/cmd/theme-loader/main.go
new file mode 100644
index 0000000..5f8919b
--- /dev/null
+++ b/cmd/theme-loader/main.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "fmt"
+ "sort"
+
+ "git.gophernest.net/azpect/TextEditor/internal/theme"
+)
+
+func main() {
+ themes, err := theme.LoadEmbeddedThemesJSON()
+ if err != nil {
+ panic(err)
+ }
+
+ fmt.Printf("%+v\n", themes)
+
+ names := make([]string, 0, len(themes))
+ for name := range themes {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ fmt.Printf("loaded %d embedded themes:\n", len(names))
+ for _, name := range names {
+ fmt.Printf("- %s\n", name)
+ }
+}
diff --git a/internal/action/interface.go b/internal/action/interface.go
index acabf24..095d608 100644
--- a/internal/action/interface.go
+++ b/internal/action/interface.go
@@ -54,8 +54,14 @@ type Model interface {
Settings() core.EditorSettings
SetSettings(s core.EditorSettings)
- Theme() theme.EditorTheme
- SetTheme(t theme.EditorTheme)
+
+ // ==================================================
+ // Themes
+ // ==================================================
+ Theme() (string, theme.EditorTheme)
+ SetTheme(name string)
+ Themes() map[string]theme.EditorTheme
+ SetThemes(t map[string]theme.EditorTheme)
// ==================================================
// Registers
diff --git a/internal/action/mock.go b/internal/action/mock.go
index e33c6e9..6380b1b 100644
--- a/internal/action/mock.go
+++ b/internal/action/mock.go
@@ -23,7 +23,8 @@ type MockModel struct {
CommandHistoryList []string
CommandHistoryCur int
LastFindVal core.LastFindCommand
- ThemeVal theme.EditorTheme
+ CurrentThemeName string
+ ThemesMap map[string]theme.EditorTheme
LastChangeKeysList []string
}
@@ -40,12 +41,14 @@ 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(),
+ 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": {}},
}
}
@@ -58,24 +61,28 @@ 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(),
+ 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": {}},
}
}
// 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(),
+ 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": {}},
}
}
@@ -117,8 +124,26 @@ func (m *MockModel) Mode() core.Mode { return m.ModeVal }
func (m *MockModel) SetMode(mode core.Mode) { m.ModeVal = mode }
func (m *MockModel) Settings() core.EditorSettings { return m.SettingsVal }
func (m *MockModel) SetSettings(s core.EditorSettings) { m.SettingsVal = s }
-func (m *MockModel) Theme() theme.EditorTheme { return m.ThemeVal }
-func (m *MockModel) SetTheme(t theme.EditorTheme) { m.ThemeVal = t }
+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["default"]; ok {
+ return "default", t
+ }
+ }
+
+ return m.CurrentThemeName, theme.EditorTheme{}
+}
+func (m *MockModel) SetTheme(name string) { m.CurrentThemeName = name }
+func (m *MockModel) Themes() map[string]theme.EditorTheme {
+ if m.ThemesMap == nil {
+ m.ThemesMap = map[string]theme.EditorTheme{}
+ }
+ return m.ThemesMap
+}
+func (m *MockModel) SetThemes(t map[string]theme.EditorTheme) { m.ThemesMap = t }
// Registers
func (m *MockModel) Registers() map[rune]core.Register { return m.RegistersMap }
diff --git a/internal/command/handlers.go b/internal/command/handlers.go
index 6dbc4a2..2a0d751 100644
--- a/internal/command/handlers.go
+++ b/internal/command/handlers.go
@@ -887,8 +887,9 @@ func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
// No args, just print the current scheme
if len(args) == 0 {
+ name, _ := m.Theme()
m.SetCommandOutput(&core.CommandOutput{
- Lines: []string{"default"},
+ Lines: []string{name},
Inline: true,
IsError: false,
})
@@ -897,15 +898,9 @@ func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
// Theme switching is disabled while migrating away from Chroma.
name := strings.TrimSpace(strings.Join(args, " "))
- if name == "" {
- m.SetCommandOutput(&core.CommandOutput{
- Lines: []string{"colorscheme not found: "},
- Inline: true,
- IsError: true,
- })
- return nil
- }
- if name != "" && strings.ToLower(name) != "default" {
+ _, found := m.Themes()[name]
+
+ if name == "" || !found {
m.SetCommandOutput(&core.CommandOutput{
Lines: []string{fmt.Sprintf("colorscheme not found: %s", name)},
Inline: true,
@@ -914,14 +909,19 @@ func cmdColorscheme(m action.Model, args []string, force bool) tea.Cmd {
return nil
}
- // m.SetStyles(style.DefaultStyles())
+ m.SetTheme(name)
return nil
}
func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
- colors := []string{"default"}
+ var colors []string
+ for k := range m.Themes() {
+ colors = append(colors, k)
+ }
+
+ slices.Sort(colors)
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
diff --git a/internal/editor/model.go b/internal/editor/model.go
index 9dcd388..edf4f47 100644
--- a/internal/editor/model.go
+++ b/internal/editor/model.go
@@ -51,8 +51,9 @@ type Model struct {
registers map[rune]core.Register // name -> register
// Visual styles
- theme theme.EditorTheme
- syntax syntax.Engine
+ currentTheme string // Name of current theme
+ themes map[string]theme.EditorTheme
+ syntax syntax.Engine
// Dot operator state
lastChangeKeys []string
@@ -341,12 +342,39 @@ func (m *Model) SetSettings(s core.EditorSettings) {
m.settings = s
}
-func (m *Model) Theme() theme.EditorTheme {
- return m.theme
+// ==================================================
+// Themes
+// ==================================================
+func (m *Model) Theme() (string, theme.EditorTheme) {
+ t, ok := m.themes[m.currentTheme]
+ if ok {
+ return m.currentTheme, t
+ }
+ return "default", m.themes["default"]
}
-func (m *Model) SetTheme(t theme.EditorTheme) {
- m.theme = t
+func (m *Model) SetTheme(name string) {
+ m.currentTheme = name
+
+ if m.syntax == nil {
+ return
+ }
+
+ // Need to invalidate the buffers to force a redraw
+ for _, buf := range m.buffers {
+ if buf == nil {
+ continue
+ }
+ m.syntax.InvalidateBuffer(buf)
+ }
+}
+
+func (m *Model) Themes() map[string]theme.EditorTheme {
+ return m.themes
+}
+
+func (m *Model) SetThemes(t map[string]theme.EditorTheme) {
+ m.themes = t
}
func (m *Model) Syntax() syntax.Engine {
diff --git a/internal/editor/model_builder.go b/internal/editor/model_builder.go
index f0af1a5..1f02664 100644
--- a/internal/editor/model_builder.go
+++ b/internal/editor/model_builder.go
@@ -4,6 +4,7 @@ import (
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/input"
"git.gophernest.net/azpect/TextEditor/internal/syntax"
+ "git.gophernest.net/azpect/TextEditor/internal/theme"
"git.gophernest.net/azpect/TextEditor/internal/theme/themes"
)
@@ -15,6 +16,16 @@ type ModelBuilder struct {
func NewModelBuilder() *ModelBuilder {
editorTheme := themes.NewDefaultTheme()
+ // Embed the themes
+ var embededThemes map[string]theme.EditorTheme
+ embededThemesJson, err := theme.LoadEmbeddedThemesJSON()
+ if err == nil {
+ embededThemes = theme.MapEmbededThemeToEditorTheme(embededThemesJson)
+ }
+
+ // Always have a default theme
+ embededThemes["default"] = themes.NewDefaultTheme()
+
return &ModelBuilder{
model: Model{
buffers: []*core.Buffer{},
@@ -33,7 +44,8 @@ func NewModelBuilder() *ModelBuilder {
settings: core.NewDefaultSettings(),
registers: core.DefaultRegisters(),
syntax: syntax.NewTreeSitterEngine(editorTheme),
- theme: editorTheme,
+ currentTheme: "default",
+ themes: embededThemes,
},
}
}
diff --git a/internal/editor/view.go b/internal/editor/view.go
index f13cd72..3cd4888 100644
--- a/internal/editor/view.go
+++ b/internal/editor/view.go
@@ -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
- t := m.Theme()
+ _, t := m.Theme()
options := win.Options
// Adjust gutter to fit line len
@@ -51,7 +51,7 @@ func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mo
buf := w.Buffer
var view strings.Builder
if sx != nil {
- sx.PrepareBuffer(buf)
+ sx.PrepareBuffer(buf, t)
}
// Compute window size (y)
@@ -63,7 +63,7 @@ func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mo
if lineNum < buf.LineCount() {
styleMap := make([]lipgloss.Style, len([]rune(buf.Line(lineNum))))
if sx != nil {
- styleMap = sx.LineStyleMap(buf, lineNum)
+ styleMap = sx.LineStyleMap(buf, lineNum, t)
}
line := drawLine(w, t, options, mode, buf.Line(lineNum), lineNum, styleMap)
view.WriteString(line)
diff --git a/internal/syntax/engine.go b/internal/syntax/engine.go
index 674af74..b8f9d77 100644
--- a/internal/syntax/engine.go
+++ b/internal/syntax/engine.go
@@ -2,6 +2,7 @@ package syntax
import (
"git.gophernest.net/azpect/TextEditor/internal/core"
+ "git.gophernest.net/azpect/TextEditor/internal/theme"
"github.com/charmbracelet/lipgloss"
)
@@ -11,13 +12,13 @@ import (
// directly.
type Engine interface {
// Engine.PrepareBuffer: Ensure syntax state for a buffer is ready.
- PrepareBuffer(buf *core.Buffer)
+ PrepareBuffer(buf *core.Buffer, t theme.EditorTheme)
// Engine.ApplyEdit: Apply an incremental text edit to syntax state.
ApplyEdit(buf *core.Buffer, edit *core.BufferEdit)
// Engine.LineStyleMap: Returns per-rune styles for a line.
- LineStyleMap(buf *core.Buffer, line int) []lipgloss.Style
+ LineStyleMap(buf *core.Buffer, line int, t theme.EditorTheme) []lipgloss.Style
// Engine.InvalidateBuffer: Marks all syntax state for a buffer as stale.
InvalidateBuffer(buf *core.Buffer)
diff --git a/internal/syntax/treesitter.go b/internal/syntax/treesitter.go
index adf8ba6..05ac49d 100644
--- a/internal/syntax/treesitter.go
+++ b/internal/syntax/treesitter.go
@@ -20,8 +20,7 @@ import (
//
// Cached styles are represented as one style per rune for each line.
type TreeSitterEngine struct {
- editorTheme theme.EditorTheme
- registry *languageRegistry
+ registry *languageRegistry
cache map[*core.Buffer]*bufferCache
}
@@ -74,9 +73,8 @@ type captureRange struct {
// work with any language/query pair registered there.
func NewTreeSitterEngine(t theme.EditorTheme) *TreeSitterEngine {
return &TreeSitterEngine{
- editorTheme: t,
- registry: newLanguageRegistry(),
- cache: map[*core.Buffer]*bufferCache{},
+ registry: newLanguageRegistry(),
+ cache: map[*core.Buffer]*bufferCache{},
}
}
@@ -88,7 +86,7 @@ func NewTreeSitterEngine(t theme.EditorTheme) *TreeSitterEngine {
//
// If the buffer language is unsupported or resolution fails, it still marks the
// cache as built with an empty style map so callers can safely continue.
-func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer) {
+func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer, t theme.EditorTheme) {
// Cannot prepare a nil buffer
if buf == nil {
return
@@ -115,7 +113,7 @@ func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer) {
}
_ = lang
- e.buildFullBuffer(buf, bc)
+ e.buildFullBuffer(buf, bc, t)
}
// LineStyleMap returns the style row for a specific line in buf.
@@ -123,12 +121,12 @@ func (e *TreeSitterEngine) PrepareBuffer(buf *core.Buffer) {
// It first guarantees buffer preparation, then returns cached styles when
// available. Missing lines are lazily initialized to the base line style and
// stored in cache to keep downstream rendering logic simple.
-func (e *TreeSitterEngine) LineStyleMap(buf *core.Buffer, line int) []lipgloss.Style {
+func (e *TreeSitterEngine) LineStyleMap(buf *core.Buffer, line int, t theme.EditorTheme) []lipgloss.Style {
if buf == nil {
return nil
}
- e.PrepareBuffer(buf)
+ e.PrepareBuffer(buf, t)
bc := e.getCache(buf)
if s, ok := bc.lines[line]; ok {
@@ -138,7 +136,7 @@ func (e *TreeSitterEngine) LineStyleMap(buf *core.Buffer, line int) []lipgloss.S
runes := []rune(buf.Line(line))
out := make([]lipgloss.Style, len(runes))
for i := range out {
- out[i] = e.editorTheme.Line
+ out[i] = t.Line
}
bc.lines[line] = out
return out
@@ -297,7 +295,7 @@ func (e *TreeSitterEngine) getCache(buf *core.Buffer) *bufferCache {
// It (re)parses source when needed, collects query captures, sorts captures by
// precedence order, then writes styles onto per-rune line slices. After a
// successful pass it clears dirty flags and marks the cache as built.
-func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache) {
+func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache, t theme.EditorTheme) {
lineCount := buf.LineCount()
// Load the lines into memory. There is no method for this due to the buffers
@@ -312,13 +310,13 @@ func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache) {
if fullRebuild {
bc.lines = map[int][]lipgloss.Style{}
for i := range lineCount {
- bc.lines[i] = defaultLineStyles(lines[i], e.editorTheme.Line)
+ bc.lines[i] = defaultLineStyles(lines[i], t.Line)
}
} else {
dirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, r := range dirty {
for i := r.start; i <= r.end; i++ {
- bc.lines[i] = defaultLineStyles(lines[i], e.editorTheme.Line)
+ bc.lines[i] = defaultLineStyles(lines[i], t.Line)
}
}
}
@@ -397,7 +395,7 @@ func (e *TreeSitterEngine) buildFullBuffer(buf *core.Buffer, bc *bufferCache) {
// rewrites.
targetDirty := normalizedDirtyRanges(bc.dirty, lineCount)
for _, c := range captures {
- sty := e.editorTheme.CaptureStyle(c.name)
+ sty := t.CaptureStyle(c.name)
for row := c.startRow; row <= c.endRow; row++ {
if int(row) >= len(lines) {
break
diff --git a/internal/syntax/treesitter_behavior_test.go b/internal/syntax/treesitter_behavior_test.go
index e421f37..44ddfa6 100644
--- a/internal/syntax/treesitter_behavior_test.go
+++ b/internal/syntax/treesitter_behavior_test.go
@@ -23,13 +23,14 @@ func TestTreeSitterEngineHighlightsGoKeywordAndString(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
- base := engine.editorTheme.Line
+ base := editorTheme.Line
line0 := buf.Line(0)
- map0 := engine.LineStyleMap(buf, 0)
+ map0 := engine.LineStyleMap(buf, 0, editorTheme)
if len(map0) != len([]rune(line0)) {
t.Fatalf("line 0 style map length mismatch")
}
@@ -42,7 +43,7 @@ func TestTreeSitterEngineHighlightsGoKeywordAndString(t *testing.T) {
if stringStart < 0 {
t.Fatalf("test setup failed: string literal not found")
}
- map2 := engine.LineStyleMap(buf, 2)
+ map2 := engine.LineStyleMap(buf, 2, editorTheme)
if styleEquivalent(map2[stringStart+1], base) {
t.Fatalf("expected string contents to be highlighted")
}
@@ -63,11 +64,12 @@ func TestTreeSitterEngineHighlightsMultilineRawString(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
- base := engine.editorTheme.Line
- map3 := engine.LineStyleMap(buf, 3)
+ base := editorTheme.Line
+ map3 := engine.LineStyleMap(buf, 3, editorTheme)
if len(map3) == 0 {
t.Fatalf("expected style map on multiline raw string line")
}
@@ -89,15 +91,16 @@ func TestTreeSitterEngineApplyEditUpdatesStyleCategory(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
oldLine := buf.Line(2)
oldIdx := strings.Index(oldLine, "123")
if oldIdx < 0 {
t.Fatalf("test setup failed: number not found")
}
- oldMap := engine.LineStyleMap(buf, 2)
+ oldMap := engine.LineStyleMap(buf, 2, editorTheme)
oldStyle := oldMap[oldIdx]
var edit *core.BufferEdit
@@ -111,17 +114,17 @@ func TestTreeSitterEngineApplyEditUpdatesStyleCategory(t *testing.T) {
}
engine.ApplyEdit(buf, edit)
- engine.PrepareBuffer(buf)
+ engine.PrepareBuffer(buf, editorTheme)
newLine := buf.Line(2)
newIdx := strings.Index(newLine, "abc")
if newIdx < 0 {
t.Fatalf("test setup failed: string not found")
}
- newMap := engine.LineStyleMap(buf, 2)
+ newMap := engine.LineStyleMap(buf, 2, editorTheme)
newStyle := newMap[newIdx]
- if styleEquivalent(newStyle, engine.editorTheme.Line) {
+ if styleEquivalent(newStyle, editorTheme.Line) {
t.Fatalf("expected updated string to be highlighted")
}
if styleEquivalent(oldStyle, newStyle) {
@@ -137,8 +140,9 @@ func TestTreeSitterEngineApplyEditLineCountChangeForcesFullDirty(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
bc := engine.getCache(buf)
var edit *core.BufferEdit
@@ -156,7 +160,7 @@ func TestTreeSitterEngineApplyEditLineCountChangeForcesFullDirty(t *testing.T) {
t.Fatalf("expected line count change to set dirtyAll")
}
- engine.PrepareBuffer(buf)
+ engine.PrepareBuffer(buf, editorTheme)
if !bc.built {
t.Fatalf("expected cache rebuilt after prepare")
}
@@ -176,12 +180,13 @@ func TestTreeSitterEngineUnsupportedBufferFallsBackToDefaultStyles(t *testing.T)
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
- base := engine.editorTheme.Line
+ base := editorTheme.Line
line := buf.Line(0)
- m := engine.LineStyleMap(buf, 0)
+ m := engine.LineStyleMap(buf, 0, editorTheme)
if len(m) != len([]rune(line)) {
t.Fatalf("style map length mismatch on fallback buffer")
}
@@ -200,8 +205,9 @@ func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
bc := engine.getCache(buf)
var edit *core.BufferEdit
@@ -215,7 +221,7 @@ func TestTreeSitterEngineLastLineEditDoesNotPanicAndRebuilds(t *testing.T) {
}
engine.ApplyEdit(buf, edit)
- engine.PrepareBuffer(buf)
+ engine.PrepareBuffer(buf, editorTheme)
if !bc.built {
t.Fatalf("expected cache built after last-line edit")
diff --git a/internal/syntax/treesitter_bench_test.go b/internal/syntax/treesitter_bench_test.go
index 6fc4c97..c211801 100644
--- a/internal/syntax/treesitter_bench_test.go
+++ b/internal/syntax/treesitter_bench_test.go
@@ -18,9 +18,10 @@ func BenchmarkTreeSitterPrepareAndIncrementalEdit(b *testing.B) {
bld := core.NewBufferBuilder().WithFilename("bench.go").WithFiletype("go").WithLines(lines).Build()
buf := &bld
- eng := NewTreeSitterEngine(themes.NewDefaultTheme())
+ editorTheme := themes.NewDefaultTheme()
+ eng := NewTreeSitterEngine(editorTheme)
- eng.PrepareBuffer(buf)
+ eng.PrepareBuffer(buf, editorTheme)
b.ResetTimer()
for i := 0; i < b.N; i++ {
@@ -32,6 +33,6 @@ func BenchmarkTreeSitterPrepareAndIncrementalEdit(b *testing.B) {
// Synthetic direct invalidate path benchmark (current API entrypoints)
eng.InvalidateLines(buf, lineIdx, lineIdx)
- eng.PrepareBuffer(buf)
+ eng.PrepareBuffer(buf, editorTheme)
}
}
diff --git a/internal/syntax/treesitter_engine_test.go b/internal/syntax/treesitter_engine_test.go
index 096d358..ecc36e4 100644
--- a/internal/syntax/treesitter_engine_test.go
+++ b/internal/syntax/treesitter_engine_test.go
@@ -15,8 +15,9 @@ func TestTreeSitterEngineApplyEditMarksDirtyWithoutFullInvalidation(t *testing.T
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
bc := engine.getCache(buf)
if !bc.built {
@@ -45,7 +46,7 @@ func TestTreeSitterEngineApplyEditMarksDirtyWithoutFullInvalidation(t *testing.T
t.Fatalf("expected dirty ranges after apply edit")
}
- engine.PrepareBuffer(buf)
+ engine.PrepareBuffer(buf, editorTheme)
if !bc.built {
t.Fatalf("expected cache rebuilt after prepare")
}
@@ -62,8 +63,9 @@ func TestTreeSitterEngineInvalidateLinesAndBuffer(t *testing.T) {
Build()
buf := &b
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
- engine.PrepareBuffer(buf)
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
+ engine.PrepareBuffer(buf, editorTheme)
bc := engine.getCache(buf)
engine.InvalidateLines(buf, 1, 1)
diff --git a/internal/syntax/treesitter_sequences_test.go b/internal/syntax/treesitter_sequences_test.go
index 4aae529..1353bbc 100644
--- a/internal/syntax/treesitter_sequences_test.go
+++ b/internal/syntax/treesitter_sequences_test.go
@@ -5,6 +5,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"
)
@@ -66,7 +67,8 @@ func TestTreeSitterEngineEditSequences(t *testing.T) {
w := core.NewWindowBuilder().WithBuffer(buf).WithDimensions(120, 40).Build()
win := &w
- engine := NewTreeSitterEngine(themes.NewDefaultTheme())
+ editorTheme := themes.NewDefaultTheme()
+ engine := NewTreeSitterEngine(editorTheme)
buf.OnChange = func(change core.BufferChange) {
if change.Edit != nil {
@@ -76,19 +78,19 @@ func TestTreeSitterEngineEditSequences(t *testing.T) {
}
}
- engine.PrepareBuffer(buf)
- assertEngineInvariants(t, engine, buf, "initial")
+ engine.PrepareBuffer(buf, editorTheme)
+ assertEngineInvariants(t, engine, buf, editorTheme, "initial")
for i, op := range tc.opList {
op(buf, win)
- engine.PrepareBuffer(buf)
- assertEngineInvariants(t, engine, buf, fmt.Sprintf("after op %d", i+1))
+ engine.PrepareBuffer(buf, editorTheme)
+ assertEngineInvariants(t, engine, buf, editorTheme, fmt.Sprintf("after op %d", i+1))
}
})
}
}
-func assertEngineInvariants(t *testing.T, engine *TreeSitterEngine, buf *core.Buffer, phase string) {
+func assertEngineInvariants(t *testing.T, engine *TreeSitterEngine, buf *core.Buffer, editorTheme theme.EditorTheme, phase string) {
t.Helper()
bc := engine.getCache(buf)
@@ -104,7 +106,7 @@ func assertEngineInvariants(t *testing.T, engine *TreeSitterEngine, buf *core.Bu
for i := 0; i < buf.LineCount(); i++ {
line := buf.Line(i)
- m := engine.LineStyleMap(buf, i)
+ m := engine.LineStyleMap(buf, i, editorTheme)
if len(m) != len([]rune(line)) {
t.Fatalf("%s: line %d style length mismatch: got %d want %d", phase, i, len(m), len([]rune(line)))
}
diff --git a/internal/theme/loader.go b/internal/theme/loader.go
new file mode 100644
index 0000000..f7f6219
--- /dev/null
+++ b/internal/theme/loader.go
@@ -0,0 +1,154 @@
+package theme
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+//go:embed themes/*.json
+var embeddedThemes embed.FS
+
+// LoadEmbeddedThemesJSON reads all embedded theme JSON files and unmarshals
+// them into ThemeJSON objects keyed by theme name.
+func LoadEmbeddedThemesJSON() (map[string]ThemeJSON, error) {
+ paths, err := fs.Glob(embeddedThemes, "themes/*.json")
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(paths)
+
+ out := make(map[string]ThemeJSON, len(paths))
+ for _, path := range paths {
+ b, readErr := embeddedThemes.ReadFile(path)
+ if readErr != nil {
+ return nil, fmt.Errorf("read embedded theme %q: %w", path, readErr)
+ }
+
+ var th ThemeJSON
+ if unmarshalErr := json.Unmarshal(b, &th); unmarshalErr != nil {
+ return nil, fmt.Errorf("decode embedded theme %q: %w", path, unmarshalErr)
+ }
+
+ if strings.TrimSpace(th.Name) == "" {
+ th.Name = strings.TrimSuffix(filepath.Base(path), ".json")
+ }
+
+ out[th.Name] = th
+ }
+
+ return out, nil
+}
+
+func MapEmbededThemeToEditorTheme(em map[string]ThemeJSON) map[string]EditorTheme {
+ out := make(map[string]EditorTheme, len(em))
+
+ for name, in := range em {
+ line := styleFromJSON(in.Line)
+ lineBg := colorString(in.Line.BG)
+
+ syntaxExact := make(map[string]lipgloss.Style, len(in.Syntax.Exact))
+ for capture, col := range in.Syntax.Exact {
+ c := normalizeCaptureKey(capture)
+ if c == "" {
+ continue
+ }
+ syntaxExact[c] = syntaxColorStyle(col, lineBg)
+ }
+
+ syntaxGroup := make(map[string]lipgloss.Style, len(in.Syntax.Group))
+ for group, col := range in.Syntax.Group {
+ g := normalizeCaptureKey(group)
+ if g == "" {
+ continue
+ }
+ syntaxGroup[g] = syntaxColorStyle(col, lineBg)
+ }
+
+ key := strings.TrimSpace(name)
+ if key == "" {
+ key = strings.TrimSpace(in.Name)
+ }
+ if key == "" {
+ continue
+ }
+
+ out[key] = EditorTheme{
+ Cursors: CursorTheme{
+ Normal: styleFromJSON(in.Cursors.Normal),
+ Insert: styleFromJSON(in.Cursors.Insert),
+ Command: styleFromJSON(in.Cursors.Command),
+ Replace: styleFromJSON(in.Cursors.Replace),
+ },
+ Gutter: GutterTheme{
+ Default: styleFromJSON(in.Gutter.Default),
+ CurrentLine: styleFromJSON(in.Gutter.CurrentLine),
+ },
+ VisualHightlight: styleFromJSON(in.VisualHighlight),
+ StatusBar: StatusBarTheme{
+ Default: styleFromJSON(in.StatusBar.Default),
+ },
+ CommandLine: CommandLineTheme{
+ Error: styleFromJSON(in.CommandLine.Error),
+ OutputBorder: styleFromJSON(in.CommandLine.OutputBorder),
+ ContinueMessage: styleFromJSON(in.CommandLine.ContinueMessage),
+ },
+ Line: line,
+ Background: styleFromJSON(in.Background),
+ Syntax: SyntaxTheme{
+ Exact: syntaxExact,
+ Group: syntaxGroup,
+ },
+ }
+ }
+
+ return out
+}
+
+// MapEmbeddedThemeToEditorTheme is a correctly spelled alias for
+// MapEmbededThemeToEditorTheme.
+func MapEmbeddedThemeToEditorTheme(em map[string]ThemeJSON) map[string]EditorTheme {
+ return MapEmbededThemeToEditorTheme(em)
+}
+
+func styleFromJSON(in ColorStyleJSON) lipgloss.Style {
+ out := lipgloss.NewStyle()
+
+ if fg := colorString(in.FG); fg != "" {
+ out = out.Foreground(lipgloss.Color(fg))
+ }
+ if bg := colorString(in.BG); bg != "" {
+ out = out.Background(lipgloss.Color(bg))
+ }
+
+ return out
+}
+
+func colorString(c string) string {
+ return strings.TrimSpace(c)
+}
+
+func normalizeCaptureKey(k string) string {
+ k = strings.TrimSpace(strings.ToLower(k))
+ k = strings.TrimPrefix(k, "@")
+ return k
+}
+
+func syntaxColorStyle(fg, bg string) lipgloss.Style {
+ out := lipgloss.NewStyle()
+
+ if f := colorString(fg); f != "" {
+ out = out.Foreground(lipgloss.Color(f))
+ }
+ if b := colorString(bg); b != "" {
+ out = out.Background(lipgloss.Color(b))
+ }
+
+ return out
+}
diff --git a/internal/theme/theme_json.go b/internal/theme/theme_json.go
new file mode 100644
index 0000000..07969b8
--- /dev/null
+++ b/internal/theme/theme_json.go
@@ -0,0 +1,53 @@
+package theme
+
+// ThemeJSON is the file-backed theme DTO used for JSON unmarshalling.
+//
+// This mirrors the format documented in internal/theme/themes/README.md.
+// It is intentionally string-based so values can be validated and compiled
+// into EditorTheme styles in a separate step.
+type ThemeJSON struct {
+ Name string `json:"name"`
+ Line ColorStyleJSON `json:"line"`
+ Background ColorStyleJSON `json:"background"`
+ VisualHighlight ColorStyleJSON `json:"visual_highlight"`
+ Cursors CursorJSON `json:"cursors"`
+ Gutter GutterJSON `json:"gutter"`
+ StatusBar StatusBarJSON `json:"status_bar"`
+ CommandLine CommandLineJSON `json:"command_line"`
+ Syntax SyntaxJSON `json:"syntax"`
+}
+
+// ColorStyleJSON represents a simple fg/bg style entry.
+//
+// For v1 themes, only color values are supported.
+type ColorStyleJSON struct {
+ FG string `json:"fg,omitempty"`
+ BG string `json:"bg,omitempty"`
+}
+
+type CursorJSON struct {
+ Normal ColorStyleJSON `json:"normal"`
+ Insert ColorStyleJSON `json:"insert"`
+ Command ColorStyleJSON `json:"command"`
+ Replace ColorStyleJSON `json:"replace"`
+}
+
+type GutterJSON struct {
+ Default ColorStyleJSON `json:"default"`
+ CurrentLine ColorStyleJSON `json:"current_line"`
+}
+
+type StatusBarJSON struct {
+ Default ColorStyleJSON `json:"default"`
+}
+
+type CommandLineJSON struct {
+ Error ColorStyleJSON `json:"error"`
+ OutputBorder ColorStyleJSON `json:"output_border"`
+ ContinueMessage ColorStyleJSON `json:"continue_message"`
+}
+
+type SyntaxJSON struct {
+ Group map[string]string `json:"group"`
+ Exact map[string]string `json:"exact"`
+}
diff --git a/internal/theme/themes/README.md b/internal/theme/themes/README.md
new file mode 100644
index 0000000..44f6a48
--- /dev/null
+++ b/internal/theme/themes/README.md
@@ -0,0 +1,81 @@
+# Theme JSON Format (v1)
+
+This document defines the JSON structure for editor themes.
+
+- All color values are 6-digit hex strings (for example `#d4d8e1`).
+- Capture keys must be lowercase and must not include `@`.
+- `syntax.exact` overrides `syntax.group`. **These can be any values!**
+- If a capture is missing from both maps, the editor should fall back to the base `line` style.
+
+## Full structure
+
+```json
+{
+ "name": "default",
+ "line": { "fg": "#d4d8e1", "bg": "#111418" },
+ "background": { "bg": "#111418" },
+ "visual_highlight": { "bg": "#2f334d" },
+
+ "cursors": {
+ "normal": { "fg": "#111418", "bg": "#d4d8e1" },
+ "insert": { "fg": "#d4d8e1", "bg": "#111418" },
+ "command": { "fg": "#111418", "bg": "#d4d8e1" },
+ "replace": { "fg": "#d4d8e1", "bg": "#111418" }
+ },
+
+ "gutter": {
+ "default": { "fg": "#6b7280", "bg": "#0d1014" },
+ "current_line": { "fg": "#c0c8d8", "bg": "#0d1014" }
+ },
+
+ "status_bar": {
+ "default": { "fg": "#8f99aa", "bg": "#0d1014" }
+ },
+
+ "command_line": {
+ "error": { "fg": "#bf616a", "bg": "#111418" },
+ "output_border": { "fg": "#d4d8e1", "bg": "#0d1014" },
+ "continue_message": { "fg": "#81a1c1", "bg": "#111418" }
+ },
+
+ "syntax": {
+ "group": {
+ "comment": "#7f8795",
+ "function": "#81a1c1",
+ "keyword": "#b48ead",
+ "number": "#88c0d0",
+ "string": "#a3be8c",
+ "type": "#ebcb8b",
+ "variable": "#d4d8e1"
+ ...
+ },
+ "exact": {
+ "comment.documentation": "#8f99aa",
+ "function.call": "#81a1c1",
+ "keyword.return": "#b48ead",
+ "string.escape": "#d08770",
+ "variable.parameter": "#c0c8d8",
+ ...
+ }
+ }
+}
+```
+
+## Field notes
+
+- `name`: theme name shown by `:colorscheme`.
+- `line`: base text style used as the default fallback.
+- `background`: background fill style used for empty space.
+- `visual_highlight`: selection background style.
+- `syntax.group`: fallback colors by capture group (`keyword`, `string`, `comment`, etc.).
+- `syntax.exact`: exact capture overrides (`keyword.function`, `string.escape`, etc.).
+
+## Future ideas
+
+For now, styles only support foreground/background colors.
+
+In a future version we may add optional text attributes such as:
+
+- `bold`
+- `italic`
+- `underline`
diff --git a/internal/theme/themes/kanagawa-dragon.json b/internal/theme/themes/kanagawa-dragon.json
new file mode 100644
index 0000000..79f3f10
--- /dev/null
+++ b/internal/theme/themes/kanagawa-dragon.json
@@ -0,0 +1,116 @@
+{
+ "name": "kanagawa-dragon",
+ "line": { "fg": "#c5c9c5", "bg": "#181616" },
+ "background": { "bg": "#181616" },
+ "visual_highlight": { "bg": "#223249" },
+ "cursors": {
+ "normal": { "fg": "#181616", "bg": "#c5c9c5" },
+ "insert": { "fg": "#181616", "bg": "#c5c9c5" },
+ "command": { "fg": "#181616", "bg": "#c5c9c5" },
+ "replace": { "fg": "#181616", "bg": "#c5c9c5" }
+ },
+ "gutter": {
+ "default": { "fg": "#625e5a", "bg": "#282727" },
+ "current_line": { "fg": "#c4b28a", "bg": "#282727" }
+ },
+ "status_bar": {
+ "default": { "fg": "#c5c9c5", "bg": "#282727" }
+ },
+ "command_line": {
+ "error": { "fg": "#e82424", "bg": "#181616" },
+ "output_border": { "fg": "#c5c9c5", "bg": "#0d0c0c" },
+ "continue_message": { "fg": "#8ba4b0", "bg": "#181616" }
+ },
+ "syntax": {
+ "group": {
+ "attribute": "#c4b28a",
+ "boolean": "#a292a3",
+ "character": "#8a9a7b",
+ "charset": "#8ea4a2",
+ "comment": "#737c73",
+ "conceal": "#737c73",
+ "constant": "#b6927b",
+ "constructor": "#c4b28a",
+ "error": "#e82424",
+ "function": "#8ba4b0",
+ "import": "#8992a7",
+ "interface": "#8ea4a2",
+ "keyframes": "#c4746e",
+ "keyword": "#8992a7",
+ "label": "#949fb5",
+ "media": "#c4746e",
+ "module": "#949fb5",
+ "namespace": "#949fb5",
+ "none": "#c5c9c5",
+ "nospell": "#c5c9c5",
+ "number": "#a292a3",
+ "operator": "#c4746e",
+ "property": "#c4b28a",
+ "spell": "#c5c9c5",
+ "string": "#8a9a7b",
+ "supports": "#c4746e",
+ "tag": "#8ba4b0",
+ "type": "#8ea4a2",
+ "variable": "#c5c9c5"
+ },
+ "exact": {
+ "attribute.builtin": "#c4b28a",
+ "character.special": "#c4746e",
+ "comment.documentation": "#a6a69c",
+ "constant.builtin": "#b6927b",
+ "constant.macro": "#c4746e",
+ "function.builtin": "#949fb5",
+ "function.call": "#8ba4b0",
+ "function.macro": "#c4746e",
+ "function.method": "#8ba4b0",
+ "function.method.call": "#8ba4b0",
+ "keyword.conditional": "#8992a7",
+ "keyword.conditional.ternary": "#8992a7",
+ "keyword.coroutine": "#8992a7",
+ "keyword.debug": "#8992a7",
+ "keyword.directive": "#c4746e",
+ "keyword.directive.define": "#c4746e",
+ "keyword.exception": "#8992a7",
+ "keyword.function": "#8992a7",
+ "keyword.import": "#8992a7",
+ "keyword.modifier": "#8992a7",
+ "keyword.operator": "#c4746e",
+ "keyword.repeat": "#8992a7",
+ "keyword.return": "#8992a7",
+ "keyword.type": "#8ea4a2",
+ "markup.heading": "#c4b28a",
+ "markup.heading.1": "#c4b28a",
+ "markup.heading.2": "#b6927b",
+ "markup.heading.3": "#a292a3",
+ "markup.heading.4": "#949fb5",
+ "markup.heading.5": "#8ba4b0",
+ "markup.heading.6": "#8ea4a2",
+ "markup.italic": "#a6a69c",
+ "markup.link.label": "#8ba4b0",
+ "markup.raw": "#8a9a7b",
+ "markup.strikethrough": "#737c73",
+ "markup.strong": "#c5c9c5",
+ "markup.underline": "#949fb5",
+ "module.builtin": "#949fb5",
+ "number.float": "#a292a3",
+ "punctuation.bracket": "#9e9b93",
+ "punctuation.delimiter": "#9e9b93",
+ "punctuation.special": "#c4746e",
+ "string.documentation": "#a6a69c",
+ "string.escape": "#c4746e",
+ "string.regexp": "#c4746e",
+ "string.special.path": "#8a9a7b",
+ "string.special.symbol": "#b6927b",
+ "string.special.url": "#8ba4b0",
+ "tag.attribute": "#c4b28a",
+ "tag.attribute.url": "#8ba4b0",
+ "tag.builtin": "#949fb5",
+ "tag.delimiter": "#9e9b93",
+ "type.builtin": "#8ea4a2",
+ "type.definition": "#8ea4a2",
+ "variable.builtin": "#b6927b",
+ "variable.member": "#c4b28a",
+ "variable.parameter": "#a6a69c"
+ }
+ }
+}
diff --git a/internal/theme/themes/kanagawa-dragon.xml b/internal/theme/themes/kanagawa-dragon.xml
deleted file mode 100644
index 114d165..0000000
--- a/internal/theme/themes/kanagawa-dragon.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
diff --git a/internal/theme/themes/kanagawa-lotus.json b/internal/theme/themes/kanagawa-lotus.json
new file mode 100644
index 0000000..8fbba2c
--- /dev/null
+++ b/internal/theme/themes/kanagawa-lotus.json
@@ -0,0 +1,116 @@
+{
+ "name": "kanagawa-lotus",
+ "line": { "fg": "#545464", "bg": "#f2ecbc" },
+ "background": { "bg": "#f2ecbc" },
+ "visual_highlight": { "bg": "#c9cbd1" },
+ "cursors": {
+ "normal": { "fg": "#f2ecbc", "bg": "#545464" },
+ "insert": { "fg": "#f2ecbc", "bg": "#545464" },
+ "command": { "fg": "#f2ecbc", "bg": "#545464" },
+ "replace": { "fg": "#f2ecbc", "bg": "#545464" }
+ },
+ "gutter": {
+ "default": { "fg": "#a09cac", "bg": "#e7dba0" },
+ "current_line": { "fg": "#77713f", "bg": "#e7dba0" }
+ },
+ "status_bar": {
+ "default": { "fg": "#43436c", "bg": "#e7dba0" }
+ },
+ "command_line": {
+ "error": { "fg": "#e82424", "bg": "#f2ecbc" },
+ "output_border": { "fg": "#545464", "bg": "#e7dba0" },
+ "continue_message": { "fg": "#4d699b", "bg": "#f2ecbc" }
+ },
+ "syntax": {
+ "group": {
+ "attribute": "#77713f",
+ "boolean": "#b35b79",
+ "character": "#6f894e",
+ "charset": "#597b75",
+ "comment": "#8a8980",
+ "conceal": "#8a8980",
+ "constant": "#cc6d00",
+ "constructor": "#77713f",
+ "error": "#e82424",
+ "function": "#4d699b",
+ "import": "#624c83",
+ "interface": "#597b75",
+ "keyframes": "#c84053",
+ "keyword": "#624c83",
+ "label": "#6693bf",
+ "media": "#c84053",
+ "module": "#6693bf",
+ "namespace": "#6693bf",
+ "none": "#545464",
+ "nospell": "#545464",
+ "number": "#b35b79",
+ "operator": "#836f4a",
+ "property": "#77713f",
+ "spell": "#545464",
+ "string": "#6f894e",
+ "supports": "#c84053",
+ "tag": "#4d699b",
+ "type": "#597b75",
+ "variable": "#545464"
+ },
+ "exact": {
+ "attribute.builtin": "#77713f",
+ "character.special": "#836f4a",
+ "comment.documentation": "#716e61",
+ "constant.builtin": "#cc6d00",
+ "constant.macro": "#c84053",
+ "function.builtin": "#6693bf",
+ "function.call": "#4d699b",
+ "function.macro": "#c84053",
+ "function.method": "#4d699b",
+ "function.method.call": "#4d699b",
+ "keyword.conditional": "#624c83",
+ "keyword.conditional.ternary": "#624c83",
+ "keyword.coroutine": "#624c83",
+ "keyword.debug": "#624c83",
+ "keyword.directive": "#c84053",
+ "keyword.directive.define": "#c84053",
+ "keyword.exception": "#624c83",
+ "keyword.function": "#624c83",
+ "keyword.import": "#624c83",
+ "keyword.modifier": "#624c83",
+ "keyword.operator": "#836f4a",
+ "keyword.repeat": "#624c83",
+ "keyword.return": "#624c83",
+ "keyword.type": "#597b75",
+ "markup.heading": "#77713f",
+ "markup.heading.1": "#77713f",
+ "markup.heading.2": "#836f4a",
+ "markup.heading.3": "#cc6d00",
+ "markup.heading.4": "#4d699b",
+ "markup.heading.5": "#624c83",
+ "markup.heading.6": "#6693bf",
+ "markup.italic": "#716e61",
+ "markup.link.label": "#4d699b",
+ "markup.raw": "#6f894e",
+ "markup.strikethrough": "#8a8980",
+ "markup.strong": "#545464",
+ "markup.underline": "#6693bf",
+ "module.builtin": "#6693bf",
+ "number.float": "#b35b79",
+ "punctuation.bracket": "#4e8ca2",
+ "punctuation.delimiter": "#4e8ca2",
+ "punctuation.special": "#836f4a",
+ "string.documentation": "#716e61",
+ "string.escape": "#836f4a",
+ "string.regexp": "#836f4a",
+ "string.special.path": "#6f894e",
+ "string.special.symbol": "#cc6d00",
+ "string.special.url": "#4d699b",
+ "tag.attribute": "#77713f",
+ "tag.attribute.url": "#4d699b",
+ "tag.builtin": "#6693bf",
+ "tag.delimiter": "#4e8ca2",
+ "type.builtin": "#597b75",
+ "type.definition": "#597b75",
+ "variable.builtin": "#c84053",
+ "variable.member": "#77713f",
+ "variable.parameter": "#5d57a3"
+ }
+ }
+}
diff --git a/internal/theme/themes/kanagawa-lotus.xml b/internal/theme/themes/kanagawa-lotus.xml
deleted file mode 100644
index dde3bc8..0000000
--- a/internal/theme/themes/kanagawa-lotus.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
diff --git a/internal/theme/themes/kanagawa-wave.xml b/internal/theme/themes/kanagawa-wave.xml
deleted file mode 100644
index cebcda1..0000000
--- a/internal/theme/themes/kanagawa-wave.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
diff --git a/internal/theme/themes/kanagawa.json b/internal/theme/themes/kanagawa.json
new file mode 100644
index 0000000..33ff303
--- /dev/null
+++ b/internal/theme/themes/kanagawa.json
@@ -0,0 +1,153 @@
+{
+ "name": "kanagawa",
+ "line": {
+ "fg": "#dcd7ba",
+ "bg": "#1f1f28"
+ },
+ "background": {
+ "bg": "#1f1f28"
+ },
+ "visual_highlight": {
+ "bg": "#223249"
+ },
+ "cursors": {
+ "normal": {
+ "fg": "#1f1f28",
+ "bg": "#dcd7ba"
+ },
+ "insert": {
+ "fg": "#1f1f28",
+ "bg": "#dcd7ba"
+ },
+ "command": {
+ "fg": "#1f1f28",
+ "bg": "#dcd7ba"
+ },
+ "replace": {
+ "fg": "#1f1f28",
+ "bg": "#dcd7ba"
+ }
+ },
+ "gutter": {
+ "default": {
+ "fg": "#727169",
+ "bg": "#2a2a37"
+ },
+ "current_line": {
+ "fg": "#e6c384",
+ "bg": "#2a2a37"
+ }
+ },
+ "status_bar": {
+ "default": {
+ "fg": "#c8c093",
+ "bg": "#2a2a37"
+ }
+ },
+ "command_line": {
+ "error": {
+ "fg": "#e82424",
+ "bg": "#1f1f28"
+ },
+ "output_border": {
+ "fg": "#dcd7ba",
+ "bg": "#16161d"
+ },
+ "continue_message": {
+ "fg": "#7e9cd8",
+ "bg": "#1f1f28"
+ }
+ },
+ "syntax": {
+ "group": {
+ "attribute": "#e6c384",
+ "boolean": "#d27e99",
+ "character": "#98bb6c",
+ "charset": "#7aa89f",
+ "comment": "#727169",
+ "conceal": "#727169",
+ "constant": "#ffa066",
+ "constructor": "#e6c384",
+ "error": "#e82424",
+ "function": "#7e9cd8",
+ "import": "#957fb8",
+ "interface": "#7aa89f",
+ "keyframes": "#e46876",
+ "keyword": "#957fb8",
+ "label": "#e46876",
+ "media": "#e46876",
+ "module": "#7fb4ca",
+ "namespace": "#7fb4ca",
+ "none": "#dcd7ba",
+ "nospell": "#dcd7ba",
+ "number": "#d27e99",
+ "operator": "#c0a36e",
+ "property": "#e6c384",
+ "spell": "#dcd7ba",
+ "string": "#98bb6c",
+ "supports": "#e46876",
+ "tag": "#7fb4ca",
+ "type": "#7aa89f",
+ "variable": "#dcd7ba"
+ },
+ "exact": {
+ "attribute.builtin": "#e6c384",
+ "character.special": "#c0a36e",
+ "comment.documentation": "#c8c093",
+ "constant.builtin": "#ffa066",
+ "constant.macro": "#e46876",
+ "function.builtin": "#7fb4ca",
+ "function.call": "#7e9cd8",
+ "function.macro": "#e46876",
+ "function.method": "#7e9cd8",
+ "function.method.call": "#7e9cd8",
+ "keyword.conditional": "#957fb8",
+ "keyword.conditional.ternary": "#957fb8",
+ "keyword.coroutine": "#957fb8",
+ "keyword.debug": "#957fb8",
+ "keyword.directive": "#e46876",
+ "keyword.directive.define": "#e46876",
+ "keyword.exception": "#957fb8",
+ "keyword.function": "#957fb8",
+ "keyword.import": "#957fb8",
+ "keyword.modifier": "#957fb8",
+ "keyword.operator": "#c0a36e",
+ "keyword.repeat": "#957fb8",
+ "keyword.return": "#957fb8",
+ "keyword.type": "#7aa89f",
+ "markup.heading": "#e6c384",
+ "markup.heading.1": "#e6c384",
+ "markup.heading.2": "#dca561",
+ "markup.heading.3": "#c0a36e",
+ "markup.heading.4": "#b6927b",
+ "markup.heading.5": "#957fb8",
+ "markup.heading.6": "#7e9cd8",
+ "markup.italic": "#b8b4d0",
+ "markup.link.label": "#7e9cd8",
+ "markup.raw": "#98bb6c",
+ "markup.strikethrough": "#727169",
+ "markup.strong": "#c8c093",
+ "markup.underline": "#7fb4ca",
+ "module.builtin": "#7fb4ca",
+ "number.float": "#d27e99",
+ "punctuation.bracket": "#9cabca",
+ "punctuation.delimiter": "#9cabca",
+ "punctuation.special": "#c0a36e",
+ "string.documentation": "#c8c093",
+ "string.escape": "#c0a36e",
+ "string.regexp": "#c0a36e",
+ "string.special.path": "#98bb6c",
+ "string.special.symbol": "#ffa066",
+ "string.special.url": "#7e9cd8",
+ "tag.attribute": "#e6c384",
+ "tag.attribute.url": "#7e9cd8",
+ "tag.builtin": "#7fb4ca",
+ "tag.delimiter": "#9cabca",
+ "type.builtin": "#7aa89f",
+ "type.definition": "#7aa89f",
+ "variable.builtin": "#ffa066",
+ "variable.member": "#e6c384",
+ "variable.parameter": "#b8b4d0"
+ }
+ }
+}