diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 437db154d..d68cfbc80 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -131,7 +131,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b return false } - return (binding.Description != "" || binding.Alternative != "") + return (binding.Description != "" || binding.Alternative != "") && binding.Key != nil }) bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header { diff --git a/pkg/cheatsheet/generate_test.go b/pkg/cheatsheet/generate_test.go index 44c49a461..ba77d3b15 100644 --- a/pkg/cheatsheet/generate_test.go +++ b/pkg/cheatsheet/generate_test.go @@ -27,6 +27,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, }, expected: []*bindingSection{ @@ -36,6 +37,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, }, }, @@ -47,6 +49,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", + Key: 'a', }, }, expected: []*bindingSection{ @@ -56,6 +59,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", + Key: 'a', }, }, }, @@ -67,14 +71,17 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, { ViewName: "submodules", Description: "drop submodule", + Key: 'a', }, }, expected: []*bindingSection{ @@ -84,10 +91,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, }, }, @@ -97,6 +106,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "submodules", Description: "drop submodule", + Key: 'a', }, }, }, @@ -108,19 +118,23 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, { ViewName: "files", Description: "scroll", + Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", + Key: 'a', }, }, expected: []*bindingSection{ @@ -130,6 +144,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", + Key: 'a', Tag: "navigation", }, }, @@ -140,6 +155,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", + Key: 'a', }, }, }, @@ -149,10 +165,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, }, }, @@ -164,28 +182,34 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, { ViewName: "files", Description: "scroll", + Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", + Key: 'a', }, { ViewName: "commits", Description: "scroll", + Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "page up", + Key: 'a', Tag: "navigation", }, }, @@ -196,11 +220,13 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", + Key: 'a', Tag: "navigation", }, { ViewName: "commits", Description: "page up", + Key: 'a', Tag: "navigation", }, }, @@ -211,6 +237,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", + Key: 'a', }, }, }, @@ -220,10 +247,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", + Key: 'a', }, { ViewName: "files", Description: "unstage file", + Key: 'a', }, }, }, diff --git a/pkg/gui/context/context.go b/pkg/gui/context/context.go index 131eecf0a..5a88b4a26 100644 --- a/pkg/gui/context/context.go +++ b/pkg/gui/context/context.go @@ -7,6 +7,7 @@ import ( const ( GLOBAL_CONTEXT_KEY types.ContextKey = "global" STATUS_CONTEXT_KEY types.ContextKey = "status" + SNAKE_CONTEXT_KEY types.ContextKey = "snake" FILES_CONTEXT_KEY types.ContextKey = "files" LOCAL_BRANCHES_CONTEXT_KEY types.ContextKey = "localBranches" REMOTES_CONTEXT_KEY types.ContextKey = "remotes" @@ -74,6 +75,7 @@ var AllContextKeys = []types.ContextKey{ type ContextTree struct { Global types.Context Status types.Context + Snake types.Context Files *WorkingTreeContext Menu *MenuContext Branches *BranchesContext @@ -112,6 +114,7 @@ func (self *ContextTree) Flatten() []types.Context { return []types.Context{ self.Global, self.Status, + self.Snake, self.Submodules, self.Files, self.SubCommits, diff --git a/pkg/gui/context_config.go b/pkg/gui/context_config.go index 7bd0ca7f8..063690ff5 100644 --- a/pkg/gui/context_config.go +++ b/pkg/gui/context_config.go @@ -32,6 +32,26 @@ func (gui *Gui) contextTree() *context.ContextTree { OnRenderToMain: gui.statusRenderToMain, }, ), + Snake: context.NewSimpleContext( + context.NewBaseContext(context.NewBaseContextOpts{ + Kind: types.SIDE_CONTEXT, + View: gui.Views.Snake, + WindowName: "files", + Key: context.SNAKE_CONTEXT_KEY, + Focusable: true, + }), + context.ContextCallbackOpts{ + OnFocus: func(opts types.OnFocusOpts) error { + gui.startSnake() + return nil + }, + OnFocusLost: func(opts types.OnFocusLostOpts) error { + gui.snakeGame.Exit() + gui.moveToTopOfWindow(gui.State.Contexts.Submodules) + return nil + }, + }, + ), Files: gui.filesListContext(), Submodules: gui.submodulesListContext(), Menu: gui.menuListContext(), diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 974080e67..d7f6647c7 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/services/custom_commands" + "github.com/jesseduffield/lazygit/pkg/snake" ) func (gui *Gui) resetControllers() { @@ -130,6 +131,7 @@ func (gui *Gui) resetControllers() { stagingController := controllers.NewStagingController(common, gui.State.Contexts.Staging, gui.State.Contexts.StagingSecondary, false) stagingSecondaryController := controllers.NewStagingController(common, gui.State.Contexts.StagingSecondary, gui.State.Contexts.Staging, true) patchBuildingController := controllers.NewPatchBuildingController(common) + snakeController := controllers.NewSnakeController(common, func() *snake.Game { return gui.snakeGame }) setSubCommits := func(commits []*models.Commit) { gui.State.Model.SubCommits = commits } @@ -248,6 +250,10 @@ func (gui *Gui) resetControllers() { contextLinesController, ) + controllers.AttachControllers(gui.State.Contexts.Snake, + snakeController, + ) + // this must come last so that we've got our click handlers defined against the context listControllerFactory := controllers.NewListControllerFactory(gui.c) for _, context := range gui.getListContexts() { diff --git a/pkg/gui/controllers/snake_controller.go b/pkg/gui/controllers/snake_controller.go new file mode 100644 index 000000000..4217878e3 --- /dev/null +++ b/pkg/gui/controllers/snake_controller.go @@ -0,0 +1,68 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/snake" +) + +type SnakeController struct { + baseController + *controllerCommon + + getGame func() *snake.Game +} + +var _ types.IController = &SnakeController{} + +func NewSnakeController( + common *controllerCommon, + getGame func() *snake.Game, +) *SnakeController { + return &SnakeController{ + baseController: baseController{}, + controllerCommon: common, + getGame: getGame, + } +} + +func (self *SnakeController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + bindings := []*types.Binding{ + { + Key: opts.GetKey(opts.Config.Universal.NextItem), + Handler: self.SetDirection(snake.Down), + }, + { + Key: opts.GetKey(opts.Config.Universal.PrevItem), + Handler: self.SetDirection(snake.Up), + }, + { + Key: opts.GetKey(opts.Config.Universal.PrevBlock), + Handler: self.SetDirection(snake.Left), + }, + { + Key: opts.GetKey(opts.Config.Universal.NextBlock), + Handler: self.SetDirection(snake.Right), + }, + { + Key: opts.GetKey(opts.Config.Universal.Return), + Handler: self.Escape, + }, + } + + return bindings +} + +func (self *SnakeController) Context() types.Context { + return self.contexts.Snake +} + +func (self *SnakeController) SetDirection(direction snake.Direction) func() error { + return func() error { + self.getGame().SetDirection(direction) + return nil + } +} + +func (self *SnakeController) Escape() error { + return self.c.PushContext(self.contexts.Submodules) +} diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 5bd7ce088..e6a6f8d17 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -69,6 +69,11 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* Description: self.c.Tr.LcViewBulkSubmoduleOptions, OpensMenu: true, }, + { + Key: nil, + Handler: self.easterEgg, + Description: self.c.Tr.EasterEgg, + }, } } @@ -219,6 +224,10 @@ func (self *SubmodulesController) remove(submodule *models.SubmoduleConfig) erro }) } +func (self *SubmodulesController) easterEgg() error { + return self.c.PushContext(self.contexts.Snake) +} + func (self *SubmodulesController) checkSelected(callback func(*models.SubmoduleConfig) error) func() error { return func() error { submodule := self.context().GetSelected() diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 1431f6c16..80b18661b 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -33,6 +33,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/integration/components" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" + "github.com/jesseduffield/lazygit/pkg/snake" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/updates" @@ -154,6 +155,8 @@ type Gui struct { c *types.HelperCommon helpers *helpers.Helpers + + snakeGame *snake.Game } // we keep track of some stuff from one render to the next to see if certain diff --git a/pkg/gui/snake.go b/pkg/gui/snake.go new file mode 100644 index 000000000..9d82275f8 --- /dev/null +++ b/pkg/gui/snake.go @@ -0,0 +1,56 @@ +package gui + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/jesseduffield/lazygit/pkg/snake" +) + +func (gui *Gui) startSnake() { + view := gui.Views.Snake + + game := snake.NewGame(view.Width(), view.Height(), gui.renderSnakeGame, gui.c.LogAction) + gui.snakeGame = game + game.Start() +} + +func (gui *Gui) renderSnakeGame(cells [][]snake.CellType, alive bool) { + view := gui.Views.Snake + + if !alive { + _ = gui.c.ErrorMsg(gui.Tr.YouDied) + return + } + + output := drawSnakeGame(cells) + + view.Clear() + fmt.Fprint(view, output) + gui.c.Render() +} + +func drawSnakeGame(cells [][]snake.CellType) string { + writer := &strings.Builder{} + + for i, row := range cells { + for _, cell := range row { + switch cell { + case snake.None: + writer.WriteString(" ") + case snake.Snake: + writer.WriteString("█") + case snake.Food: + writer.WriteString(style.FgMagenta.Sprint("█")) + } + } + + if i < len(cells) { + writer.WriteString("\n") + } + } + + output := writer.String() + return output +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 9cc3d983f..be9a2952b 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -40,6 +40,9 @@ type Views struct { Suggestions *gocui.View Tooltip *gocui.View Extras *gocui.View + + // for playing the easter egg snake game + Snake *gocui.View } type viewNameMapping struct { @@ -58,6 +61,7 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping { // first layer. Ordering within this layer does not matter because there are // no overlapping views {viewPtr: &gui.Views.Status, name: "status"}, + {viewPtr: &gui.Views.Snake, name: "snake"}, {viewPtr: &gui.Views.Submodules, name: "submodules"}, {viewPtr: &gui.Views.Files, name: "files"}, {viewPtr: &gui.Views.Tags, name: "tags"}, @@ -220,5 +224,8 @@ func (gui *Gui) createAllViews() error { gui.Views.Extras.Autoscroll = true gui.Views.Extras.Wrap = true + gui.Views.Snake.Title = gui.c.Tr.SnakeTitle + gui.Views.Snake.FgColor = gocui.ColorGreen + return nil } diff --git a/pkg/gui/window.go b/pkg/gui/window.go index 1d69c420c..c5355bcea 100644 --- a/pkg/gui/window.go +++ b/pkg/gui/window.go @@ -76,26 +76,21 @@ func (gui *Gui) resetWindowContext(c types.Context) { } } -// moves given context's view to the top of the window and returns -// true if the view was not already on top. -func (gui *Gui) moveToTopOfWindow(context types.Context) bool { +// moves given context's view to the top of the window +func (gui *Gui) moveToTopOfWindow(context types.Context) { view := context.GetView() if view == nil { - return false + return } window := context.GetWindowName() topView := gui.topViewInWindow(window) - if view.Name() == topView.Name() { - return false - } else { + if view.Name() != topView.Name() { if err := gui.g.SetViewOnTopOf(view.Name(), topView.Name()); err != nil { gui.Log.Error(err) } - - return true } } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index b90564e29..f1c081018 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -17,6 +17,8 @@ type TranslationSet struct { BranchesTitle string CommitsTitle string StashTitle string + SnakeTitle string + EasterEgg string UnstagedChanges string StagedChanges string MainTitle string @@ -213,6 +215,7 @@ type TranslationSet struct { ErrorOccurred string NoRoom string YouAreHere string + YouDied string LcRewordNotSupported string LcCherryPickCopy string LcCherryPickCopyRange string @@ -665,6 +668,8 @@ func EnglishTranslationSet() TranslationSet { BranchesTitle: "Branches", CommitsTitle: "Commits", StashTitle: "Stash", + SnakeTitle: "Snake", + EasterEgg: "easter egg", UnstagedChanges: `Unstaged Changes`, StagedChanges: `Staged Changes`, MainTitle: "Main", @@ -861,6 +866,7 @@ func EnglishTranslationSet() TranslationSet { ErrorOccurred: "An error occurred! Please create an issue at", NoRoom: "Not enough room", YouAreHere: "YOU ARE HERE", + YouDied: "YOU DIED!", LcRewordNotSupported: "rewording commits while interactively rebasing is not currently supported", LcCherryPickCopy: "copy commit (cherry-pick)", LcCherryPickCopyRange: "copy commit range (cherry-pick)", diff --git a/pkg/snake/snake.go b/pkg/snake/snake.go new file mode 100644 index 000000000..68feec15c --- /dev/null +++ b/pkg/snake/snake.go @@ -0,0 +1,209 @@ +package snake + +import ( + "math/rand" + "time" + + "github.com/samber/lo" +) + +type Game struct { + // width/height of the board + width int + height int + + // function for rendering the game. If alive is false, the cells are expected + // to be ignored. + render func(cells [][]CellType, alive bool) + + // closed when the game is exited + exit chan (struct{}) + + // channel for specifying the direction the player wants the snake to go in + setNewDir chan (Direction) + + // allows logging for debugging + logger func(string) + + // putting this on the struct for deterministic testing + randIntFn func(int) int +} + +type State struct { + // first element is the head, final element is the tail + snakePositions []Position + + foodPosition Position + + // direction of the snake + direction Direction + // direction as of the end of the last tick. We hold onto this so that + // the snake can't do a 180 turn inbetween ticks + lastTickDirection Direction +} + +type Position struct { + x int + y int +} + +type Direction int + +const ( + Up Direction = iota + Down + Left + Right +) + +type CellType int + +const ( + None CellType = iota + Snake + Food +) + +func NewGame(width, height int, render func(cells [][]CellType, alive bool), logger func(string)) *Game { + return &Game{ + width: width, + height: height, + render: render, + randIntFn: rand.Intn, + exit: make(chan struct{}), + logger: logger, + setNewDir: make(chan Direction), + } +} + +func (self *Game) Start() { + go self.gameLoop() +} + +func (self *Game) Exit() { + close(self.exit) +} + +func (self *Game) SetDirection(direction Direction) { + self.setNewDir <- direction +} + +func (self *Game) gameLoop() { + state := self.initializeState() + var alive bool + + self.render(self.getCells(state), true) + + ticker := time.NewTicker(time.Duration(75) * time.Millisecond) + + for { + select { + case <-self.exit: + return + case dir := <-self.setNewDir: + state.direction = self.newDirection(state, dir) + case <-ticker.C: + state, alive = self.tick(state) + self.render(self.getCells(state), alive) + if !alive { + return + } + } + } +} + +func (self *Game) initializeState() State { + centerOfScreen := Position{self.width / 2, self.height / 2} + snakePositions := []Position{centerOfScreen} + + state := State{ + snakePositions: snakePositions, + direction: Right, + foodPosition: self.newFoodPos(snakePositions), + } + + return state +} + +func (self *Game) newFoodPos(snakePositions []Position) Position { + // arbitrarily setting a limit of attempts to place food + attemptLimit := 1000 + + for i := 0; i < attemptLimit; i++ { + newFoodPos := Position{self.randIntFn(self.width), self.randIntFn(self.height)} + + if !lo.Contains(snakePositions, newFoodPos) { + return newFoodPos + } + } + + panic("SORRY, BUT I WAS TOO LAZY TO MAKE THE SNAKE GAME SMART ENOUGH TO PUT THE FOOD SOMEWHERE SENSIBLE NO MATTER WHAT, AND I ALSO WAS TOO LAZY TO ADD A WIN CONDITION") +} + +// returns whether the snake is alive +func (self *Game) tick(currentState State) (State, bool) { + nextState := currentState // copy by value + newHeadPos := nextState.snakePositions[0] + + nextState.lastTickDirection = nextState.direction + + switch nextState.direction { + case Up: + newHeadPos.y-- + case Down: + newHeadPos.y++ + case Left: + newHeadPos.x-- + case Right: + newHeadPos.x++ + } + + outOfBounds := newHeadPos.x < 0 || newHeadPos.x >= self.width || newHeadPos.y < 0 || newHeadPos.y >= self.height + eatingOwnTail := lo.Contains(nextState.snakePositions, newHeadPos) + + if outOfBounds || eatingOwnTail { + return State{}, false + } + + nextState.snakePositions = append([]Position{newHeadPos}, nextState.snakePositions...) + + if newHeadPos == nextState.foodPosition { + nextState.foodPosition = self.newFoodPos(nextState.snakePositions) + } else { + nextState.snakePositions = nextState.snakePositions[:len(nextState.snakePositions)-1] + } + + return nextState, true +} + +func (self *Game) getCells(state State) [][]CellType { + cells := make([][]CellType, self.height) + + setCell := func(pos Position, value CellType) { + cells[pos.y][pos.x] = value + } + + for i := 0; i < self.height; i++ { + cells[i] = make([]CellType, self.width) + } + + for _, pos := range state.snakePositions { + setCell(pos, Snake) + } + + setCell(state.foodPosition, Food) + + return cells +} + +func (self *Game) newDirection(state State, direction Direction) Direction { + // don't allow the snake to turn 180 degrees + if (state.lastTickDirection == Up && direction == Down) || + (state.lastTickDirection == Down && direction == Up) || + (state.lastTickDirection == Left && direction == Right) || + (state.lastTickDirection == Right && direction == Left) { + return state.direction + } + + return direction +} diff --git a/pkg/snake/snake_test.go b/pkg/snake/snake_test.go new file mode 100644 index 000000000..7a7ed038a --- /dev/null +++ b/pkg/snake/snake_test.go @@ -0,0 +1,64 @@ +package snake + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSnake(t *testing.T) { + scenarios := []struct { + state State + expectedState State + expectedAlive bool + }{ + { + state: State{ + snakePositions: []Position{{x: 5, y: 5}}, + direction: Right, + lastTickDirection: Right, + foodPosition: Position{x: 9, y: 9}, + }, + expectedState: State{ + snakePositions: []Position{{x: 6, y: 5}}, + direction: Right, + lastTickDirection: Right, + foodPosition: Position{x: 9, y: 9}, + }, + expectedAlive: true, + }, + { + state: State{ + snakePositions: []Position{{x: 5, y: 5}, {x: 4, y: 5}, {x: 4, y: 4}, {x: 5, y: 4}}, + direction: Up, + lastTickDirection: Up, + foodPosition: Position{x: 9, y: 9}, + }, + expectedState: State{}, + expectedAlive: false, + }, + { + state: State{ + snakePositions: []Position{{x: 5, y: 5}}, + direction: Right, + lastTickDirection: Right, + foodPosition: Position{x: 6, y: 5}, + }, + expectedState: State{ + snakePositions: []Position{{x: 6, y: 5}, {x: 5, y: 5}}, + direction: Right, + lastTickDirection: Right, + foodPosition: Position{x: 8, y: 8}, + }, + expectedAlive: true, + }, + } + + for _, scenario := range scenarios { + game := NewGame(10, 10, nil, func(string) {}) + game.randIntFn = func(int) int { return 8 } + state, alive := game.tick(scenario.state) + assert.Equal(t, scenario.expectedAlive, alive) + assert.EqualValues(t, scenario.expectedState, state) + } +}