diff --git a/README.md b/README.md
index 874048493..4b3784b88 100644
--- a/README.md
+++ b/README.md
@@ -515,6 +515,22 @@ Set CursorBlink false
+#### Set KeyStroke Overlay
+
+Set whether keystrokes in the recording should be rendered in subtitle fashion on the recording. You can toggle keystrokes rendering on or off for sections of the tape.
+
+**NOTE**: Keystroke overlay will use symbols for certain non-printable keystrokes (e.g. Backspace). If these symbols do not render correctly, investigate your `fontconfig` settings for the `monospace` family.
+
+```elixir
+Set KeyStrokes Show
+```
+
+
+
### 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 79832cc9f..aa110188a 100644
--- a/command.go
+++ b/command.go
@@ -89,7 +89,7 @@ func ExecuteKey(k input.Key) CommandFunc {
repeat = 1
}
for i := 0; i < repeat; i++ {
- _ = v.Page.Keyboard.Type(k)
+ v.Page.Keyboard.Type(k)
time.Sleep(typingSpeed)
}
}
@@ -140,45 +140,45 @@ func ExecuteCtrl(c parser.Command, v *VHS) {
// ExecuteAlt is a CommandFunc that presses the argument key with the alt key
// held down on the running instance of vhs.
func ExecuteAlt(c parser.Command, v *VHS) {
- _ = v.Page.Keyboard.Press(input.AltLeft)
+ v.Page.Keyboard.Press(input.AltLeft)
if k, ok := token.Keywords[c.Args]; ok {
switch k {
case token.ENTER:
- _ = v.Page.Keyboard.Type(input.Enter)
+ v.Page.Keyboard.Type(input.Enter)
case token.TAB:
- _ = v.Page.Keyboard.Type(input.Tab)
+ v.Page.Keyboard.Type(input.Tab)
}
} else {
for _, r := range c.Args {
if k, ok := keymap[r]; ok {
- _ = v.Page.Keyboard.Type(k)
+ v.Page.Keyboard.Type(k)
}
}
}
- _ = v.Page.Keyboard.Release(input.AltLeft)
+ v.Page.Keyboard.Release(input.AltLeft)
}
// ExecuteShift is a CommandFunc that presses the argument key with the shift
// key held down on the running instance of vhs.
func ExecuteShift(c parser.Command, v *VHS) {
- _ = v.Page.Keyboard.Press(input.ShiftLeft)
+ v.Page.Keyboard.Press(input.ShiftLeft)
if k, ok := token.Keywords[c.Args]; ok {
switch k {
case token.ENTER:
- _ = v.Page.Keyboard.Type(input.Enter)
+ v.Page.Keyboard.Type(input.Enter)
case token.TAB:
- _ = v.Page.Keyboard.Type(input.Tab)
+ v.Page.Keyboard.Type(input.Tab)
}
} else {
for _, r := range c.Args {
if k, ok := keymap[r]; ok {
- _ = v.Page.Keyboard.Type(k)
+ v.Page.Keyboard.Type(k)
}
}
}
- _ = v.Page.Keyboard.Release(input.ShiftLeft)
+ v.Page.Keyboard.Release(input.ShiftLeft)
}
// ExecuteHide is a CommandFunc that starts or stops the recording of the vhs.
@@ -219,9 +219,9 @@ func ExecuteType(c parser.Command, v *VHS) {
for _, r := range c.Args {
k, ok := keymap[r]
if ok {
- _ = v.Page.Keyboard.Type(k)
+ v.Page.Keyboard.Type(k)
} else {
- _ = v.Page.MustElement("textarea").Input(string(r))
+ v.Page.Keyboard.Input(string(r))
v.Page.MustWaitIdle()
}
time.Sleep(typingSpeed)
@@ -263,9 +263,9 @@ func ExecutePaste(_ parser.Command, v *VHS) {
for _, r := range clip {
k, ok := keymap[r]
if ok {
- _ = v.Page.Keyboard.Type(k)
+ v.Page.Keyboard.Type(k)
} else {
- _ = v.Page.MustElement("textarea").Input(string(r))
+ v.Page.Keyboard.Input(string(r))
v.Page.MustWaitIdle()
}
}
@@ -283,6 +283,7 @@ var Settings = map[string]CommandFunc{
"Padding": ExecuteSetPadding,
"Theme": ExecuteSetTheme,
"TypingSpeed": ExecuteSetTypingSpeed,
+ "KeyStrokes": ExecuteSetKeyStrokes,
"Width": ExecuteSetWidth,
"Shell": ExecuteSetShell,
"LoopOffset": ExecuteLoopOffset,
@@ -369,6 +370,13 @@ func ExecuteSetTheme(c parser.Command, v *VHS) {
_, _ = v.Page.Eval(fmt.Sprintf("() => term.options.theme = %s", string(bts)))
v.Options.Video.Style.BackgroundColor = v.Options.Theme.Background
v.Options.Video.Style.WindowBarColor = v.Options.Theme.Background
+ // The intuitive behavior is to have keystroke overlay inherit from the
+ // foreground color. One key benefit of this behavior is that you won't have
+ // issues where e.g. a light theme makes a default white-value keystroke
+ // overlay be hard to read. If it does, then the theme is likely
+ // fundamentally 'broken' since the text you type at the shell will
+ // similarly be very hard to read.
+ v.Options.Video.KeyStrokeOverlay.Color = v.Options.Theme.Foreground
}
// ExecuteSetTypingSpeed applies the default typing speed on the vhs.
@@ -378,6 +386,19 @@ func ExecuteSetTypingSpeed(c parser.Command, v *VHS) {
return
}
v.Options.TypingSpeed = typingSpeed
+ v.Options.Video.KeyStrokeOverlay.TypingSpeed = typingSpeed
+}
+
+// ExecuteSetKeyStrokes enables or disables keystroke overlay recording.
+func ExecuteSetKeyStrokes(c parser.Command, v *VHS) {
+ switch c.Args {
+ case "Hide":
+ v.Page.KeyStrokeEvents.Disable()
+ case "Show":
+ v.Page.KeyStrokeEvents.Enable()
+ default:
+ return
+ }
}
// ExecuteSetPadding applies the padding on the vhs.
diff --git a/command_test.go b/command_test.go
index c8629cdee..bb52938ef 100644
--- a/command_test.go
+++ b/command_test.go
@@ -17,6 +17,11 @@ func TestCommand(t *testing.T) {
if len(CommandFuncs) != numberOfCommandFuncs {
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
}
+
+ const numberOfSettings = 20
+ if len(Settings) != numberOfSettings {
+ t.Errorf("Expected %d settings, got %d", numberOfSettings, len(Settings))
+ }
}
func TestExecuteSetTheme(t *testing.T) {
diff --git a/evaluator.go b/evaluator.go
index 3e10b5298..2e4532d85 100644
--- a/evaluator.go
+++ b/evaluator.go
@@ -15,6 +15,22 @@ import (
// EvaluatorOption is a function that can be used to modify the VHS instance.
type EvaluatorOption func(*VHS)
+// isIntermediateAllowedSet returns true if the set command is allowed to be
+// evaluated in the middle of the tape.
+// This function assumes that the given option is from a Set command.
+func isIntermediateAllowedSet(opt string) bool {
+ intermediateAllowedSets := []string{
+ "TypingSpeed",
+ "KeyStrokes",
+ }
+ for _, allowed := range intermediateAllowedSets {
+ if opt == allowed {
+ return true
+ }
+ }
+ return false
+}
+
// Evaluate takes as input a tape string, an output writer, and an output file
// and evaluates all the commands within the tape string and produces a GIF.
func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...EvaluatorOption) []error {
@@ -134,7 +150,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator
// GIF as the frame sequence will change dimensions. This is fixable.
//
// We should remove if isSetting statement.
- isSetting := cmd.Type == token.SET && cmd.Options != "TypingSpeed"
+ isSetting := cmd.Type == token.SET && !isIntermediateAllowedSet(cmd.Options)
if isSetting || cmd.Type == token.REQUIRE {
fmt.Fprintln(out, Highlight(cmd, true))
continue
diff --git a/examples/fixtures/all.tape b/examples/fixtures/all.tape
index cfd9e75fc..66adf8b34 100644
--- a/examples/fixtures/all.tape
+++ b/examples/fixtures/all.tape
@@ -19,6 +19,8 @@ Set Padding 50
Set Framerate 60
Set PlaybackSpeed 2
Set TypingSpeed .1
+Set KeyStrokes Hide
+Set KeyStrokes Show
Set LoopOffset 60.4
Set LoopOffset 20.99%
Set CursorBlink false
diff --git a/ffmpeg.go b/ffmpeg.go
index 4d4ae8026..045a9e05b 100644
--- a/ffmpeg.go
+++ b/ffmpeg.go
@@ -172,6 +172,76 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB
return fb
}
+// WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex.
+func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplexBuilder {
+ var (
+ defaultFontFamily = "monospace"
+ horizontalCenter = "(w-text_w)/2"
+ verticalCenter = fmt.Sprintf("h-text_h-%d", opts.Style.Margin+opts.Style.Padding)
+ )
+ events := opts.KeyStrokeOverlay.Events
+
+ // When we are dealing with the last event, things can actually get very
+ // subtly tricky.
+ // If the last keystroke event is _very_ close to the end of the recording
+ // (e.g. the last line of the .tape is Type), then there is a chance that
+ // this keystroke draw event gets effectively dropped. That is because the
+ // gte() clause here may have a timestamp that is exactly equal to or
+ // slightly greater than the actual length of the recording itself. In order
+ // to fix this, we actually want to "pad" the recording a little extra in
+ // this case. While we could simply unconditionally record a few more
+ // seconds at the end of every vhs recording, this is actually not a
+ // generalizable solution for a dynamic typing speed, where we would
+ // proportionally require _more_ extra frames to make sure to not drop any
+ // keystrokes.
+ // Now, the condition in which we need to do this corrective action is if
+ // the last event is recorded after the duration of the recording with a
+ // tolerance of 100 ms:
+ if overflow := events[len(events)-1].WhenMS - opts.KeyStrokeOverlay.Duration.Milliseconds(); overflow > -100 && overflow < 100 {
+ // If so, extend the recording by a window twice the length of the
+ // typing speed. This should give ample time for the last keystroke
+ // event to be properly rendered.
+ fb.filterComplex.WriteString(fmt.Sprintf(";\n[%s]tpad=stop_mode=clone:stop_duration=%f[endpad]\n", fb.prevStageName, opts.KeyStrokeOverlay.TypingSpeed.Seconds()*2))
+ fb.prevStageName = "endpad"
+ }
+
+ prevStageName := fb.prevStageName
+ for i := range events {
+ event := events[i]
+ fb.filterComplex.WriteString(";")
+ stageName := fmt.Sprintf("keystrokeOverlay%d", i)
+
+ // When setting the enable conditions, we have to handle the very last
+ // event specially. It technically has no 'end' so we set it to render
+ // until the end of the video.
+ enableCondition := fmt.Sprintf("gte(t,%f)", float64(event.WhenMS)/1000)
+ if i < len(events)-1 {
+ enableCondition = fmt.Sprintf("between(t,%f,%f)", float64(events[i].WhenMS)/1000, float64(events[i+1].WhenMS)/1000)
+ }
+ fb.filterComplex.WriteString(
+ fmt.Sprintf(`
+ [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x='%s':y='%s':enable='%s'[%s]
+ `,
+ prevStageName,
+ defaultFontFamily,
+ events[i].Display,
+ opts.KeyStrokeOverlay.Color,
+ defaultFontSize,
+ horizontalCenter,
+ verticalCenter,
+ enableCondition,
+ stageName,
+ ),
+ )
+ prevStageName = stageName
+ }
+
+ // At the end of the loop, the previous stage name is now transfered to the filter complex builder's
+ // state for use in subsequent filters.
+ fb.prevStageName = prevStageName
+ return fb
+}
+
// WithGIF adds gif options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithGIF() *FilterComplexBuilder {
fb.filterComplex.WriteString(";")
diff --git a/keys.go b/keys.go
index 384846306..0ecaaf6b6 100644
--- a/keys.go
+++ b/keys.go
@@ -129,3 +129,13 @@ var keymap = map[rune]input.Key{
'→': input.ArrowRight,
'↓': input.ArrowDown,
}
+
+// inverseKeymap is the inverse map of keymap, mapping input.Keys to runes.
+var inverseKeymap = make(map[input.Key]rune)
+
+// init initializes the invKeymap map to ensure it stays in-sync with keymap.
+func init() {
+ for r, k := range keymap {
+ inverseKeymap[k] = r
+ }
+}
diff --git a/keystroke.go b/keystroke.go
new file mode 100644
index 000000000..07a6b4567
--- /dev/null
+++ b/keystroke.go
@@ -0,0 +1,233 @@
+package main
+
+import (
+ "sync"
+ "time"
+
+ "github.com/go-rod/rod"
+ "github.com/go-rod/rod/lib/input"
+)
+
+// KeyStrokeEvent represents a key press event for the purposes of keystroke
+// overlay.
+type KeyStrokeEvent struct {
+ // Display generally includes the current key stroke sequence.
+ Display string
+ // WhenMS is the time in milliseconds when the key was pressed starting
+ // from the beginning of the recording.
+ WhenMS int64
+}
+
+// KeyStrokeEvents is a collection of key press events that you can push to.
+type KeyStrokeEvents struct {
+ enabled bool
+ display string
+ events []KeyStrokeEvent
+ once sync.Once
+ startTime time.Time
+ duration time.Duration
+ maxDisplaySize int
+}
+
+const (
+ // DefaultMaxDisplaySize is the default maximum display size for the
+ // keystroke overlay.
+ DefaultMaxDisplaySize = 20
+)
+
+// NewKeyStrokeEvents creates a new KeyStrokeEvents struct.
+func NewKeyStrokeEvents(maxDisplaySize int) *KeyStrokeEvents {
+ return &KeyStrokeEvents{
+ display: "",
+ events: make([]KeyStrokeEvent, 0),
+ // NOTE: This is actually setting the startTime too early. It
+ // takes a while (in computer time) to get to the point where
+ // we start recording. Therefore, we actually set this another
+ // time on the first push. Without this, the final overlay
+ // would be slightly desynced by a 20-40 ms, which is
+ // noticeable to the human eye.
+ startTime: time.Now(),
+ maxDisplaySize: maxDisplaySize,
+ }
+}
+
+// keystrokeSymbolOverrides maps certain input keys to their corresponding
+// keystroke string or symbol. These override the default rune for the
+// corresponding input key to improve the visuals or readability of the
+// keystroke overlay. A good example of this improvement can be seen in things
+// like Enter (newline). The description string and symbol are embedded into an
+// inner map, which can be indexed into based on whether special symbols are
+// requested or not.
+var keystrokeSymbolOverrides = map[input.Key]string{
+ input.Backspace: "⌫",
+ input.Delete: "⌦",
+ input.ControlLeft: "^",
+ input.ControlRight: "^",
+ input.AltLeft: "⌥",
+ input.AltRight: "⌥",
+ input.ShiftLeft: "⇧",
+ input.ShiftRight: "⇧",
+ input.ArrowDown: "↓",
+ input.PageDown: "⇟",
+ input.ArrowUp: "↑",
+ input.PageUp: "⇞",
+ input.ArrowLeft: "←",
+ input.ArrowRight: "→",
+ input.Space: "␣",
+ input.Enter: "⏎",
+ input.Escape: "↖",
+ input.Tab: "⇥",
+}
+
+func keyToDisplay(key input.Key) string {
+ if symbol, ok := keystrokeSymbolOverrides[key]; ok {
+ return symbol
+ }
+ return string(inverseKeymap[key])
+}
+
+// Enable enables key press event recording.
+func (k *KeyStrokeEvents) Enable() {
+ k.enabled = true
+}
+
+// Disable disables key press event recording.
+func (k *KeyStrokeEvents) Disable() {
+ k.enabled = false
+}
+
+// End signals to the KeyStrokeEvents that the recording has finished.
+// This _seems_ small, but it is crucial to ensure that a final key stroke event
+// is not lost due to the recording finishing 1 frame too early.
+func (k *KeyStrokeEvents) End() {
+ k.duration = time.Now().Sub(k.startTime)
+}
+
+// Push adds a new key press event to the collection.
+func (k *KeyStrokeEvents) Push(display string) {
+ k.once.Do(func() {
+ k.startTime = time.Now()
+ })
+
+ // If we're not enabled, we don't want to do anything.
+ // But note that we still want to update the start time -- this is because
+ // we need to know the global start time if we want to render any subsequent
+ // events correctly, and the keystroke overlay may be re-enabled later in
+ // the recording.
+ if !k.enabled {
+ return
+ }
+
+ k.display += display
+ // Keep k.display @ 20 max.
+ // Anymore than that is probably overkill, and we don't want to run into
+ // issues where the overlay text is longer than the video width itself.
+ if displayRunes := []rune(k.display); len(displayRunes) > k.maxDisplaySize {
+ // We need to be cognizant of unicode -- we can't just slice off a byte,
+ // we have to slice off a _rune_. The conversion back-and-forth may be a
+ // bit inefficient, but k.display will always be tiny thanks to
+ // k.maxDisplaySize.
+ k.display = string(displayRunes[1:])
+ }
+ event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()}
+ k.events = append(k.events, event)
+}
+
+// Page is a wrapper around the rod.Page object.
+// It's primary purpose is to decorate the rod.Page struct such that we can
+// record keystroke events during the recording for keystroke overlays. We
+// prefer decorating so that we that minimize the possibility of future bugs
+// around forgetting to log key presses, since all input is done through
+// rod.Page (and technically rod.Page.MustElement() + rod.Page.Keyboard).
+type Page struct {
+ *rod.Page
+ Keyboard Keyboard
+ KeyStrokeEvents *KeyStrokeEvents
+}
+
+// NewPage creates a new wrapper Page object.
+func NewPage(page *rod.Page) *Page {
+ keyStrokeEvents := NewKeyStrokeEvents(DefaultMaxDisplaySize)
+ return &Page{Page: page, KeyStrokeEvents: keyStrokeEvents, Keyboard: Keyboard{page.Keyboard, page.MustElement("textarea"), keyStrokeEvents}}
+}
+
+// MustSetViewport is a wrapper around the rod.Page#MustSetViewport method.
+func (p *Page) MustSetViewport(width, height int, deviceScaleFactor float64, mobile bool) *Page {
+ p.Page.MustSetViewport(width, height, deviceScaleFactor, mobile)
+ return p
+}
+
+// MustWait is a wrapper around the rod.Page#MustWait method.
+func (p *Page) MustWait(js string) *Page {
+ p.Page.MustWait(js)
+ return p
+}
+
+// KeyActions is a wrapper around the rod.Page#KeyActions method.
+func (p *Page) KeyActions() *KeyActions {
+ return &KeyActions{
+ KeyActions: p.Page.KeyActions(),
+ displays: []string{},
+ KeyStrokeEvents: p.KeyStrokeEvents,
+ }
+}
+
+// Keyboard is a wrapper around the rod.KeyActions method.
+type KeyActions struct {
+ *rod.KeyActions
+ displays []string
+ KeyStrokeEvents *KeyStrokeEvents
+}
+
+// Press is a wrapper around the rod.KeyActions#Press method.
+func (k *KeyActions) Press(key input.Key) *KeyActions {
+ k.displays = append(k.displays, keyToDisplay(key))
+ return &KeyActions{
+ KeyActions: k.KeyActions.Press(key),
+ displays: k.displays,
+ KeyStrokeEvents: k.KeyStrokeEvents,
+ }
+}
+
+// Type is a wrapper around the rod.KeyActions#Type method.
+func (k *KeyActions) Type(key input.Key) *KeyActions {
+ k.displays = append(k.displays, keyToDisplay(key))
+ return &KeyActions{
+ KeyActions: k.KeyActions.Type(key),
+ displays: k.displays,
+ KeyStrokeEvents: k.KeyStrokeEvents,
+ }
+}
+
+// MustDo is a wrapper around the rod.KeyActions#MustDo method.
+func (k *KeyActions) MustDo() {
+ for _, display := range k.displays {
+ k.KeyStrokeEvents.Push(display)
+ }
+ k.KeyActions.MustDo()
+}
+
+// Keyboard is a wrapper around the rod.Keyboard object.
+type Keyboard struct {
+ *rod.Keyboard
+ textAreaElem *rod.Element
+ KeyStrokeEvents *KeyStrokeEvents
+}
+
+// Press is a wrapper around the rod.Keyboard#Press method.
+func (k *Keyboard) Press(key input.Key) {
+ k.KeyStrokeEvents.Push(keyToDisplay(key))
+ k.Keyboard.Press(key)
+}
+
+// Type is a wrapper around the rod.Keyboard#Type method.
+func (k *Keyboard) Type(key input.Key) {
+ k.KeyStrokeEvents.Push(keyToDisplay(key))
+ k.Keyboard.Type(key)
+}
+
+// Input is a wrapper around the rod.Page#MustElement("textarea")#Input method.
+func (k *Keyboard) Input(text string) {
+ k.KeyStrokeEvents.Push(text)
+ k.textAreaElem.Input(text)
+}
diff --git a/keystroke_test.go b/keystroke_test.go
new file mode 100644
index 000000000..1ef3a3b11
--- /dev/null
+++ b/keystroke_test.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/go-rod/rod/lib/input"
+)
+
+// checkKeyStrokeEvents checks if the key stroke events are as expected.
+func checkKeyStrokeEvents(t *testing.T, events *KeyStrokeEvents, expected ...string) {
+ for i, event := range events.events {
+ if actual := event.Display; expected[i] != actual {
+ t.Fatalf("expected event display %q, got %q", expected, actual)
+ }
+ }
+}
+
+func defaultKeyStrokeEvents() *KeyStrokeEvents {
+ events := NewKeyStrokeEvents(DefaultMaxDisplaySize)
+ events.Enable()
+
+ return events
+}
+
+func TestKeyStrokeEventsRemembersKeyStrokes(t *testing.T) {
+ events := defaultKeyStrokeEvents()
+ events.Push("a")
+ events.Push("b")
+ events.Push("c")
+ checkKeyStrokeEvents(t, events, "a", "ab", "abc")
+}
+
+func TestKeyStrokeEventsHonorsMaxDisplaySize(t *testing.T) {
+ events := defaultKeyStrokeEvents()
+ events.maxDisplaySize = 2
+
+ events.Push("a")
+ events.Push("b")
+ events.Push("c")
+
+ // NOTE: It should not be "ab", but "bc" at the end -- we should be acting
+ // like a ring buffer.
+ checkKeyStrokeEvents(t, events, "a", "ab", "bc")
+}
+
+func TestKeyStrokeEventsShowsNothingIfDisabled(t *testing.T) {
+ events := defaultKeyStrokeEvents()
+ events.Disable()
+
+ events.Push("a")
+ events.Push("b")
+ events.Push("c")
+
+ checkKeyStrokeEvents(t, events)
+}
+
+func TestKeyStrokeEventsKeyToDisplay(t *testing.T) {
+ cases := []struct {
+ name string
+ key input.Key
+ expected string
+ }{
+ {
+ name: "letter",
+ key: input.KeyA,
+ expected: "a",
+ },
+ {
+ name: "number",
+ key: input.Digit1,
+ expected: "1",
+ },
+ {
+ name: "symbol no override",
+ key: input.Minus,
+ expected: "-",
+ },
+ {
+ name: "shifted key",
+ key: shift(input.Minus),
+ expected: "_",
+ },
+ {
+ name: "symbol override",
+ key: input.Backspace,
+ expected: "⌫",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ if actual := keyToDisplay(tc.key); tc.expected != actual {
+ t.Fatalf("expected display %q, got %q", tc.expected, actual)
+ }
+ })
+ }
+}
diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go
index 1383dfe77..2579d49bb 100644
--- a/lexer/lexer_test.go
+++ b/lexer/lexer_test.go
@@ -147,6 +147,12 @@ func TestLexTapeFile(t *testing.T) {
{token.TYPING_SPEED, "TypingSpeed"},
{token.NUMBER, ".1"},
{token.SET, "Set"},
+ {token.KEYSTROKES, "KeyStrokes"},
+ {token.HIDE, "Hide"},
+ {token.SET, "Set"},
+ {token.KEYSTROKES, "KeyStrokes"},
+ {token.SHOW, "Show"},
+ {token.SET, "Set"},
{token.LOOP_OFFSET, "LoopOffset"},
{token.NUMBER, "60.4"},
{token.SET, "Set"},
diff --git a/parser/parser.go b/parser/parser.go
index 3ee8f2c9a..646c4dddc 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -414,6 +414,14 @@ func (p *Parser) parseSet() Command {
} else if cmd.Options == "TypingSpeed" {
cmd.Args += "s"
}
+ case token.KEYSTROKES:
+ switch p.peek.Type {
+ case token.HIDE, token.SHOW:
+ cmd.Args = p.peek.Literal
+ p.nextToken()
+ default:
+ p.errors = append(p.errors, NewError(p.peek, "Expected Hide or Show after KeyStrokes"))
+ }
case token.WINDOW_BAR:
cmd.Args = p.peek.Literal
p.nextToken()
diff --git a/parser/parser_test.go b/parser/parser_test.go
index c82c7f652..e62f5f7b4 100644
--- a/parser/parser_test.go
+++ b/parser/parser_test.go
@@ -78,7 +78,8 @@ func TestParserErrors(t *testing.T) {
Type Enter
Type "echo 'Hello, World!'" Enter
Foo
-Sleep Bar`
+Sleep Bar
+Set KeyStrokes imsowrong`
l := lexer.New(input)
p := New(l)
@@ -90,15 +91,22 @@ Sleep Bar`
" 4:1 │ Invalid command: Foo",
" 5:1 │ Expected time after Sleep",
" 5:7 │ Invalid command: Bar",
+ " 6:16 │ Expected Hide or Show after KeyStrokes",
+ " 6:16 │ Invalid command: imsowrong",
+ }
+
+ actualErrorMessages := make([]string, len(p.errors))
+ for i, err := range p.errors {
+ actualErrorMessages[i] = err.String()
}
if len(p.errors) != len(expectedErrors) {
- t.Fatalf("Expected %d errors, got %d", len(expectedErrors), len(p.errors))
+ t.Fatalf("Expected %d errors, got %d (expected:\n%s\ngot:\n%s)", len(expectedErrors), len(p.errors), strings.Join(expectedErrors, "\n"), strings.Join(actualErrorMessages, "\n"))
}
- for i, err := range p.errors {
- if err.String() != expectedErrors[i] {
- t.Errorf("Expected error %d to be [%s], got (%s)", i, expectedErrors[i], err)
+ for i, errMsg := range actualErrorMessages {
+ if errMsg != expectedErrors[i] {
+ t.Errorf("Expected error %d to be [%s], got (%s)", i, expectedErrors[i], errMsg)
}
}
}
@@ -126,6 +134,8 @@ func TestParseTapeFile(t *testing.T) {
{Type: token.SET, Options: "Framerate", Args: "60"},
{Type: token.SET, Options: "PlaybackSpeed", Args: "2"},
{Type: token.SET, Options: "TypingSpeed", Args: ".1s"},
+ {Type: token.SET, Options: "KeyStrokes", Args: "Hide"},
+ {Type: token.SET, Options: "KeyStrokes", Args: "Show"},
{Type: token.SET, Options: "LoopOffset", Args: "60.4%"},
{Type: token.SET, Options: "LoopOffset", Args: "20.99%"},
{Type: token.SET, Options: "CursorBlink", Args: "false"},
diff --git a/token/token.go b/token/token.go
index a9cdd3d30..c2d9579a9 100644
--- a/token/token.go
+++ b/token/token.go
@@ -82,6 +82,7 @@ const (
LETTER_SPACING = "LETTER_SPACING" //nolint:revive
LINE_HEIGHT = "LINE_HEIGHT" //nolint:revive
TYPING_SPEED = "TYPING_SPEED" //nolint:revive
+ KEYSTROKES = "KEYSTROKES"
PADDING = "PADDING"
THEME = "THEME"
LOOP_OFFSET = "LOOP_OFFSET" //nolint:revive
@@ -138,6 +139,7 @@ var Keywords = map[string]Type{
"LineHeight": LINE_HEIGHT,
"PlaybackSpeed": PLAYBACK_SPEED,
"TypingSpeed": TYPING_SPEED,
+ "KeyStrokes": KEYSTROKES,
"Padding": PADDING,
"Theme": THEME,
"Width": WIDTH,
@@ -156,7 +158,7 @@ var Keywords = map[string]Type{
func IsSetting(t Type) bool {
switch t {
case SHELL, FONT_FAMILY, FONT_SIZE, LETTER_SPACING, LINE_HEIGHT,
- FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH,
+ FRAMERATE, TYPING_SPEED, KEYSTROKES, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH,
PADDING, LOOP_OFFSET, MARGIN_FILL, MARGIN, WINDOW_BAR,
WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK:
return true
diff --git a/vhs.go b/vhs.go
index 6660896fd..862f7bee2 100644
--- a/vhs.go
+++ b/vhs.go
@@ -22,7 +22,7 @@ import (
type VHS struct {
Options *Options
Errors []error
- Page *rod.Page
+ Page *Page
browser *rod.Browser
TextCanvas *rod.Element
CursorCanvas *rod.Element
@@ -141,7 +141,7 @@ func (vhs *VHS) Start() error {
}
vhs.browser = browser
- vhs.Page = page
+ vhs.Page = NewPage(page)
vhs.close = vhs.browser.Close
vhs.started = true
return nil
@@ -190,6 +190,8 @@ const cleanupWaitTime = 100 * time.Millisecond
// Terminate cleans up a VHS instance and terminates the go-rod browser and ttyd
// processes.
func (vhs *VHS) terminate() error {
+ // Signal the end of all keystroke events.
+ vhs.Page.KeyStrokeEvents.End()
// Give some time for any commands executed (such as `rm`) to finish.
//
// If a user runs a long running command, they must sleep for the required time
@@ -217,6 +219,9 @@ func (vhs *VHS) Render() error {
return err
}
+ vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.events
+ vhs.Options.Video.KeyStrokeOverlay.Duration = vhs.Page.KeyStrokeEvents.duration
+
// Generate the video(s) with the frames.
var cmds []*exec.Cmd
cmds = append(cmds, MakeGIF(vhs.Options.Video))
@@ -317,7 +322,8 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error {
go func() {
counter := 0
- start := time.Now()
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
for {
select {
case <-ctx.Done():
@@ -330,10 +336,7 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error {
close(ch)
return
- case <-time.After(interval - time.Since(start)):
- // record last attempt
- start = time.Now()
-
+ case <-ticker.C:
if !vhs.recording {
continue
}
diff --git a/video.go b/video.go
index 1cbf77f55..b509db79b 100644
--- a/video.go
+++ b/video.go
@@ -15,6 +15,7 @@ import (
"os/exec"
"path/filepath"
"strings"
+ "time"
)
const (
@@ -47,15 +48,24 @@ type VideoOutputs struct {
Frames string
}
+// KeyStrokeOptions is the set of options for rendering keystroke overlay.
+type KeyStrokeOptions struct {
+ Events []KeyStrokeEvent
+ Color string
+ TypingSpeed time.Duration
+ Duration time.Duration
+}
+
// VideoOptions is the set of options for converting frames to a GIF.
type VideoOptions struct {
- Framerate int
- PlaybackSpeed float64
- Input string
- MaxColors int
- Output VideoOutputs
- StartingFrame int
- Style *StyleOptions
+ Framerate int
+ PlaybackSpeed float64
+ Input string
+ MaxColors int
+ Output VideoOutputs
+ StartingFrame int
+ Style *StyleOptions
+ KeyStrokeOverlay KeyStrokeOptions
}
const (
@@ -73,6 +83,11 @@ func DefaultVideoOptions() VideoOptions {
Output: VideoOutputs{GIF: "", WebM: "", MP4: "", Frames: ""},
PlaybackSpeed: defaultPlaybackSpeed,
StartingFrame: defaultStartingFrame,
+ KeyStrokeOverlay: KeyStrokeOptions{
+ Events: []KeyStrokeEvent{},
+ Color: DefaultTheme.Foreground,
+ TypingSpeed: defaultTypingSpeed,
+ },
}
}
@@ -119,6 +134,10 @@ func buildFFopts(opts VideoOptions, targetFile string) []string {
WithBorderRadius(streamBuilder.cornerStream).
WithMarginFill(streamBuilder.marginStream)
+ if len(opts.KeyStrokeOverlay.Events) > 0 {
+ filterBuilder = filterBuilder.WithKeyStrokes(opts)
+ }
+
// Format-specific options
switch filepath.Ext(targetFile) {
case gif: