Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,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
Expand Down Expand Up @@ -655,6 +656,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)
Expand Down
21 changes: 21 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,27 @@ func (p *Parser) parseSet() Command {
)
}
}
case token.PROGRESS_BAR:
cmd.Args = p.peek.Literal
p.nextToken()

progressBar := p.cur.Literal

// Validate hex color: #RGB, #RRGGBB, or #RRGGBBAA
if strings.HasPrefix(progressBar, "#") {
hex := progressBar[1:]
_, err := strconv.ParseUint(hex, 16, 64)

if 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()
Expand Down
62 changes: 62 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,68 @@ 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
Expand Down
1 change: 1 addition & 0 deletions style.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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.
Expand Down
20 changes: 20 additions & 0 deletions svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ func (g *SVGGenerator) Generate() string {

sb.WriteString("</g>") // Close animation container
g.writeNewline(&sb)

// Progress bar: full-width bar at bottom that grows left to right via scaleX.
// Only rendered when the user sets a color via "Set ProgressBar <color>".
// Placed inside the inner SVG so the CSS animation rule (also in this scope) applies
// in both browsers and non-browser renderers (librsvg, Inkscape, etc.).
if style.ProgressBarColor != "" {
sb.WriteString(fmt.Sprintf(`<rect class="progress-bar" x="0" y="%d" width="%s" height="1" fill="%s"/>`,
innerHeight-1, formatCoord(viewBoxWidth), style.ProgressBarColor))
g.writeNewline(&sb)
}

sb.WriteString("</svg>") // Close inner SVG
g.writeNewline(&sb)

Expand Down Expand Up @@ -1104,6 +1115,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(fmt.Sprintf("@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }"))
g.writeNewline(&sb)
sb.WriteString(fmt.Sprintf(".progress-bar { transform-origin: 0 0; animation: progress %ss linear %ss infinite; }",
formatDuration(animationDuration), formatDuration(animationDelay)))
g.writeNewline(&sb)
}

sb.WriteString("</style>")
g.writeNewline(&sb)

Expand Down
70 changes: 70 additions & 0 deletions svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1478,3 +1478,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 </svg> close
progressIdx := strings.Index(svg, `class="progress-bar"`)
// Count </svg> tags — the progress bar should be before the first </svg>
firstCloseSVG := strings.Index(svg, "</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 </svg>)")
}
})

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")
})
}
4 changes: 3 additions & 1 deletion token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,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.
Expand Down Expand Up @@ -165,6 +166,7 @@ var Keywords = map[string]Type{
"Wait": WAIT,
"Source": SOURCE,
"CursorBlink": CURSOR_BLINK,
"ProgressBar": PROGRESS_BAR,
"true": BOOLEAN,
"false": BOOLEAN,
"Screenshot": SCREENSHOT,
Expand All @@ -179,7 +181,7 @@ func IsSetting(t Type) bool {
case SHELL, FONT_FAMILY, FONT_SIZE, LETTER_SPACING, LINE_HEIGHT,
FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH,
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
Expand Down