github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/repo/garden/garden.go (about)

     1  package garden
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"math/rand"
     8  	"net/http"
     9  	"os"
    10  	"os/exec"
    11  	"runtime"
    12  	"strconv"
    13  	"strings"
    14  
    15  	"github.com/cli/cli/api"
    16  	"github.com/cli/cli/internal/ghinstance"
    17  	"github.com/cli/cli/internal/ghrepo"
    18  	"github.com/cli/cli/pkg/cmdutil"
    19  	"github.com/cli/cli/pkg/iostreams"
    20  	"github.com/cli/cli/utils"
    21  	"github.com/spf13/cobra"
    22  	"golang.org/x/term"
    23  )
    24  
    25  type Geometry struct {
    26  	Width      int
    27  	Height     int
    28  	Density    float64
    29  	Repository ghrepo.Interface
    30  }
    31  
    32  type Player struct {
    33  	X                   int
    34  	Y                   int
    35  	Char                string
    36  	Geo                 *Geometry
    37  	ShoeMoistureContent int
    38  }
    39  
    40  type Commit struct {
    41  	Email  string
    42  	Handle string
    43  	Sha    string
    44  	Char   string
    45  }
    46  
    47  type Cell struct {
    48  	Char       string
    49  	StatusLine string
    50  }
    51  
    52  const (
    53  	DirUp Direction = iota
    54  	DirDown
    55  	DirLeft
    56  	DirRight
    57  	Quit
    58  )
    59  
    60  type Direction = int
    61  
    62  func (p *Player) move(direction Direction) bool {
    63  	switch direction {
    64  	case DirUp:
    65  		if p.Y == 0 {
    66  			return false
    67  		}
    68  		p.Y--
    69  	case DirDown:
    70  		if p.Y == p.Geo.Height-1 {
    71  			return false
    72  		}
    73  		p.Y++
    74  	case DirLeft:
    75  		if p.X == 0 {
    76  			return false
    77  		}
    78  		p.X--
    79  	case DirRight:
    80  		if p.X == p.Geo.Width-1 {
    81  			return false
    82  		}
    83  		p.X++
    84  	}
    85  
    86  	return true
    87  }
    88  
    89  type GardenOptions struct {
    90  	HttpClient func() (*http.Client, error)
    91  	IO         *iostreams.IOStreams
    92  	BaseRepo   func() (ghrepo.Interface, error)
    93  
    94  	RepoArg string
    95  }
    96  
    97  func NewCmdGarden(f *cmdutil.Factory, runF func(*GardenOptions) error) *cobra.Command {
    98  	opts := GardenOptions{
    99  		IO:         f.IOStreams,
   100  		HttpClient: f.HttpClient,
   101  		BaseRepo:   f.BaseRepo,
   102  	}
   103  
   104  	cmd := &cobra.Command{
   105  		Use:    "garden [<repository>]",
   106  		Short:  "Explore a git repository as a garden",
   107  		Long:   "Use arrow keys, WASD or vi keys to move. q to quit.",
   108  		Hidden: true,
   109  		RunE: func(c *cobra.Command, args []string) error {
   110  			if len(args) > 0 {
   111  				opts.RepoArg = args[0]
   112  			}
   113  			if runF != nil {
   114  				return runF(&opts)
   115  			}
   116  			return gardenRun(&opts)
   117  		},
   118  	}
   119  
   120  	return cmd
   121  }
   122  
   123  func gardenRun(opts *GardenOptions) error {
   124  	cs := opts.IO.ColorScheme()
   125  	out := opts.IO.Out
   126  
   127  	if runtime.GOOS == "windows" {
   128  		return errors.New("sorry :( this command only works on linux and macos")
   129  	}
   130  
   131  	if !opts.IO.IsStdoutTTY() {
   132  		return errors.New("must be connected to a terminal")
   133  	}
   134  
   135  	httpClient, err := opts.HttpClient()
   136  	if err != nil {
   137  		return err
   138  	}
   139  
   140  	var toView ghrepo.Interface
   141  	apiClient := api.NewClientFromHTTP(httpClient)
   142  	if opts.RepoArg == "" {
   143  		var err error
   144  		toView, err = opts.BaseRepo()
   145  		if err != nil {
   146  			return err
   147  		}
   148  	} else {
   149  		var err error
   150  		viewURL := opts.RepoArg
   151  		if !strings.Contains(viewURL, "/") {
   152  			currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default())
   153  			if err != nil {
   154  				return err
   155  			}
   156  			viewURL = currentUser + "/" + viewURL
   157  		}
   158  		toView, err = ghrepo.FromFullName(viewURL)
   159  		if err != nil {
   160  			return fmt.Errorf("argument error: %w", err)
   161  		}
   162  	}
   163  
   164  	seed := computeSeed(ghrepo.FullName(toView))
   165  	rand.Seed(seed)
   166  
   167  	termWidth, termHeight, err := utils.TerminalSize(out)
   168  	if err != nil {
   169  		return err
   170  	}
   171  
   172  	termWidth -= 10
   173  	termHeight -= 10
   174  
   175  	geo := &Geometry{
   176  		Width:      termWidth,
   177  		Height:     termHeight,
   178  		Repository: toView,
   179  		// TODO based on number of commits/cells instead of just hardcoding
   180  		Density: 0.3,
   181  	}
   182  
   183  	maxCommits := (geo.Width * geo.Height) / 2
   184  
   185  	opts.IO.StartProgressIndicator()
   186  	fmt.Fprintln(out, "gathering commits; this could take a minute...")
   187  	commits, err := getCommits(httpClient, toView, maxCommits)
   188  	opts.IO.StopProgressIndicator()
   189  	if err != nil {
   190  		return err
   191  	}
   192  	player := &Player{0, 0, cs.Bold("@"), geo, 0}
   193  
   194  	garden := plantGarden(commits, geo)
   195  	if len(garden) < geo.Height {
   196  		geo.Height = len(garden)
   197  	}
   198  	if geo.Height > 0 && len(garden[0]) < geo.Width {
   199  		geo.Width = len(garden[0])
   200  	} else if len(garden) == 0 {
   201  		geo.Width = 0
   202  	}
   203  	clear(opts.IO)
   204  	drawGarden(opts.IO, garden, player)
   205  
   206  	// TODO: use opts.IO instead of os.Stdout
   207  	oldTermState, err := term.MakeRaw(int(os.Stdout.Fd()))
   208  	if err != nil {
   209  		return fmt.Errorf("term.MakeRaw: %w", err)
   210  	}
   211  
   212  	dirc := make(chan Direction)
   213  	go func() {
   214  		b := make([]byte, 3)
   215  		for {
   216  			_, _ = opts.IO.In.Read(b)
   217  			switch {
   218  			case isLeft(b):
   219  				dirc <- DirLeft
   220  			case isRight(b):
   221  				dirc <- DirRight
   222  			case isUp(b):
   223  				dirc <- DirUp
   224  			case isDown(b):
   225  				dirc <- DirDown
   226  			case isQuit(b):
   227  				dirc <- Quit
   228  			}
   229  		}
   230  	}()
   231  
   232  mainLoop:
   233  	for {
   234  		oldX := player.X
   235  		oldY := player.Y
   236  
   237  		d := <-dirc
   238  		if d == Quit {
   239  			break mainLoop
   240  		} else if !player.move(d) {
   241  			continue mainLoop
   242  		}
   243  
   244  		underPlayer := garden[player.Y][player.X]
   245  		previousCell := garden[oldY][oldX]
   246  
   247  		// print whatever was just under player
   248  
   249  		fmt.Fprint(out, "\033[;H") // move to top left
   250  		for x := 0; x < oldX && x < player.Geo.Width; x++ {
   251  			fmt.Fprint(out, "\033[C")
   252  		}
   253  		for y := 0; y < oldY && y < player.Geo.Height; y++ {
   254  			fmt.Fprint(out, "\033[B")
   255  		}
   256  		fmt.Fprint(out, previousCell.Char)
   257  
   258  		// print player character
   259  		fmt.Fprint(out, "\033[;H") // move to top left
   260  		for x := 0; x < player.X && x < player.Geo.Width; x++ {
   261  			fmt.Fprint(out, "\033[C")
   262  		}
   263  		for y := 0; y < player.Y && y < player.Geo.Height; y++ {
   264  			fmt.Fprint(out, "\033[B")
   265  		}
   266  		fmt.Fprint(out, player.Char)
   267  
   268  		// handle stream wettening
   269  
   270  		if strings.Contains(underPlayer.StatusLine, "stream") {
   271  			player.ShoeMoistureContent = 5
   272  		} else {
   273  			if player.ShoeMoistureContent > 0 {
   274  				player.ShoeMoistureContent--
   275  			}
   276  		}
   277  
   278  		// status line stuff
   279  		sl := statusLine(garden, player, opts.IO)
   280  
   281  		fmt.Fprint(out, "\033[;H") // move to top left
   282  		for y := 0; y < player.Geo.Height-1; y++ {
   283  			fmt.Fprint(out, "\033[B")
   284  		}
   285  		fmt.Fprintln(out)
   286  		fmt.Fprintln(out)
   287  
   288  		fmt.Fprint(out, cs.Bold(sl))
   289  	}
   290  
   291  	clear(opts.IO)
   292  	fmt.Fprint(out, "\033[?25h")
   293  	// TODO: use opts.IO instead of os.Stdout
   294  	_ = term.Restore(int(os.Stdout.Fd()), oldTermState)
   295  	fmt.Fprintln(out, cs.Bold("You turn and walk away from the wildflower garden..."))
   296  
   297  	return nil
   298  }
   299  
   300  func isLeft(b []byte) bool {
   301  	left := []byte{27, 91, 68}
   302  	r := rune(b[0])
   303  	return bytes.EqualFold(b, left) || r == 'a' || r == 'h'
   304  }
   305  
   306  func isRight(b []byte) bool {
   307  	right := []byte{27, 91, 67}
   308  	r := rune(b[0])
   309  	return bytes.EqualFold(b, right) || r == 'd' || r == 'l'
   310  }
   311  
   312  func isDown(b []byte) bool {
   313  	down := []byte{27, 91, 66}
   314  	r := rune(b[0])
   315  	return bytes.EqualFold(b, down) || r == 's' || r == 'j'
   316  }
   317  
   318  func isUp(b []byte) bool {
   319  	up := []byte{27, 91, 65}
   320  	r := rune(b[0])
   321  	return bytes.EqualFold(b, up) || r == 'w' || r == 'k'
   322  }
   323  
   324  var ctrlC = []byte{0x3, 0x5b, 0x43}
   325  
   326  func isQuit(b []byte) bool {
   327  	return rune(b[0]) == 'q' || bytes.Equal(b, ctrlC)
   328  }
   329  
   330  func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell {
   331  	cellIx := 0
   332  	grassCell := &Cell{RGB(0, 200, 0, ","), "You're standing on a patch of grass in a field of wildflowers."}
   333  	garden := [][]*Cell{}
   334  	streamIx := rand.Intn(geo.Width - 1)
   335  	if streamIx == geo.Width/2 {
   336  		streamIx--
   337  	}
   338  	tint := 0
   339  	for y := 0; y < geo.Height; y++ {
   340  		if cellIx == len(commits)-1 {
   341  			break
   342  		}
   343  		garden = append(garden, []*Cell{})
   344  		for x := 0; x < geo.Width; x++ {
   345  			if (y > 0 && (x == 0 || x == geo.Width-1)) || y == geo.Height-1 {
   346  				garden[y] = append(garden[y], &Cell{
   347  					Char:       RGB(0, 150, 0, "^"),
   348  					StatusLine: "You're standing under a tall, leafy tree.",
   349  				})
   350  				continue
   351  			}
   352  			if x == streamIx {
   353  				garden[y] = append(garden[y], &Cell{
   354  					Char:       RGB(tint, tint, 255, "#"),
   355  					StatusLine: "You're standing in a shallow stream. It's refreshing.",
   356  				})
   357  				tint += 15
   358  				streamIx--
   359  				if rand.Float64() < 0.5 {
   360  					streamIx++
   361  				}
   362  				if streamIx < 0 {
   363  					streamIx = 0
   364  				}
   365  				if streamIx > geo.Width {
   366  					streamIx = geo.Width
   367  				}
   368  				continue
   369  			}
   370  			if y == 0 && (x < geo.Width/2 || x > geo.Width/2) {
   371  				garden[y] = append(garden[y], &Cell{
   372  					Char:       RGB(0, 200, 0, ","),
   373  					StatusLine: "You're standing by a wildflower garden. There is a light breeze.",
   374  				})
   375  				continue
   376  			} else if y == 0 && x == geo.Width/2 {
   377  				garden[y] = append(garden[y], &Cell{
   378  					Char:       RGB(139, 69, 19, "+"),
   379  					StatusLine: fmt.Sprintf("You're standing in front of a weather-beaten sign that says %s.", ghrepo.FullName(geo.Repository)),
   380  				})
   381  				continue
   382  			}
   383  
   384  			if cellIx == len(commits)-1 {
   385  				garden[y] = append(garden[y], grassCell)
   386  				continue
   387  			}
   388  
   389  			chance := rand.Float64()
   390  			if chance <= geo.Density {
   391  				commit := commits[cellIx]
   392  				garden[y] = append(garden[y], &Cell{
   393  					Char:       commits[cellIx].Char,
   394  					StatusLine: fmt.Sprintf("You're standing at a flower called %s planted by %s.", commit.Sha[0:6], commit.Handle),
   395  				})
   396  				cellIx++
   397  			} else {
   398  				garden[y] = append(garden[y], grassCell)
   399  			}
   400  		}
   401  	}
   402  
   403  	return garden
   404  }
   405  
   406  func drawGarden(io *iostreams.IOStreams, garden [][]*Cell, player *Player) {
   407  	out := io.Out
   408  	cs := io.ColorScheme()
   409  
   410  	fmt.Fprint(out, "\033[?25l") // hide cursor. it needs to be restored at command exit.
   411  	sl := ""
   412  	for y, gardenRow := range garden {
   413  		for x, gardenCell := range gardenRow {
   414  			char := ""
   415  			underPlayer := (player.X == x && player.Y == y)
   416  			if underPlayer {
   417  				sl = gardenCell.StatusLine
   418  				char = cs.Bold(player.Char)
   419  
   420  				if strings.Contains(gardenCell.StatusLine, "stream") {
   421  					player.ShoeMoistureContent = 5
   422  				}
   423  			} else {
   424  				char = gardenCell.Char
   425  			}
   426  
   427  			fmt.Fprint(out, char)
   428  		}
   429  		fmt.Fprintln(out)
   430  	}
   431  
   432  	fmt.Println()
   433  	fmt.Fprintln(out, cs.Bold(sl))
   434  }
   435  
   436  func statusLine(garden [][]*Cell, player *Player, io *iostreams.IOStreams) string {
   437  	width := io.TerminalWidth()
   438  	statusLines := []string{garden[player.Y][player.X].StatusLine}
   439  
   440  	if player.ShoeMoistureContent > 1 {
   441  		statusLines = append(statusLines, "Your shoes squish with water from the stream.")
   442  	} else if player.ShoeMoistureContent == 1 {
   443  		statusLines = append(statusLines, "Your shoes seem to have dried out.")
   444  	} else {
   445  		statusLines = append(statusLines, "")
   446  	}
   447  
   448  	for i, line := range statusLines {
   449  		if len(line) < width {
   450  			paddingSize := width - len(line)
   451  			statusLines[i] = line + strings.Repeat(" ", paddingSize)
   452  		}
   453  	}
   454  
   455  	return strings.Join(statusLines, "\n")
   456  }
   457  
   458  func shaToColorFunc(sha string) func(string) string {
   459  	return func(c string) string {
   460  		red, err := strconv.ParseInt(sha[0:2], 16, 64)
   461  		if err != nil {
   462  			panic(err)
   463  		}
   464  
   465  		green, err := strconv.ParseInt(sha[2:4], 16, 64)
   466  		if err != nil {
   467  			panic(err)
   468  		}
   469  
   470  		blue, err := strconv.ParseInt(sha[4:6], 16, 64)
   471  		if err != nil {
   472  			panic(err)
   473  		}
   474  
   475  		return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", red, green, blue, c)
   476  	}
   477  }
   478  
   479  func computeSeed(seed string) int64 {
   480  	lol := ""
   481  
   482  	for _, r := range seed {
   483  		lol += fmt.Sprintf("%d", int(r))
   484  	}
   485  
   486  	result, err := strconv.ParseInt(lol[0:10], 10, 64)
   487  	if err != nil {
   488  		panic(err)
   489  	}
   490  
   491  	return result
   492  }
   493  
   494  func clear(io *iostreams.IOStreams) {
   495  	cmd := exec.Command("clear")
   496  	cmd.Stdout = io.Out
   497  	_ = cmd.Run()
   498  }
   499  
   500  func RGB(r, g, b int, x string) string {
   501  	return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x)
   502  }