package main import ( "encoding/json" "fmt" "os" "os/exec" "regexp" "strconv" "strings" "time" "github.com/atotto/clipboard" "github.com/charmbracelet/vhs/parser" "github.com/charmbracelet/vhs/token" "github.com/go-rod/rod/lib/input" ) // Execute executes a command on a running instance of vhs. func Execute(c parser.Command, v *VHS) error { err := CommandFuncs[c.Type](c, v) if err != nil { return fmt.Errorf("failed to execute command: %w", err) } if v.recording && v.Options.Test.Output != "" { err := v.SaveOutput() if err != nil { return fmt.Errorf("failed to save output: %w", err) } } return nil } // CommandFunc is a function that executes a command on a running // instance of vhs. type CommandFunc func(c parser.Command, v *VHS) error // CommandFuncs maps command types to their executable functions. var CommandFuncs = map[parser.CommandType]CommandFunc{ token.BACKSPACE: ExecuteKey(input.Backspace), token.DELETE: ExecuteKey(input.Delete), token.INSERT: ExecuteKey(input.Insert), token.DOWN: ExecuteKey(input.ArrowDown), token.ENTER: ExecuteKey(input.Enter), token.LEFT: ExecuteKey(input.ArrowLeft), token.RIGHT: ExecuteKey(input.ArrowRight), token.SPACE: ExecuteKey(input.Space), token.UP: ExecuteKey(input.ArrowUp), token.TAB: ExecuteKey(input.Tab), token.ESCAPE: ExecuteKey(input.Escape), token.PAGE_UP: ExecuteKey(input.PageUp), token.PAGE_DOWN: ExecuteKey(input.PageDown), token.SCROLL_UP: ExecuteScroll(-1), token.SCROLL_DOWN: ExecuteScroll(1), token.HIDE: ExecuteHide, token.REQUIRE: ExecuteRequire, token.SHOW: ExecuteShow, token.SET: ExecuteSet, token.OUTPUT: ExecuteOutput, token.SLEEP: ExecuteSleep, token.TYPE: ExecuteType, token.CTRL: ExecuteCtrl, token.ALT: ExecuteAlt, token.SHIFT: ExecuteShift, token.ILLEGAL: ExecuteNoop, token.SCREENSHOT: ExecuteScreenshot, token.COPY: ExecuteCopy, token.PASTE: ExecutePaste, token.ENV: ExecuteEnv, token.WAIT: ExecuteWait, } // ExecuteNoop is a no-op command that does nothing. // Generally, this is used for Unknown commands when dealing with // commands that are not recognized. func ExecuteNoop(_ parser.Command, _ *VHS) error { return nil } // ExecuteKey is a higher-order function that returns a CommandFunc to execute // a key press for a given key. This is so that the logic for key pressing // (since they are repeatable and delayable) can be re-used. // // i.e. ExecuteKey(input.ArrowDown) would return a CommandFunc that executes // the ArrowDown key press. func ExecuteKey(k input.Key) CommandFunc { return func(c parser.Command, v *VHS) error { typingSpeed, err := time.ParseDuration(c.Options) if err != nil { typingSpeed = v.Options.TypingSpeed } repeat, err := strconv.Atoi(c.Args) if err != nil { repeat = 1 } for i := 0; i < repeat; i++ { err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", k, err) } time.Sleep(typingSpeed) } return nil } } // ExecuteScroll returns a command function that scrolls xterm's viewport. // // The direction argument is expected to be: // // -1 for ScrollUp // +1 for ScrollDown // // Each command repeat applies one viewport row movement with the same timing // semantics as other repeatable key-like commands (Options is the per-step // delay, Args is repeat count). // // A caller that wires an unexpected direction (for example 10) will still call // xterm's scroll API with that value and produce larger jumps per step. func ExecuteScroll(direction int) CommandFunc { return func(c parser.Command, v *VHS) error { typingSpeed, err := time.ParseDuration(c.Options) if err != nil { typingSpeed = v.Options.TypingSpeed } repeat, err := strconv.Atoi(c.Args) if err != nil { repeat = 1 } for i := 0; i < repeat; i++ { // ScrollUp/ScrollDown are viewport operations implemented directly via // xterm's scroll API. _, err = v.Page.Eval(fmt.Sprintf("() => term.scrollLines(%d)", direction)) if err != nil { return fmt.Errorf("failed to scroll viewport: %w", err) } time.Sleep(typingSpeed) } return nil } } // WaitTick is the amount of time to wait between checking for a match. const WaitTick = 10 * time.Millisecond // ExecuteWait is a CommandFunc that waits for a regex match for the given amount of time. func ExecuteWait(c parser.Command, v *VHS) error { scope, rxStr, ok := strings.Cut(c.Args, " ") rx := v.Options.WaitPattern if ok { // This is validated on parse so using MustCompile reduces noise. rx = regexp.MustCompile(rxStr) } timeout := v.Options.WaitTimeout if c.Options != "" { t, err := time.ParseDuration(c.Options) if err != nil { // Shouldn't be possible due to parse validation. return fmt.Errorf("failed to parse duration: %w", err) } timeout = t } checkT := time.NewTicker(WaitTick) defer checkT.Stop() timeoutT := time.NewTimer(timeout) defer timeoutT.Stop() for { var last string switch scope { case "Line": line, err := v.CurrentLine() if err != nil { return fmt.Errorf("failed to get current line: %w", err) } last = line if rx.MatchString(line) { return nil } case "Screen": lines, err := v.Buffer() if err != nil { return fmt.Errorf("failed to get buffer: %w", err) } last = strings.Join(lines, "\n") if rx.MatchString(last) { return nil } default: // Should be impossible due to parse validation, but we don't want to // hang if it does happen due to a bug. return fmt.Errorf("invalid scope %q", scope) } select { case <-checkT.C: continue case <-timeoutT.C: return fmt.Errorf("timeout waiting for %q to match %s; last value was: %s", c.Args, rx.String(), last) } } } // ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers // with the ctrl key held down on the running instance of vhs. func ExecuteCtrl(c parser.Command, v *VHS) error { // Create key combination by holding ControlLeft action := v.Page.KeyActions().Press(input.ControlLeft) keys := strings.Split(c.Args, " ") for i, key := range keys { var inputKey *input.Key switch key { case "Shift": inputKey = &input.ShiftLeft case "Alt": inputKey = &input.AltLeft case "Enter": inputKey = &input.Enter case "Space": inputKey = &input.Space case "Backspace": inputKey = &input.Backspace case "Left": inputKey = &input.ArrowLeft case "Right": inputKey = &input.ArrowRight case "Up": inputKey = &input.ArrowUp case "Down": inputKey = &input.ArrowDown default: r := rune(key[0]) if k, ok := keymap[r]; ok { inputKey = &k } } // Press or hold key in case it's valid if inputKey != nil { if i != len(keys)-1 { action.Press(*inputKey) } else { // Other keys will remain pressed until the combination reaches the end action.Type(*inputKey) } } } err := action.Do() if err != nil { return fmt.Errorf("failed to type key %s: %w", c.Args, err) } return nil } // 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) error { err := v.Page.Keyboard.Press(input.AltLeft) if err != nil { return fmt.Errorf("failed to press Alt key: %w", err) } if k, ok := token.Keywords[c.Args]; ok { //nolint:nestif switch k { case token.ENTER: err = v.Page.Keyboard.Type(input.Enter) if err != nil { return fmt.Errorf("failed to type Enter key: %w", err) } case token.TAB: err := v.Page.Keyboard.Type(input.Tab) if err != nil { return fmt.Errorf("failed to type Tab key: %w", err) } } } else { for _, r := range c.Args { if k, ok := keymap[r]; ok { err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", r, err) } } } } err = v.Page.Keyboard.Release(input.AltLeft) if err != nil { return fmt.Errorf("failed to release Alt key: %w", err) } return nil } // 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) error { err := v.Page.Keyboard.Press(input.ShiftLeft) if err != nil { return fmt.Errorf("failed to press Shift key: %w", err) } if k, ok := token.Keywords[c.Args]; ok { //nolint:nestif switch k { case token.ENTER: err = v.Page.Keyboard.Type(input.Enter) if err != nil { return fmt.Errorf("failed to type Enter key: %w", err) } case token.TAB: err = v.Page.Keyboard.Type(input.Tab) if err != nil { return fmt.Errorf("failed to type Tab key: %w", err) } } } else { for _, r := range c.Args { if k, ok := keymap[r]; ok { err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", r, err) } } } } err = v.Page.Keyboard.Release(input.ShiftLeft) if err != nil { return fmt.Errorf("failed to release Shift key: %w", err) } return nil } // ExecuteHide is a CommandFunc that starts or stops the recording of the vhs. func ExecuteHide(_ parser.Command, v *VHS) error { v.PauseRecording() return nil } // ExecuteRequire is a CommandFunc that checks if all the binaries mentioned in the // Require command are present. If not, it exits with a non-zero error. func ExecuteRequire(c parser.Command, _ *VHS) error { _, err := exec.LookPath(c.Args) return err //nolint:wrapcheck } // ExecuteShow is a CommandFunc that resumes the recording of the vhs. func ExecuteShow(_ parser.Command, v *VHS) error { v.ResumeRecording() return nil } // ExecuteSleep sleeps for the desired time specified through the argument of // the Sleep command. func ExecuteSleep(c parser.Command, _ *VHS) error { dur, err := time.ParseDuration(c.Args) if err != nil { return fmt.Errorf("failed to parse duration: %w", err) } time.Sleep(dur) return nil } // ExecuteType types the argument string on the running instance of vhs. func ExecuteType(c parser.Command, v *VHS) error { typingSpeed := v.Options.TypingSpeed if c.Options != "" { var err error typingSpeed, err = time.ParseDuration(c.Options) if err != nil { return fmt.Errorf("failed to parse typing speed: %w", err) } } for _, r := range c.Args { k, ok := keymap[r] if ok { err := v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", r, err) } } else { err := v.Page.MustElement("textarea").Input(string(r)) if err != nil { return fmt.Errorf("failed to input text: %w", err) } v.Page.MustWaitIdle() } time.Sleep(typingSpeed) } return nil } // ExecuteOutput applies the output on the vhs videos. func ExecuteOutput(c parser.Command, v *VHS) error { switch c.Options { case ".mp4": v.Options.Video.Output.MP4 = c.Args case ".test", ".ascii", ".txt": v.Options.Test.Output = c.Args case ".png": v.Options.Video.Output.Frames = c.Args case ".webm": v.Options.Video.Output.WebM = c.Args default: v.Options.Video.Output.GIF = c.Args } return nil } // ExecuteCopy copies text to the clipboard. func ExecuteCopy(c parser.Command, _ *VHS) error { return clipboard.WriteAll(c.Args) //nolint:wrapcheck } // ExecuteEnv sets env with given key-value pair. func ExecuteEnv(c parser.Command, _ *VHS) error { return os.Setenv(c.Options, c.Args) //nolint:wrapcheck } // ExecutePaste pastes text from the clipboard. func ExecutePaste(_ parser.Command, v *VHS) error { clip, err := clipboard.ReadAll() if err != nil { return fmt.Errorf("failed to read clipboard: %w", err) } for _, r := range clip { k, ok := keymap[r] if ok { err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", r, err) } } else { err = v.Page.MustElement("textarea").Input(string(r)) if err != nil { return fmt.Errorf("failed to input text: %w", err) } v.Page.MustWaitIdle() } } return nil } // Settings maps the Set commands to their respective functions. var Settings = map[string]CommandFunc{ "FontFamily": ExecuteSetFontFamily, "FontSize": ExecuteSetFontSize, "Framerate": ExecuteSetFramerate, "Height": ExecuteSetHeight, "LetterSpacing": ExecuteSetLetterSpacing, "LineHeight": ExecuteSetLineHeight, "PlaybackSpeed": ExecuteSetPlaybackSpeed, "Padding": ExecuteSetPadding, "Theme": ExecuteSetTheme, "TypingSpeed": ExecuteSetTypingSpeed, "Width": ExecuteSetWidth, "Shell": ExecuteSetShell, "LoopOffset": ExecuteLoopOffset, "MarginFill": ExecuteSetMarginFill, "Margin": ExecuteSetMargin, "WindowBar": ExecuteSetWindowBar, "WindowBarSize": ExecuteSetWindowBarSize, "BorderRadius": ExecuteSetBorderRadius, "WaitPattern": ExecuteSetWaitPattern, "WaitTimeout": ExecuteSetWaitTimeout, "CursorBlink": ExecuteSetCursorBlink, } // ExecuteSet applies the settings on the running vhs specified by the // option and argument pass to the command. func ExecuteSet(c parser.Command, v *VHS) error { return Settings[c.Options](c, v) } // ExecuteSetFontSize applies the font size on the vhs. func ExecuteSetFontSize(c parser.Command, v *VHS) error { fontSize, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse font size: %w", err) } v.Options.FontSize = fontSize _, err = v.Page.Eval(fmt.Sprintf("() => term.options.fontSize = %d", fontSize)) if err != nil { return fmt.Errorf("failed to set font size: %w", err) } // When changing the font size only the canvas dimensions change which are // scaled back during the render to fit the aspect ration and dimensions. // // We need to call term.fit to ensure that everything is resized properly. _, err = v.Page.Eval("term.fit") if err != nil { return fmt.Errorf("failed to fit terminal: %w", err) } return nil } // ExecuteSetFontFamily applies the font family on the vhs. func ExecuteSetFontFamily(c parser.Command, v *VHS) error { v.Options.FontFamily = c.Args _, err := v.Page.Eval(fmt.Sprintf("() => term.options.fontFamily = '%s'", withSymbolsFallback(c.Args))) if err != nil { return fmt.Errorf("failed to set font family: %w", err) } return nil } // ExecuteSetHeight applies the height on the vhs. func ExecuteSetHeight(c parser.Command, v *VHS) error { height, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse height: %w", err) } v.Options.Video.Style.Height = height return nil } // ExecuteSetWidth applies the width on the vhs. func ExecuteSetWidth(c parser.Command, v *VHS) error { width, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse width: %w", err) } v.Options.Video.Style.Width = width return nil } // ExecuteSetShell applies the shell on the vhs. func ExecuteSetShell(c parser.Command, v *VHS) error { s, ok := Shells[c.Args] if !ok { return fmt.Errorf("invalid shell %s", c.Args) } v.Options.Shell = s return nil } const ( bitSize = 64 base = 10 ) // ExecuteSetLetterSpacing applies letter spacing (also known as tracking) on // the vhs. func ExecuteSetLetterSpacing(c parser.Command, v *VHS) error { letterSpacing, err := strconv.ParseFloat(c.Args, bitSize) if err != nil { return fmt.Errorf("failed to parse letter spacing: %w", err) } v.Options.LetterSpacing = letterSpacing _, err = v.Page.Eval(fmt.Sprintf("() => term.options.letterSpacing = %f", letterSpacing)) if err != nil { return fmt.Errorf("failed to set letter spacing: %w", err) } return nil } // ExecuteSetLineHeight applies the line height on the vhs. func ExecuteSetLineHeight(c parser.Command, v *VHS) error { lineHeight, err := strconv.ParseFloat(c.Args, bitSize) if err != nil { return fmt.Errorf("failed to parse line height: %w", err) } v.Options.LineHeight = lineHeight _, err = v.Page.Eval(fmt.Sprintf("() => term.options.lineHeight = %f", lineHeight)) if err != nil { return fmt.Errorf("failed to set line height: %w", err) } return nil } // ExecuteSetTheme applies the theme on the vhs. func ExecuteSetTheme(c parser.Command, v *VHS) error { var err error v.Options.Theme, err = getTheme(c.Args) if err != nil { return err } bts, err := json.Marshal(v.Options.Theme) if err != nil { return fmt.Errorf("failed to marshal theme: %w", err) } _, err = v.Page.Eval(fmt.Sprintf("() => term.options.theme = %s", string(bts))) if err != nil { return fmt.Errorf("failed to set theme: %w", err) } v.Options.Video.Style.BackgroundColor = v.Options.Theme.Background v.Options.Video.Style.WindowBarColor = v.Options.Theme.Background return nil } // ExecuteSetTypingSpeed applies the default typing speed on the vhs. func ExecuteSetTypingSpeed(c parser.Command, v *VHS) error { typingSpeed, err := time.ParseDuration(c.Args) if err != nil { return fmt.Errorf("failed to parse typing speed: %w", err) } v.Options.TypingSpeed = typingSpeed return nil } // ExecuteSetWaitTimeout applies the default wait timeout on the vhs. func ExecuteSetWaitTimeout(c parser.Command, v *VHS) error { waitTimeout, err := time.ParseDuration(c.Args) if err != nil { return fmt.Errorf("failed to parse wait timeout: %w", err) } v.Options.WaitTimeout = waitTimeout return nil } // ExecuteSetWaitPattern applies the default wait pattern on the vhs. func ExecuteSetWaitPattern(c parser.Command, v *VHS) error { rx, err := regexp.Compile(c.Args) if err != nil { return fmt.Errorf("failed to compile regexp: %w", err) } v.Options.WaitPattern = rx return nil } // ExecuteSetPadding applies the padding on the vhs. func ExecuteSetPadding(c parser.Command, v *VHS) error { padding, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse padding: %w", err) } v.Options.Video.Style.Padding = padding return nil } // ExecuteSetFramerate applies the framerate on the vhs. func ExecuteSetFramerate(c parser.Command, v *VHS) error { framerate, err := strconv.ParseInt(c.Args, base, 0) if err != nil { return fmt.Errorf("failed to parse framerate: %w", err) } v.Options.Video.Framerate = int(framerate) return nil } // ExecuteSetPlaybackSpeed applies the playback speed option on the vhs. func ExecuteSetPlaybackSpeed(c parser.Command, v *VHS) error { playbackSpeed, err := strconv.ParseFloat(c.Args, bitSize) if err != nil { return fmt.Errorf("failed to parse playback speed: %w", err) } v.Options.Video.PlaybackSpeed = playbackSpeed return nil } // ExecuteLoopOffset applies the loop offset option on the vhs. func ExecuteLoopOffset(c parser.Command, v *VHS) error { loopOffset, err := strconv.ParseFloat(strings.TrimRight(c.Args, "%"), bitSize) if err != nil { return fmt.Errorf("failed to parse loop offset: %w", err) } v.Options.LoopOffset = loopOffset return nil } // ExecuteSetMarginFill sets vhs margin fill. func ExecuteSetMarginFill(c parser.Command, v *VHS) error { v.Options.Video.Style.MarginFill = c.Args return nil } // ExecuteSetMargin sets vhs margin size. func ExecuteSetMargin(c parser.Command, v *VHS) error { margin, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse margin: %w", err) } v.Options.Video.Style.Margin = margin return nil } // ExecuteSetWindowBar sets window bar type. func ExecuteSetWindowBar(c parser.Command, v *VHS) error { v.Options.Video.Style.WindowBar = c.Args return nil } // ExecuteSetWindowBarSize sets window bar size. func ExecuteSetWindowBarSize(c parser.Command, v *VHS) error { windowBarSize, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse window bar size: %w", err) } v.Options.Video.Style.WindowBarSize = windowBarSize return nil } // ExecuteSetBorderRadius sets corner radius. func ExecuteSetBorderRadius(c parser.Command, v *VHS) error { borderRadius, err := strconv.Atoi(c.Args) if err != nil { return fmt.Errorf("failed to parse border radius: %w", err) } v.Options.Video.Style.BorderRadius = borderRadius return nil } // ExecuteSetCursorBlink sets cursor blinking. func ExecuteSetCursorBlink(c parser.Command, v *VHS) error { var err error v.Options.CursorBlink, err = strconv.ParseBool(c.Args) if err != nil { return fmt.Errorf("failed to parse cursor blink: %w", err) } return nil } // ExecuteScreenshot is a CommandFunc that indicates a new screenshot must be taken. func ExecuteScreenshot(c parser.Command, v *VHS) error { v.ScreenshotNextFrame(c.Args) return nil } func getTheme(s string) (Theme, error) { if strings.TrimSpace(s) == "" { return DefaultTheme, nil } switch s[0] { case '{': return getJSONTheme(s) default: return findTheme(s) } } func getJSONTheme(s string) (Theme, error) { var t Theme if err := json.Unmarshal([]byte(s), &t); err != nil { return DefaultTheme, fmt.Errorf("invalid `Set Theme %q: %w`", s, err) } return t, nil }