diff --git a/command.go b/command.go index b3be98ab..c5b2e9ff 100644 --- a/command.go +++ b/command.go @@ -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 @@ -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) diff --git a/parser/parser.go b/parser/parser.go index aa468ce8..c5f7fbbb 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -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() diff --git a/parser/parser_test.go b/parser/parser_test.go index a8531990..7adea454 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -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 diff --git a/style.go b/style.go index d5ce5635..14897d56 100644 --- a/style.go +++ b/style.go @@ -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. diff --git a/svg.go b/svg.go index 64828f84..224ec0f1 100644 --- a/svg.go +++ b/svg.go @@ -309,6 +309,17 @@ func (g *SVGGenerator) Generate() string { sb.WriteString("") // 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 ". + // 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(``, + innerHeight-1, formatCoord(viewBoxWidth), style.ProgressBarColor)) + g.writeNewline(&sb) + } + sb.WriteString("") // Close inner SVG g.writeNewline(&sb) @@ -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("") g.writeNewline(&sb) diff --git a/svg_test.go b/svg_test.go index 487f6cc4..ba9574f4 100644 --- a/svg_test.go +++ b/svg_test.go @@ -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 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 acec65f9..7723a7be 100644 --- a/token/token.go +++ b/token/token.go @@ -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. @@ -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, @@ -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