Skip to content

Commit 80b6466

Browse files
committed
feat: add support for themes
1 parent bf73461 commit 80b6466

File tree

19 files changed

+999
-8
lines changed

19 files changed

+999
-8
lines changed

cmd/termsvg/export/export.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7+
"log"
78
"os"
89
"path/filepath"
910
"strings"
@@ -15,6 +16,7 @@ import (
1516
"github.com/mrmarble/termsvg/pkg/renderer/gif"
1617
"github.com/mrmarble/termsvg/pkg/renderer/svg"
1718
"github.com/mrmarble/termsvg/pkg/renderer/webm"
19+
"github.com/mrmarble/termsvg/pkg/theme"
1820
"github.com/tdewolff/minify/v2"
1921
msvg "github.com/tdewolff/minify/v2/svg"
2022
)
@@ -30,6 +32,7 @@ type Cmd struct {
3032
Cols int `short:"c" default:"0" help:"Override columns (0 = use original)"`
3133
Rows int `short:"r" default:"0" help:"Override rows (0 = use original)"`
3234
Debug bool `short:"d" help:"Enable debug logging"`
35+
Theme string `short:"t" help:"Theme name (built-in) or path to theme JSON file"`
3336
}
3437

3538
//nolint:funlen // sequential steps are clearer in one function
@@ -61,10 +64,43 @@ func (cmd *Cmd) Run() error {
6164
cast.Header.Height = cmd.Rows
6265
}
6366

67+
// Determine theme to use
68+
selectedTheme := theme.Default()
69+
themeSource := "default"
70+
71+
if cmd.Theme != "" {
72+
// CLI flag takes priority
73+
t, err := theme.Load(cmd.Theme)
74+
if err != nil {
75+
fmt.Fprintf(os.Stderr, "Warning: failed to load theme %q: %v\n", cmd.Theme, err)
76+
fmt.Fprintf(os.Stderr, "Falling back to default theme\n")
77+
} else {
78+
selectedTheme = t
79+
themeSource = "CLI flag"
80+
}
81+
} else if cast.Header.Theme.Fg != "" {
82+
// Use theme from asciicast header
83+
t, err := theme.FromAsciinema("asciicast", cast.Header.Theme.Fg,
84+
cast.Header.Theme.Bg, cast.Header.Theme.Palette)
85+
if err != nil {
86+
if cmd.Debug {
87+
log.Printf("[Export] Invalid theme in asciicast header: %v", err)
88+
}
89+
} else {
90+
selectedTheme = t
91+
themeSource = "asciicast header"
92+
}
93+
}
94+
95+
if cmd.Debug {
96+
log.Printf("[Export] Using theme from %s: %s", themeSource, selectedTheme.Name)
97+
}
98+
6499
// Process through IR
65100
procConfig := ir.DefaultProcessorConfig()
66101
procConfig.Speed = cmd.Speed
67102
procConfig.IdleTimeLimit = cmd.MaxIdle
103+
procConfig.Theme = selectedTheme
68104

