diff --git a/README.md b/README.md
index 20b4854f..fb4f4fa1 100644
--- a/README.md
+++ b/README.md
@@ -673,6 +673,21 @@ Set CursorBlink false
+#### ProgressBar
+
+Add a one-pixel progress bar at the bottom of the SVG terminal viewport:
+
+```elixir
+Set ProgressBar "#9B79FF"
+```
+
+Use a quoted `#RGB`, `#RRGGBB`, or `#RRGGBBAA` hex color. The bar is off by
+default and applies only to SVG output. It uses the terminal animation's
+`PlaybackSpeed` and `LoopOffset` timing.
+
+Browsers animate the bar during playback. Static renderers such as librsvg and
+Inkscape display a full-width bar without playback animation.
+
### Type
Use `Type` to emulate key presses. That is, you can use `Type` to script typing
diff --git a/command.go b/command.go
index 35f3bea4..b71182d5 100644
--- a/command.go
+++ b/command.go
@@ -498,6 +498,7 @@ var Settings = map[string]CommandFunc{
"WaitPattern": ExecuteSetWaitPattern,
"WaitTimeout": ExecuteSetWaitTimeout,
"CursorBlink": ExecuteSetCursorBlink,
+ "ProgressBar": ExecuteSetProgressBar,
}
// ExecuteSet applies the settings on the running vhs specified by the
@@ -764,6 +765,12 @@ func ExecuteSetMarginFill(c parser.Command, v *VHS) error {
return nil
}
+// ExecuteSetProgressBar sets the progress bar color.
+func ExecuteSetProgressBar(c parser.Command, v *VHS) error {
+ v.Options.Video.Style.ProgressBarColor = c.Args
+ return nil
+}
+
// ExecuteSetMargin sets vhs margin size.
func ExecuteSetMargin(c parser.Command, v *VHS) error {
margin, err := strconv.Atoi(c.Args)
diff --git a/man.go b/man.go
index 8eb13787..7c9336a1 100644
--- a/man.go
+++ b/man.go
@@ -77,6 +77,7 @@ The following is a list of all possible setting commands in VHS:
* Set %Padding%
* Set %Framerate%
* Set %PlaybackSpeed%
+* Set %ProgressBar% ""
* Set %WaitTimeout%
* Set %WaitPattern%
`
diff --git a/parser/parser.go b/parser/parser.go
index 26278be3..141e5cdc 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -513,6 +513,18 @@ func (p *Parser) parseSet() Command {
)
}
}
+ case token.PROGRESS_BAR:
+ cmd.Args = p.peek.Literal
+ p.nextToken()
+
+ progressBar := p.cur.Literal
+
+ hex, hasPrefix := strings.CutPrefix(progressBar, "#")
+ _, err := strconv.ParseUint(hex, 16, 32)
+ if !hasPrefix || err != nil || (len(hex) != 3 && len(hex) != 6 && len(hex) != 8) {
+ p.errors = append(p.errors, NewError(p.cur,
+ "\""+progressBar+"\" is not a valid color. Use #RGB, #RRGGBB, or #RRGGBBAA."))
+ }
case token.CURSOR_BLINK:
cmd.Args = p.peek.Literal
p.nextToken()
diff --git a/parser/parser_test.go b/parser/parser_test.go
index 76971824..4d52476a 100644
--- a/parser/parser_test.go
+++ b/parser/parser_test.go
@@ -438,6 +438,69 @@ func TestParseSource(t *testing.T) {
})
}
+func TestParseProgressBar(t *testing.T) {
+ t.Run("valid hex colors", func(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {`Set ProgressBar "#F00"`, "#F00"},
+ {`Set ProgressBar "#FF0000"`, "#FF0000"},
+ {`Set ProgressBar "#FF000080"`, "#FF000080"},
+ }
+
+ for _, tc := range tests {
+ l := lexer.New(tc.input)
+ p := New(l)
+ cmds := p.Parse()
+
+ if len(p.errors) > 0 {
+ t.Errorf("Unexpected error for %q: %s", tc.input, p.errors[0])
+ }
+ if len(cmds) != 1 {
+ t.Fatalf("Expected 1 command for %q, got %d", tc.input, len(cmds))
+ }
+ if cmds[0].Type != token.SET {
+ t.Errorf("Expected SET command, got %s", cmds[0].Type)
+ }
+ if cmds[0].Options != "ProgressBar" {
+ t.Errorf("Expected Options 'ProgressBar', got %q", cmds[0].Options)
+ }
+ if cmds[0].Args != tc.expected {
+ t.Errorf("Expected Args %q, got %q", tc.expected, cmds[0].Args)
+ }
+ }
+ })
+
+ t.Run("invalid colors produce errors", func(t *testing.T) {
+ tests := []string{
+ `Set ProgressBar "#GG0000"`,
+ `Set ProgressBar "#FF00"`,
+ `Set ProgressBar "#FF0000000"`,
+ }
+
+ for _, input := range tests {
+ l := lexer.New(input)
+ p := New(l)
+ _ = p.Parse()
+
+ if len(p.errors) == 0 {
+ t.Errorf("Expected error for %q, got none", input)
+ }
+ found := false
+ for _, err := range p.errors {
+ if strings.Contains(err.String(), "not a valid color") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("Expected 'not a valid color' error for %q, got: %v", input, p.errors)
+ }
+ }
+ })
+}
+
type parseScreenshotTest struct {
tape string
errors []string
diff --git a/parser/progress_test.go b/parser/progress_test.go
new file mode 100644
index 00000000..c979af80
--- /dev/null
+++ b/parser/progress_test.go
@@ -0,0 +1,40 @@
+package parser
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/agentstation/vhs/lexer"
+)
+
+func TestProgressBarRejectsNonHexColors(t *testing.T) {
+ for _, color := range []string{"red", `red"broken`, "", "#", "#1234", "#+12", "#12-", "#GGG", "#1234567", "#123456789", "#123", "#fff\n"} {
+ t.Run(color, func(t *testing.T) {
+ p := New(lexer.New(fmt.Sprintf("Set ProgressBar '%s'", color)))
+ _ = p.Parse()
+ if len(p.Errors()) == 0 {
+ t.Fatalf("invalid progress color %q accepted", color)
+ }
+ })
+ }
+}
+
+func TestProgressBarPreservesHexColors(t *testing.T) {
+ for _, color := range []string{"#000", "#fAb", "#Ab12eF", "#00000000", "#aB12Cd80"} {
+ t.Run(color, func(t *testing.T) {
+ p := New(lexer.New(fmt.Sprintf("Set ProgressBar '%s'", color)))
+ cmds := p.Parse()
+ if len(p.Errors()) != 0 || len(cmds) != 1 || cmds[0].Args != color {
+ t.Fatalf("color %q: commands=%v errors=%v", color, cmds, p.Errors())
+ }
+ })
+ }
+}
+
+func TestProgressBarRequiresColor(t *testing.T) {
+ p := New(lexer.New("Set ProgressBar"))
+ _ = p.Parse()
+ if len(p.Errors()) == 0 {
+ t.Fatal("missing color accepted")
+ }
+}
diff --git a/style.go b/style.go
index faa929f6..f9d0d28e 100644
--- a/style.go
+++ b/style.go
@@ -77,6 +77,7 @@ type StyleOptions struct {
FontSize int // Font size passed from VHS options
WindowBarFontFamily string // Font family specifically for window bar title
WindowBarFontSize int // Font size specifically for window bar title
+ ProgressBarColor string // Color for progress bar (empty = no bar)
}
// DefaultStyleOptions returns default Style config.
diff --git a/svg.go b/svg.go
index 09a05421..fe0827d4 100644
--- a/svg.go
+++ b/svg.go
@@ -331,6 +331,14 @@ func (g *SVGGenerator) Generate() string {
sb.WriteString("") // Close animation container
g.writeNewline(&sb)
+
+ // Keep the optional progress bar inside the terminal viewport.
+ if style.ProgressBarColor != "" {
+ _, _ = fmt.Fprintf(&sb, ` `,
+ innerHeight-1, formatCoord(viewBoxWidth), html.EscapeString(style.ProgressBarColor))
+ g.writeNewline(&sb)
+ }
+
sb.WriteString("") // Close inner SVG
g.writeNewline(&sb)
@@ -1127,6 +1135,15 @@ func (g *SVGGenerator) generateStyles() string {
g.writeNewline(&sb)
}
+ // Progress bar animation: grows from left to right over the animation duration
+ if g.options.Style != nil && g.options.Style.ProgressBarColor != "" {
+ sb.WriteString("@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }")
+ g.writeNewline(&sb)
+ _, _ = fmt.Fprintf(&sb, ".progress-bar { transform-origin: 0 0; animation: progress %ss linear %ss infinite; }",
+ formatDuration(animationDuration), formatDuration(animationDelay))
+ g.writeNewline(&sb)
+ }
+
sb.WriteString("")
g.writeNewline(&sb)
diff --git a/svg_progress_test.go b/svg_progress_test.go
new file mode 100644
index 00000000..1f154880
--- /dev/null
+++ b/svg_progress_test.go
@@ -0,0 +1,330 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "image/png"
+ "io"
+ "math"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/agentstation/vhs/lexer"
+ "github.com/agentstation/vhs/parser"
+ "github.com/go-rod/rod/lib/proto"
+)
+
+func TestProgressBarEscapesAttribute(t *testing.T) {
+ color := `red" onload="alert(1)<&'`
+ opts := createTestSVGConfig()
+ opts.Style.ProgressBarColor = color
+ data := NewSVGGenerator(opts).Generate()
+ decoder := xml.NewDecoder(strings.NewReader(data))
+ found := false
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("invalid XML: %v", err)
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "rect" {
+ continue
+ }
+ attrs := make(map[string]string)
+ for _, attr := range start.Attr {
+ attrs[attr.Name.Local] = attr.Value
+ }
+ if attrs["class"] == "progress-bar" {
+ found = true
+ if attrs["fill"] != color || attrs["onload"] != "" {
+ t.Fatalf("progress attributes: %v", attrs)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("progress rectangle missing")
+ }
+}
+
+func TestProgressBarAlphaRendering(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style.Width, opts.Style.Height = 320, 200
+ opts.Style.Padding, opts.Style.Margin, opts.Style.BorderRadius = 0, 0, 0
+ opts.Style.WindowBar = ""
+ opts.Style.ProgressBarColor = "#FF000080"
+ opts.Theme.Background = "#000000"
+ opts.Frames = []SVGFrame{{Lines: []string{""}, CharWidth: 8, CharHeight: 20}}
+ output := filepath.Join(t.TempDir(), "alpha.svg")
+ if err := os.WriteFile(output, []byte(NewSVGGenerator(opts).Generate()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("browser blend and growth", func(t *testing.T) {
+ if os.Getenv("VHS_TEST_BROWSER") == "" {
+ t.Skip("set VHS_TEST_BROWSER=1 to run this test")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
+ defer cancel()
+ browser, closeBrowser, err := startBrowser(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = closeBrowser() }()
+ page, err := browser.Page(proto.TargetCreateTarget{URL: (&url.URL{Scheme: "file", Path: output}).String()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{Width: 340, Height: 220}); err != nil {
+ t.Fatal(err)
+ }
+ if err := page.WaitLoad(); err != nil {
+ t.Fatal(err)
+ }
+ result, err := page.Eval(`async () => {
+ const bar = document.querySelector('.progress-bar');
+ const animation = bar.getAnimations()[0];
+ animation.pause(); await animation.ready;
+ animation.currentTime = animation.effect.getTiming().duration / 2;
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const transform = getComputedStyle(bar).transform;
+ bar.style.animation = 'none'; bar.style.transform = transform;
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const rect = bar.getBoundingClientRect();
+ return {x: rect.x, y: rect.y, width: rect.width};
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if math.Abs(result.Value.Get("width").Num()-160) > .1 {
+ t.Fatalf("half-phase width=%v, want 160", result.Value.Get("width").Num())
+ }
+ data, err := page.Screenshot(false, &proto.PageCaptureScreenshot{Format: proto.PageCaptureScreenshotFormatPng})
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ x, y := int(result.Value.Get("x").Num()), int(result.Value.Get("y").Num())
+ r, g, b, a := img.At(x+20, y).RGBA()
+ if r>>8 != 128 || g != 0 || b != 0 || a != 65535 {
+ t.Fatalf("RGBA blend pixel=(%d,%d,%d,%d), want (128,0,0,255)", r>>8, g>>8, b>>8, a>>8)
+ }
+ r, g, b, a = img.At(x+240, y).RGBA()
+ if r != 0 || g != 0 || b != 0 || a != 65535 {
+ t.Fatalf("unfilled pixel=(%d,%d,%d,%d), want opaque black", r>>8, g>>8, b>>8, a>>8)
+ }
+ })
+
+ t.Run("librsvg static full bar", func(t *testing.T) {
+ if _, err := exec.LookPath("rsvg-convert"); err != nil {
+ t.Skip("rsvg-convert is not installed")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
+ defer cancel()
+ pngPath := filepath.Join(t.TempDir(), "static.png")
+ if data, err := exec.CommandContext(ctx, "rsvg-convert", "--output", pngPath, output).CombinedOutput(); err != nil {
+ t.Fatalf("rsvg-convert: %v: %s", err, data)
+ }
+ data, err := os.ReadFile(pngPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if img.Bounds().Dx() != 320 || img.Bounds().Dy() != 200 {
+ t.Fatalf("static bounds=%v, want 320x200", img.Bounds())
+ }
+ for _, x := range []int{20, 160, 300} {
+ r, g, b, a := img.At(x, 199).RGBA()
+ if r>>8 != 128 || g != 0 || b != 0 || a != 65535 {
+ t.Fatalf("static x=%d pixel=(%d,%d,%d,%d), want (128,0,0,255)", x, r>>8, g>>8, b>>8, a>>8)
+ }
+ }
+ })
+}
+
+func TestProgressBarCommandAndTiming(t *testing.T) {
+ v := New()
+ t.Cleanup(func() { _ = v.Cleanup() })
+ p := parser.New(lexer.New(`Set ProgressBar "#12aB3480"`))
+ commands := p.Parse()
+ if len(p.Errors()) != 0 || len(commands) != 1 {
+ t.Fatalf("commands=%v errors=%v", commands, p.Errors())
+ }
+ if err := Execute(commands[0], &v); err != nil {
+ t.Fatal(err)
+ }
+ if v.Options.Video.Style.ProgressBarColor != "#12aB3480" {
+ t.Fatal("setting did not reach video style")
+ }
+ for _, tc := range []struct {
+ name string
+ speed float64
+ offset float64
+ duration string
+ delay string
+ }{
+ {"normal", 1, 0, "10s", "0s"},
+ {"double and quarter offset", 2, 25, "5s", "-1.25s"},
+ {"half and half offset", .5, 50, "20s", "-10s"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style = v.Options.Video.Style
+ opts.Duration = 10
+ opts.PlaybackSpeed = tc.speed
+ opts.LoopOffset = tc.offset
+ data := NewSVGGenerator(opts).Generate()
+ if !strings.Contains(data, "animation: slide "+tc.duration+" step-end "+tc.delay+" infinite;") || !strings.Contains(data, "animation: progress "+tc.duration+" linear "+tc.delay+" infinite;") {
+ t.Fatalf("progress and terminal clocks diverge: %s", data)
+ }
+ })
+ }
+}
+
+func TestProgressBarNativeRendering(t *testing.T) {
+ if os.Getenv("VHS_TEST_BROWSER") == "" {
+ t.Skip("set VHS_TEST_BROWSER=1 to run this test")
+ }
+ for _, enabled := range []bool{false, true} {
+ t.Run(fmt.Sprintf("enabled=%t", enabled), func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
+ defer cancel()
+ output := filepath.Join(t.TempDir(), "progress.svg")
+ setting := ""
+ if enabled {
+ setting = "Set ProgressBar \"#FF0000\"\n"
+ }
+ tape := fmt.Sprintf("Output %q\nSet Shell bash\nSet Width 320\nSet Height 200\nSet Padding 0\nSet TypingSpeed 1ms\nSet FontSize 16\nSet PlaybackSpeed 2\nSet LoopOffset 25\nSet CursorBlink false\n%sType \"printf ready\"\nEnter\nSleep 250ms\n", filepath.ToSlash(output), setting)
+ if errs := Evaluate(ctx, tape, io.Discard); len(errs) != 0 {
+ t.Fatalf("capture errors: %v", errs)
+ }
+ browser, closeBrowser, err := startBrowser(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = closeBrowser() }()
+ page, err := browser.Page(proto.TargetCreateTarget{URL: (&url.URL{Scheme: "file", Path: output}).String()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{Width: 320, Height: 200}); err != nil {
+ t.Fatal(err)
+ }
+ if err := page.WaitLoad(); err != nil {
+ t.Fatal(err)
+ }
+ clock, err := page.Eval(`async () => {
+ const bar = document.querySelector('.progress-bar');
+ const slide = document.querySelector('.animation-container').getAnimations()[0];
+ const animations = document.getAnimations();
+ for (const animation of animations) animation.pause();
+ await Promise.all(animations.map(animation => animation.ready));
+ if (!bar) return { enabled: false };
+ const progress = bar.getAnimations()[0];
+ const a = progress.effect.getTiming(), b = slide.effect.getTiming();
+ return { enabled: true, duration: a.duration, delay: a.delay,
+ slideDuration: b.duration, slideDelay: b.delay,
+ fill: getComputedStyle(bar).fill, height: bar.getBBox().height,
+ width: bar.getBBox().width,
+ viewportWidth: bar.ownerSVGElement.viewBox.baseVal.width,
+ pixelWidth: bar.ownerSVGElement.width.baseVal.value };
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if clock.Value.Get("enabled").Bool() != enabled {
+ t.Fatalf("progress default mismatch: %s", clock.Value.JSON("", ""))
+ }
+ if !enabled {
+ return
+ }
+ duration := clock.Value.Get("duration").Num()
+ delay := clock.Value.Get("delay").Num()
+ // Duration and delay each round to the nearest ten milliseconds.
+ const roundingToleranceMS = 5 * (1 + .25)
+ if duration <= 0 || duration != clock.Value.Get("slideDuration").Num() || delay != clock.Value.Get("slideDelay").Num() || math.Abs(delay+duration*.25) > roundingToleranceMS+.01 {
+ t.Fatalf("animation clocks: %s", clock.Value.JSON("", ""))
+ }
+ if clock.Value.Get("fill").Str() != "rgb(255, 0, 0)" || clock.Value.Get("height").Num() != 1 || clock.Value.Get("width").Num() != clock.Value.Get("viewportWidth").Num() {
+ t.Fatalf("bar geometry/color: %s", clock.Value.JSON("", ""))
+ }
+ pixelWidth := clock.Value.Get("pixelWidth").Num()
+ phase := -delay / duration
+ for _, sample := range []struct {
+ timeFraction float64
+ fillFraction float64
+ }{{0, phase}, {.5, phase + .5}, {.999 - phase, .999}, {1.01 - phase, .01}} {
+ geometry, err := page.Eval(`async time => {
+ for (const animation of document.getAnimations()) animation.currentTime = time;
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const bar = document.querySelector('.progress-bar');
+ const rect = bar.getBoundingClientRect();
+ return { width: rect.width, y: rect.y, transform: getComputedStyle(bar).transform };
+ }`, duration*sample.timeFraction)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantWidth := pixelWidth * sample.fillFraction
+ if math.Abs(geometry.Value.Get("width").Num()-wantWidth) > .1 {
+ t.Fatalf("time=%v: bar width=%v, want=%v", sample.timeFraction, geometry.Value.Get("width").Num(), wantWidth)
+ }
+ // Freeze the computed production transform so screenshots do not use
+ // a stale compositor frame after seeking a paused CSS animation.
+ _, err = page.Eval(`async transform => {
+ const bar = document.querySelector('.progress-bar');
+ bar.style.animation = 'none';
+ bar.style.transform = transform;
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ }`, geometry.Value.Get("transform").Str())
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, err := page.Screenshot(false, &proto.PageCaptureScreenshot{Format: proto.PageCaptureScreenshotFormatPng})
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ redPixels := 0
+ y := int(geometry.Value.Get("y").Num())
+ for x := range img.Bounds().Dx() {
+ r, g, b, _ := img.At(x, y).RGBA()
+ if r > 0xf000 && g < 0x1000 && b < 0x1000 {
+ redPixels++
+ }
+ }
+ if math.Abs(float64(redPixels)-wantWidth) > 2 {
+ t.Fatalf("time=%v: painted red pixels=%d, want=%v, transform=%s, bounds=%v, y=%d", sample.timeFraction, redPixels, wantWidth, geometry.Value.Get("transform").Str(), img.Bounds(), y)
+ }
+ _, err = page.Eval(`async () => {
+ const bar = document.querySelector('.progress-bar');
+ bar.style.removeProperty('animation');
+ bar.style.removeProperty('transform');
+ const animation = bar.getAnimations()[0];
+ animation.pause();
+ await animation.ready;
+ }`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+ }
+}
diff --git a/svg_test.go b/svg_test.go
index 47a004f4..b9b16a88 100644
--- a/svg_test.go
+++ b/svg_test.go
@@ -1481,3 +1481,73 @@ func TestSVGGenerator_TypingAnimationCSS(t *testing.T) {
}
})
}
+
+// Progress Bar Tests
+
+func TestSVGGenerator_ProgressBar(t *testing.T) {
+ t.Run("no progress bar by default", func(t *testing.T) {
+ opts := createTestSVGConfig()
+
+ gen := NewSVGGenerator(opts)
+ svg := gen.Generate()
+
+ assertNotContains(t, svg, "progress-bar", "Should not contain progress bar by default")
+ assertNotContains(t, svg, "@keyframes progress", "Should not contain progress keyframes by default")
+ })
+
+ t.Run("renders progress bar with explicit color", func(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style.ProgressBarColor = "#9B79FF"
+
+ gen := NewSVGGenerator(opts)
+ svg := gen.Generate()
+
+ assertContains(t, svg, `class="progress-bar"`, "Should contain progress bar rect")
+ assertContains(t, svg, `fill="#9B79FF"`, "Should use specified color")
+ assertContains(t, svg, "@keyframes progress", "Should contain progress keyframes")
+ assertContains(t, svg, ".progress-bar {", "Should contain progress-bar CSS rule")
+ })
+
+ t.Run("supports RGBA color", func(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style.ProgressBarColor = "#9B79FF80"
+
+ gen := NewSVGGenerator(opts)
+ svg := gen.Generate()
+
+ assertContains(t, svg, `fill="#9B79FF80"`, "Should use RGBA color")
+ })
+
+ t.Run("progress bar is inside inner SVG", func(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style.ProgressBarColor = "#FF0000"
+
+ gen := NewSVGGenerator(opts)
+ svg := gen.Generate()
+
+ // The progress bar rect should appear before the inner close
+ progressIdx := strings.Index(svg, `class="progress-bar"`)
+ // Count tags — the progress bar should be before the first
+ firstCloseSVG := strings.Index(svg, "")
+
+ if progressIdx < 0 {
+ t.Fatal("Progress bar not found in SVG")
+ }
+ if progressIdx > firstCloseSVG {
+ t.Error("Progress bar should be inside the inner SVG (before first )")
+ }
+ })
+
+ t.Run("animation duration matches slide animation", func(t *testing.T) {
+ opts := createTestSVGConfig()
+ opts.Style.ProgressBarColor = "#FF0000"
+ opts.Duration = 5.0
+
+ gen := NewSVGGenerator(opts)
+ svg := gen.Generate()
+
+ // Both the slide animation and progress bar should reference the same duration
+ assertContains(t, svg, "animation: progress", "Should have progress animation")
+ assertContains(t, svg, "animation: slide", "Should have slide animation")
+ })
+}
diff --git a/token/token.go b/token/token.go
index a8861821..66be8613 100644
--- a/token/token.go
+++ b/token/token.go
@@ -112,6 +112,7 @@ const (
WAIT_TIMEOUT = "WAIT_TIMEOUT" //nolint:revive
WAIT_PATTERN = "WAIT_PATTERN" //nolint:revive
CURSOR_BLINK = "CURSOR_BLINK" //nolint:revive
+ PROGRESS_BAR = "PROGRESS_BAR" //nolint:revive
)
// Keywords maps keyword strings to tokens.
@@ -175,6 +176,7 @@ var Keywords = map[string]Type{
"Wait": WAIT,
"Source": SOURCE,
"CursorBlink": CURSOR_BLINK,
+ "ProgressBar": PROGRESS_BAR,
"true": BOOLEAN,
"false": BOOLEAN,
"Screenshot": SCREENSHOT,
@@ -190,7 +192,7 @@ func IsSetting(t Type) bool {
FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH,
ROWS, COLUMNS,
PADDING, LOOP_OFFSET, MARGIN_FILL, MARGIN, WINDOW_BAR,
- WINDOW_BAR_SIZE, WINDOW_BAR_TITLE, WINDOW_BAR_FONT_FAMILY, WINDOW_BAR_FONT_SIZE, BORDER_RADIUS, CURSOR_BLINK, WAIT_TIMEOUT, WAIT_PATTERN:
+ WINDOW_BAR_SIZE, WINDOW_BAR_TITLE, WINDOW_BAR_FONT_FAMILY, WINDOW_BAR_FONT_SIZE, BORDER_RADIUS, CURSOR_BLINK, PROGRESS_BAR, WAIT_TIMEOUT, WAIT_PATTERN:
return true
default:
return false