Skip to content
Merged
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,21 @@ Set CursorBlink false
<img width="600" alt="Example of setting the cursor blink." src="https://vhs.charm.sh/vhs-3rMCb80VEkaDdTOJMCrxKy.gif">
</picture>

#### 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
Expand Down
7 changes: 7 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions man.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ The following is a list of all possible setting commands in VHS:
* Set %Padding% <number>
* Set %Framerate% <number>
* Set %PlaybackSpeed% <float>
* Set %ProgressBar% "<hex-color>"
* Set %WaitTimeout% <time>
* Set %WaitPattern% <regexp>
`
Expand Down
12 changes: 12 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
63 changes: 63 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions parser/progress_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions style.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@ func (g *SVGGenerator) Generate() string {

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

// Keep the optional progress bar inside the terminal viewport.
if style.ProgressBarColor != "" {
_, _ = fmt.Fprintf(&sb, `<rect class="progress-bar" x="0" y="%d" width="%s" height="1" fill="%s"/>`,
innerHeight-1, formatCoord(viewBoxWidth), html.EscapeString(style.ProgressBarColor))
g.writeNewline(&sb)
}

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

Expand Down Expand Up @@ -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("</style>")
g.writeNewline(&sb)

Expand Down
Loading
Loading