69105
proc := ir.NewProcessor(procConfig)
70106
rec, err := proc.Process(cast)
@@ -77,6 +113,7 @@ func (cmd *Cmd) Run() error {
77113
renderConfig.ShowWindow = !cmd.NoWindow
78114
renderConfig.Minify = cmd.Minify
79115
renderConfig.Debug = cmd.Debug
116+
renderConfig.Theme = selectedTheme
80117

81118
var rdr renderer.Renderer
82119
switch format {

cmd/themegen/main.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Theme generator tool for termsvg.
2+
// Reads JSON theme files from themes/ directory and generates Go code.
3+
package main
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"text/template"
12+
"time"
13+
)
14+
15+
// ThemeData represents the JSON structure of a theme file.
16+
type ThemeData struct {
17+
Fg string `json:"fg"`
18+
Bg string `json:"bg"`
19+
Palette string `json:"palette"`
20+
}
21+
22+
// ThemeInfo holds parsed theme data for code generation.
23+
type ThemeInfo struct {
24+
Name string
25+
VarName string
26+
Fg string
27+
Bg string
28+
PaletteOverrides string
29+
WindowBg string
30+
}
31+
32+
const builtinTemplate = `// Code generated by themegen. DO NOT EDIT.
33+
// Generated at: {{ .GeneratedAt }}
34+
35+
package theme
36+
37+
import (
38+
"image/color"
39+
40+
termcolor "github.com/mrmarble/termsvg/pkg/color"
41+
)
42+
43+
// builtinThemes is a registry of all built-in themes.
44+
var builtinThemes = map[string]Theme{
45+
{{- range .Themes }}
46+
"{{ .Name }}": {{ .VarName }},
47+
{{- end }}
48+
}
49+
50+
{{ range .Themes }}
51+
// {{ .VarName }} is the "{{ .Name }}" theme.
52+
var {{ .VarName }} = Theme{
53+
Name: "{{ .Name }}",
54+
Foreground: color.RGBA{ {{ .Fg }} },
55+
Background: color.RGBA{ {{ .Bg }} },
56+
Palette: {{ .VarName }}Palette,
57+
WindowBackground: color.RGBA{ {{ .WindowBg }} },
58+
WindowButtons: [3]color.RGBA{
59+
{R: 255, G: 95, B: 86, A: 255}, // Close
60+
{R: 255, G: 189, B: 46, A: 255}, // Minimize
61+
{R: 24, G: 193, B: 50, A: 255}, // Maximize
62+
},
63+
}
64+
65+
// {{ .VarName }}Palette is the color palette for the "{{ .Name }}" theme.
66+
// It extends the standard xterm palette with custom colors for the first 16 ANSI colors.
67+
var {{ .VarName }}Palette = func() termcolor.Palette {
68+
p := termcolor.Standard()
69+
{{ .PaletteOverrides }}
70+
return p
71+
}()
72+
{{ end }}
73+
`
74+
75+
// colorToGo converts a hex color to Go RGBA struct format.
76+
func colorToGo(hex string) string {
77+
// Remove # prefix
78+
hex = strings.TrimPrefix(hex, "#")
79+
80+
// Handle short form (RGB -> RRGGBB)
81+
if len(hex) == 3 {
82+
hex = string(hex[0]) + string(hex[0]) +
83+
string(hex[1]) + string(hex[1]) +
84+
string(hex[2]) + string(hex[2])
85+
}
86+
87+
if len(hex) != 6 {
88+
return "{R: 0, G: 0, B: 0, A: 255}"
89+
}
90+
91+
// Parse hex components
92+
var r, g, b int
93+
fmt.Sscanf(hex[0:2], "%x", &r)
94+
fmt.Sscanf(hex[2:4], "%x", &g)
95+
fmt.Sscanf(hex[4:6], "%x", &b)
96+
97+
return fmt.Sprintf("{R: %d, G: %d, B: %d, A: 255}", r, g, b)
98+
}
99+
100+
type TemplateData struct {
101+
GeneratedAt string
102+
Themes []ThemeInfo
103+
}
104+
105+
func main() {
106+
// Find themes directory
107+
themesDir := "themes"
108+
if _, err := os.Stat(themesDir); os.IsNotExist(err) {
109+
// Try from pkg/theme directory
110+
themesDir = "../../themes"
111+
if _, err := os.Stat(themesDir); os.IsNotExist(err) {
112+
fmt.Fprintf(os.Stderr, "Error: themes directory not found\n")
113+
os.Exit(1)
114+
}
115+
}
116+
117+
// Read all theme files
118+
entries, err := os.ReadDir(themesDir)
119+
if err != nil {
120+
fmt.Fprintf(os.Stderr, "Error reading themes directory: %v\n", err)
121+
os.Exit(1)
122+
}
123+
124+
var themes []ThemeInfo
125+
for _, entry := range entries {
126+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
127+
continue
128+
}
129+
130+
theme, err := parseThemeFile(filepath.Join(themesDir, entry.Name()))
131+
if err != nil {
132+
fmt.Fprintf(os.Stderr, "Warning: failed to parse %s: %v\n", entry.Name(), err)
133+
continue
134+
}
135+
136+
themes = append(themes, theme)
137+
}
138+
139+
if len(themes) == 0 {
140+
fmt.Fprintf(os.Stderr, "Error: no valid theme files found\n")
141+
os.Exit(1)
142+
}
143+
144+
// Generate output file
145+
outputFile := "pkg/theme/builtin.go"
146+
if _, err := os.Stat("pkg/theme"); os.IsNotExist(err) {
147+
outputFile = "builtin.go"
148+
}
149+
150+
tmpl, err := template.New("builtin").Parse(builtinTemplate)
151+
if err != nil {
152+
fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
153+
os.Exit(1)
154+
}
155+
156+
file, err := os.Create(outputFile)
157+
if err != nil {
158+
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
159+
os.Exit(1)
160+
}
161+
defer file.Close()
162+
163+
data := TemplateData{
164+
GeneratedAt: time.Now().Format(time.RFC3339),
165+
Themes: themes,
166+
}
167+
168+
if err := tmpl.Execute(file, data); err != nil {
169+
fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err)
170+
os.Exit(1)
171+
}
172+
173+
fmt.Printf("Generated %d themes in %s\n", len(themes), outputFile)
174+
}
175+
176+
func parseThemeFile(path string) (ThemeInfo, error) {
177+
data, err := os.ReadFile(path)
178+
if err != nil {
179+
return ThemeInfo{}, err
180+
}
181+
182+
var themeData ThemeData
183+
if err := json.Unmarshal(data, &themeData); err != nil {
184+
return ThemeInfo{}, err
185+
}
186+
187+
// Validate palette has 16 colors
188+
colors := strings.Split(themeData.Palette, ":")
189+
if len(colors) != 16 {
190+
return ThemeInfo{}, fmt.Errorf("palette must have 16 colors, got %d", len(colors))
191+
}
192+
193+
// Get theme name from filename
194+
name := strings.TrimSuffix(filepath.Base(path), ".json")
195+
196+
// Create variable name (camelCase)
197+
varName := toVarName(name)
198+
199+
// Parse colors (foreground/background use field format)
200+
fg := hexToRGBA(themeData.Fg)
201+
bg := hexToRGBA(themeData.Bg)
202+
203+
// Build palette overrides (only first 16 colors)
204+
var paletteOverrides []string
205+
for i, c := range colors {
206+
paletteOverrides = append(paletteOverrides, fmt.Sprintf("p[%d] = color.RGBA%s", i, colorToGo(c)))
207+
}
208+
209+
// Window background uses the theme's bg property (terminal background)
210+
windowBg := bg
211+
212+
return ThemeInfo{
213+
Name: name,
214+
VarName: varName,
215+
Fg: fg,
216+
Bg: bg,
217+
PaletteOverrides: strings.Join(paletteOverrides, "\n\t"),
218+
WindowBg: windowBg,
219+
}, nil
220+
}
221+
222+
func toVarName(name string) string {
223+
// Convert kebab-case to CamelCase
224+
parts := strings.Split(name, "-")
225+
for i, part := range parts {
226+
parts[i] = strings.Title(part)
227+
}
228+
return strings.Join(parts, "")
229+
}
230+
231+
func hexToRGBA(hex string) string {
232+
// Remove # prefix
233+
hex = strings.TrimPrefix(hex, "#")
234+
235+
// Handle short form (RGB -> RRGGBB)
236+
if len(hex) == 3 {
237+
hex = string(hex[0]) + string(hex[0]) +
238+
string(hex[1]) + string(hex[1]) +
239+
string(hex[2]) + string(hex[2])
240+
}
241+
242+
if len(hex) != 6 {
243+
return "R: 0, G: 0, B: 0, A: 255"
244+
}
245+
246+
// Parse hex components
247+
var r, g, b int
248+
fmt.Sscanf(hex[0:2], "%x", &r)
249+
fmt.Sscanf(hex[2:4], "%x", &g)
250+
fmt.Sscanf(hex[4:6], "%x", &b)
251+
252+
return fmt.Sprintf("R: %d, G: %d, B: %d, A: 255", r, g, b)
253+
}

