Skip to content

Commit

Permalink
Add automated demo recordings (#2853)
Browse files Browse the repository at this point in the history
  • Loading branch information
jesseduffield committed Jul 31, 2023
2 parents 07d03df + 9cc1d65 commit b92c294
Show file tree
Hide file tree
Showing 32 changed files with 889 additions and 10 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ test/results/**
oryxBuildBinary
__debug_bin

.worktrees
.worktrees
demo/output/*
2 changes: 2 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This directory contains stuff for recording lazygit demos.

109 changes: 109 additions & 0 deletions demo/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Specify a command to be executed
# like `/bin/bash -l`, `ls`, or any other commands
# the default is bash for Linux
# or powershell.exe for Windows
command: echo "YOU NEED TO SPECIFY YOUR OWN COMMAND WITH THE -d ARG"

# Specify the current working directory path
# the default is the current working directory path
cwd: null

# Export additional ENV variables
env:
recording: true

# Explicitly set the number of columns
# or use `auto` to take the current
# number of columns of your shell
cols: 120 # 100

# Explicitly set the number of rows
# or use `auto` to take the current
# number of rows of your shell
rows: 35 # 30

# Amount of times to repeat GIF
# If value is -1, play once
# If value is 0, loop indefinitely
# If value is a positive number, loop n times
repeat: 0

# Quality
# 1 - 100
# Higher quality seems to make no difference, but running it through
# gifsicle ends up with a much better compressed version.
quality: 100

# Delay between frames in ms
# If the value is `auto` use the actual recording delays
frameDelay: auto

# Maximum delay between frames in ms
# Ignored if the `frameDelay` isn't set to `auto`
# Set to `auto` to prevent limiting the max idle time
maxIdleTime: 2000

# The surrounding frame box
# The `type` can be null, window, floating, or solid`
# To hide the title use the value null
# Don't forget to add a backgroundColor style with a null as type
frameBox:
type: floating
title: Lazygit
style:
border: 0px black solid
backgroundColor: "#1d1d1d"
margin: -5px

# Add a watermark image to the rendered gif
# You need to specify an absolute path for
# the image on your machine or a URL, and you can also
# add your own CSS styles
watermark:
imagePath: null
style:
position: absolute
right: 15px
bottom: 15px
width: 100px
opacity: 0.9

# Cursor style can be one of
# `block`, `underline`, or `bar`
cursorStyle: block

# Font family
# You can use any font that is installed on your machine
# in CSS-like syntax
fontFamily: "DejaVuSansMono Nerd Font"

# The size of the font
fontSize: 8

# The height of lines
lineHeight: 1

# The spacing between letters
letterSpacing: 0

# Theme
theme:
background: "transparent"
foreground: "#dddad6"
cursor: "#c7c7c7"
black: "#7a7a7a"
red: "#fc4384"
green: "#b3e33b"
yellow: "#ffa727"
blue: "#102895"
magenta: "#c930c7"
cyan: "#00c5c7"
white: "#c7c7c7"
brightBlack: "#676767"
brightRed: "#ff7fac"
brightGreen: "#c8ed71"
brightYellow: "#ebdf86"
brightBlue: "#6871ff"
brightMagenta: "#ff76ff"
brightCyan: "#5ffdff"
brightWhite: "#fffefe"
38 changes: 38 additions & 0 deletions demo/record_demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/sh

TEST=$1

set -e

if [ -z "$TEST" ]
then
echo "Usage: $0 <test>"
exit 1
fi

if ! command -v terminalizer &> /dev/null
then
echo "terminalizer could not be found"
echo "Install it with: npm install -g terminalizer"
exit 1
fi

if ! command -v "gifsicle" &> /dev/null
then
echo "gifsicle could not be found"
echo "Install it with: npm install -g gifsicle"
exit 1
fi

# get last part of the test path and set that as the output name
# example test path: pkg/integration/tests/01_basic_test.go
# For that we want: NAME=01_basic_test
NAME=$(echo "$TEST" | sed -e 's/.*\///' | sed -e 's/\..*//')

go generate pkg/integration/tests/tests.go

terminalizer -c demo/config.yml record --skip-sharing -d "go run cmd/integration_test/main.go cli --slow $TEST" "demo/output/$NAME"
terminalizer render "demo/output/$NAME" -o "demo/output/$NAME.gif"
gifsicle --colors 256 --use-col=web -O3 < "demo/output/$NAME.gif" > "demo/output/$NAME-compressed.gif"

echo "Demo recorded to demo/$NAME-compressed.gif"
52 changes: 52 additions & 0 deletions docs/dev/Demo_Recordings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Demo Recordings

We want our demo recordings to be consistent and easy to update if we make changes to Lazygit's UI. Luckily for us, we have an existing recording system for the sake of our integration tests, so we can piggyback on that.

You'll want to familiarise yourself with how integration tests are written: see [here](../../pkg/integration/README.md).

## Prerequisites

Ideally we'd run this whole thing through docker but we haven't got that working. So you will need:
```
# for recording
npm i -g terminalizer
# for gif compression
npm i -g gifsicle
# font with icons
wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.tar.xz && \
tar -xf DejaVuSansMono.tar.xz -C /usr/local/share/fonts && \
rm DejaVuSansMono.tar.xz
```

## Creating a demo

Demos are found in `pkg/integration/tests/demo/`. They are like regular integration tests but have `IsDemo: true` which has a few effects:
* The bottom row of the UI is quieter so that we can render captions
* Fetch/Push/Pull have artificial latency to mimic a network request
* The loader at the bottom-right does not appear

In demos, we don't need to be as strict in our assertions as we are in tests. But it's still good to have some basic assertions so that if we automate the process of updating demos we'll know if one of them has broken.

You can use the same flow as we use with integration tests when you're writing a demo:
* Setup the repo
* Run the demo in sandbox mode to get a feel of what needs to happen
* Come back and write the code to make it happen

### Adding captions

It's good to add captions explaining what task if being performed. Use the existing demos as a guide.

### Recording the demo

Once you're happy with your demo you can record it using:
```sh
scripts/record_demo.sh <path>
# e.g.
scripts/record_demo.sh pkg/integration/tests/demo/interactive_rebase.go
```

### Storing demos

This part is subject to change. I'm thinking of storing all gifs in the `assets` branch. But yet to finalize on that.
For now, feel free to upload `demo/demo-compressed.gif` to GitHub by dragging and dropping it in a file in the browser (e.g. the README).
1 change: 1 addition & 0 deletions docs/dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

* [Busy/Idle tracking](./Busy.md).
* [Integration Tests](../../pkg/integration/README.md)
* [Demo Recordings](./Demo_Recordings.md)
5 changes: 4 additions & 1 deletion pkg/gui/controllers/helpers/refresh_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) error {

wg := sync.WaitGroup{}
refresh := func(name string, f func()) {
if options.Mode == types.ASYNC {
// if we're in a demo we don't want any async refreshes because
// everything happens fast and it's better to have everything update
// in the one frame
if !self.c.InDemo() && options.Mode == types.ASYNC {
self.c.OnWorker(func(t gocui.Task) {
f()
})
Expand Down
10 changes: 8 additions & 2 deletions pkg/gui/controllers/helpers/window_arrangement_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,18 @@ func (self *WindowArrangementHelper) infoSectionChildren(informationStr string,
appStatusBox.Weight = 1
} else {
optionsBox.Weight = 1
appStatusBox.Size = runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(appStatus)
if self.c.InDemo() {
// app status appears very briefly in demos and dislodges the caption,
// so better not to show it at all
appStatusBox.Size = 0
} else {
appStatusBox.Size = runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(appStatus)
}
}

result := []*boxlayout.Box{appStatusBox, optionsBox}

if self.c.UserConfig.Gui.ShowBottomLine || self.modeHelper.IsAnyModeActive() {
if (!self.c.InDemo() && self.c.UserConfig.Gui.ShowBottomLine) || self.modeHelper.IsAnyModeActive() {
result = append(result, &boxlayout.Box{
Window: "information",
// unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
Expand Down
20 changes: 20 additions & 0 deletions pkg/gui/global_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
Expand Down Expand Up @@ -137,3 +138,22 @@ func (gui *Gui) handleCopySelectedSideContextItemToClipboard() error {

return nil
}

func (gui *Gui) setCaption(caption string) {
gui.Views.Options.FgColor = gocui.ColorWhite
gui.Views.Options.FgColor |= gocui.AttrBold
gui.Views.Options.SetContent(captionPrefix + " " + style.FgCyan.SetBold().Sprint(caption))
gui.c.Render()
}

var captionPrefix = ""

func (gui *Gui) setCaptionPrefix(prefix string) {
gui.Views.Options.FgColor = gocui.ColorWhite
gui.Views.Options.FgColor |= gocui.AttrBold

captionPrefix = prefix

gui.Views.Options.SetContent(prefix)
gui.c.Render()
}
1 change: 1 addition & 0 deletions pkg/gui/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ func NewGui(
func(message string) { gui.helpers.AppStatus.Toast(message) },
func() string { return gui.Views.Confirmation.TextArea.GetContent() },
func(f func(gocui.Task)) { gui.c.OnWorker(f) },
func() bool { return gui.c.InDemo() },
)

guiCommon := &guiCommon{gui: gui, IPopupHandler: gui.PopupHandler}
Expand Down
4 changes: 4 additions & 0 deletions pkg/gui/gui_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,7 @@ func (self *guiCommon) AfterLayout(f func() error) {
self.gui.c.Log.Error("afterLayoutFuncs channel is full, skipping function")
}
}

func (self *guiCommon) InDemo() bool {
return self.gui.integrationTest != nil && self.gui.integrationTest.IsDemo()
}
16 changes: 15 additions & 1 deletion pkg/gui/gui_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ func (self *GuiDriver) PressKey(keyStr string) {
0,
)

// wait until lazygit is idle (i.e. all processing is done) before continuing
self.waitTillIdle()
}

// wait until lazygit is idle (i.e. all processing is done) before continuing
func (self *GuiDriver) waitTillIdle() {
<-self.isIdleChan
}

Expand Down Expand Up @@ -111,3 +115,13 @@ func (self *GuiDriver) View(viewName string) *gocui.View {
}
return view
}

func (self *GuiDriver) SetCaption(caption string) {
self.gui.setCaption(caption)
self.waitTillIdle()
}

func (self *GuiDriver) SetCaptionPrefix(prefix string) {
self.gui.setCaptionPrefix(prefix)
self.waitTillIdle()
}
4 changes: 4 additions & 0 deletions pkg/gui/options_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ type OptionsMapMgr struct {
}

func (gui *Gui) renderContextOptionsMap(c types.Context) {
// In demos, we render our own content to this view
if gui.integrationTest != nil && gui.integrationTest.IsDemo() {
return
}
mgr := OptionsMapMgr{c: gui.c}
mgr.renderContextOptionsMap(c)
}
Expand Down
9 changes: 9 additions & 0 deletions pkg/gui/popup/popup_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package popup
import (
"context"
"strings"
"time"

"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/common"
Expand All @@ -25,6 +26,7 @@ type PopupHandler struct {
toastFn func(message string)
getPromptInputFn func() string
onWorker func(func(gocui.Task))
inDemo func() bool
}

var _ types.IPopupHandler = &PopupHandler{}
Expand All @@ -40,6 +42,7 @@ func NewPopupHandler(
toastFn func(message string),
getPromptInputFn func() string,
onWorker func(func(gocui.Task)),
inDemo func() bool,
) *PopupHandler {
return &PopupHandler{
Common: common,
Expand All @@ -53,6 +56,7 @@ func NewPopupHandler(
toastFn: toastFn,
getPromptInputFn: getPromptInputFn,
onWorker: onWorker,
inDemo: inDemo,
}
}

Expand Down Expand Up @@ -144,6 +148,11 @@ func (self *PopupHandler) WithLoaderPanel(message string, f func(gocui.Task) err
}

self.onWorker(func(task gocui.Task) {
// emulating a delay due to network latency
if self.inDemo() {
time.Sleep(500 * time.Millisecond)
}

if err := f(task); err != nil {
self.Log.Error(err)
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/gui/types/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ type IGuiCommon interface {

// hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct.
GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding)

// Returns true if we're in a demo recording/playback
InDemo() bool
}

type IModeMgr interface {
Expand Down
Loading

0 comments on commit b92c294

Please sign in to comment.