pkg/asciicast/asciicast.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import (
1414
"time"
1515
)
1616

17+
// ThemeInfo represents the theme configuration in asciicast v2 format.
18+
type ThemeInfo struct {
19+
Fg string `json:"fg,omitempty"` // Foreground color (e.g., "#d0d0d0")
20+
Bg string `json:"bg,omitempty"` // Background color (e.g., "#212121")
21+
Palette string `json:"palette,omitempty"` // 16 colon-separated ANSI colors
22+
}
23+
1724
// Header is JSON-encoded object containing recording meta-data.
1825
// fields with 'omitempty' are optional by asciicast v2 format
1926
type Header struct {
@@ -26,6 +33,7 @@ type Header struct {
2633
Command string `json:"command,omitempty"`
2734
Title string `json:"title,omitempty"`
2835
Env map[string]string `json:"env,omitempty"`
36+
Theme ThemeInfo `json:"theme,omitempty"`
2937
}
3038

3139
// Cast contains asciicast file data

pkg/raster/draw.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ func (r *Rasterizer) drawTextRunWithFace(img *image.RGBA, run ir.TextRun, rowY i
3232
x := contentX + run.StartCol*r.config.ColWidth
3333
y := contentY + rowY*r.config.RowHeight
3434

35-
// Get background color - use window background for default to blend with window chrome
35+
// Get background color - use catalog default background for unset cells
3636
var bgColor color.RGBA
3737
if catalog.IsDefault(run.Attrs.BG) {
38-
bgColor = r.config.Theme.WindowBackground
38+
bgColor = catalog.DefaultBackground()
3939
} else {
4040
bgColor = catalog.Resolved(run.Attrs.BG)
4141
}
@@ -121,17 +121,17 @@ func (r *Rasterizer) drawWindow(img *image.RGBA) {
121121

122122
// drawBackground draws a plain background without window chrome.
123123
func (r *Rasterizer) drawBackground(img *image.RGBA) {
124-
bgColor := r.config.Theme.WindowBackground
124+
bgColor := r.config.Theme.Background
125125
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
126126
}
127127

128128
// drawTerminalBackground draws the terminal content area background.
129-
// Uses WindowBackground to blend with the window chrome for a seamless look.
129+
// Uses the theme's Background color for the terminal content.
130130
func (r *Rasterizer) drawTerminalBackground(img *image.RGBA, width, height int) {
131131
contentX := r.config.Padding
132132
contentY := r.contentOffsetY()
133-
// Use WindowBackground instead of Background for seamless window appearance
134-
termBg := r.config.Theme.WindowBackground
133+
// Use theme Background for terminal content area
134+
termBg := r.config.Theme.Background
135135

136136
draw.Draw(img,
137137
image.Rect(contentX, contentY, contentX+width, contentY+height),
@@ -217,10 +217,10 @@ func (r *Rasterizer) drawTextRunToPaletted(img *image.Paletted, run ir.TextRun,
217217
x := contentX + run.StartCol*r.config.ColWidth
218218
y := contentY + rowY*r.config.RowHeight
219219

220-
// Get background color - use window background for default to blend with window chrome
220+
// Get background color - use catalog default background for unset cells
221221
var bgColor color.RGBA
222222
if catalog.IsDefault(run.Attrs.BG) {
223-
bgColor = r.config.Theme.WindowBackground
223+
bgColor = catalog.DefaultBackground()
224224
} else {
225225
bgColor = catalog.Resolved(run.Attrs.BG)
226226
}

0 commit comments

Comments
 (0)