github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/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  	"os/signal"
    12  	"runtime"
    13  	"strconv"
    14  	"strings"
    15  	"syscall"
    16  
    17  	"github.com/abdfnx/gh-api/api"
    18  	"github.com/abdfnx/gh-api/internal/ghinstance"
    19  	"github.com/abdfnx/gh-api/internal/ghrepo"
    20  	"github.com/abdfnx/gh-api/pkg/cmdutil"
    21  	"github.com/abdfnx/gh-api/pkg/iostreams"
    22  	"github.com/abdfnx/gh-api/utils"
    23  	"github.com/spf13/cobra"
    24  )
    25  
    26  type Geometry struct {
    27  	Width      int
    28  	Height     int
    29  	Density    float64
    30  	Repository ghrepo.Interface
    31  }
    32  
    33  type Player struct {
    34  	X                   int
    35  	Y                   int
    36  	Char                string
    37  	Geo                 *Geometry
    38  	ShoeMoistureContent int
    39  }
    40  
    41  type Commit struct {
    42  	Email  string
    43  	Handle string
    44  	Sha    string
    45  	Char   string
    46  }
    47  
    48  type Cell struct {
    49  	Char       string
    50  	StatusLine string
    51  }
    52  
    53  const (
    54  	DirUp = iota
    55  	DirDown
    56  	DirLeft
    57  	DirRight
    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  	sttyFileArg := "-F"
   186  	if runtime.GOOS == "darwin" {
   187  		sttyFileArg = "-f"
   188  	}
   189  
   190  	oldTTYCommand := exec.Command("stty", sttyFileArg, "/dev/tty", "-g")
   191  	oldTTYSettings, err := oldTTYCommand.CombinedOutput()
   192  	if err != nil {
   193  		fmt.Fprintln(out, "getting TTY settings failed:", string(oldTTYSettings))
   194  		return err
   195  	}
   196  
   197  	opts.IO.StartProgressIndicator()
   198  	fmt.Fprintln(out, "gathering commits; this could take a minute...")
   199  	commits, err := getCommits(httpClient, toView, maxCommits)
   200  	opts.IO.StopProgressIndicator()
   201  	if err != nil {
   202  		return err
   203  	}
   204  	player := &Player{0, 0, cs.Bold("@"), geo, 0}
   205  
   206  	garden := plantGarden(commits, geo)
   207  	if len(garden) < geo.Height {
   208  		geo.Height = len(garden)
   209  	}
   210  	if geo.Height > 0 && len(garden[0]) < geo.Width {
   211  		geo.Width = len(garden[0])
   212  	} else if len(garden) == 0 {
   213  		geo.Width = 0
   214  	}
   215  	clear(opts.IO)
   216  	drawGarden(opts.IO, garden, player)
   217  
   218  	// thanks stackoverflow https://stackoverflow.com/a/17278776
   219  	_ = exec.Command("stty", sttyFileArg, "/dev/tty", "cbreak", "min", "1").Run()
   220  	_ = exec.Command("stty", sttyFileArg, "/dev/tty", "-echo").Run()
   221  
   222  	walkAway := func() {
   223  		clear(opts.IO)
   224  		fmt.Fprint(out, "\033[?25h")
   225  		_ = exec.Command("stty", sttyFileArg, "/dev/tty", strings.TrimSpace(string(oldTTYSettings))).Run()
   226  		fmt.Fprintln(out)
   227  		fmt.Fprintln(out, cs.Bold("You turn and walk away from the wildflower garden..."))
   228  	}
   229  
   230  	c := make(chan os.Signal)
   231  	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
   232  	go func() {
   233  		<-c
   234  		walkAway()
   235  		os.Exit(0)
   236  	}()
   237  
   238  	var b []byte = make([]byte, 3)
   239  	for {
   240  		_, _ = opts.IO.In.Read(b)
   241  
   242  		oldX := player.X
   243  		oldY := player.Y
   244  		moved := false
   245  		quitting := false
   246  		continuing := false
   247  
   248  		switch {
   249  		case isLeft(b):
   250  			moved = player.move(DirLeft)
   251  		case isRight(b):
   252  			moved = player.move(DirRight)
   253  		case isUp(b):
   254  			moved = player.move(DirUp)
   255  		case isDown(b):
   256  			moved = player.move(DirDown)
   257  		case isQuit(b):
   258  			quitting = true
   259  		default:
   260  			continuing = true
   261  		}
   262  
   263  		if quitting {
   264  			break
   265  		}
   266  
   267  		if !moved || continuing {
   268  			continue
   269  		}
   270  
   271  		underPlayer := garden[player.Y][player.X]
   272  		previousCell := garden[oldY][oldX]
   273  
   274  		// print whatever was just under player
   275  
   276  		fmt.Fprint(out, "\033[;H") // move to top left
   277  		for x := 0; x < oldX && x < player.Geo.Width; x++ {
   278  			fmt.Fprint(out, "\033[C")
   279  		}
   280  		for y := 0; y < oldY && y < player.Geo.Height; y++ {
   281  			fmt.Fprint(out, "\033[B")
   282  		}
   283  		fmt.Fprint(out, previousCell.Char)
   284  
   285  		// print player character
   286  		fmt.Fprint(out, "\033[;H") // move to top left
   287  		for x := 0; x < player.X && x < player.Geo.Width; x++ {
   288  			fmt.Fprint(out, "\033[C")
   289  		}
   290  		for y := 0; y < player.Y && y < player.Geo.Height; y++ {
   291  			fmt.Fprint(out, "\033[B")
   292  		}
   293  		fmt.Fprint(out, player.Char)
   294  
   295  		// handle stream wettening
   296  
   297  		if strings.Contains(underPlayer.StatusLine, "stream") {
   298  			player.ShoeMoistureContent = 5
   299  		} else {
   300  			if player.ShoeMoistureContent > 0 {
   301  				player.ShoeMoistureContent--
   302  			}
   303  		}
   304  
   305  		// status line stuff
   306  		sl := statusLine(garden, player, opts.IO)
   307  
   308  		fmt.Fprint(out, "\033[;H") // move to top left
   309  		for y := 0; y < player.Geo.Height-1; y++ {
   310  			fmt.Fprint(out, "\033[B")
   311  		}
   312  		fmt.Fprintln(out)
   313  		fmt.Fprintln(out)
   314  
   315  		fmt.Fprint(out, cs.Bold(sl))
   316  	}
   317  
   318  	walkAway()
   319  	return nil
   320  }
   321  
   322  func isLeft(b []byte) bool {
   323  	left := []byte{27, 91, 68}
   324  	r := rune(b[0])
   325  	return bytes.EqualFold(b, left) || r == 'a' || r == 'h'
   326  }
   327  
   328  func isRight(b []byte) bool {
   329  	right := []byte{27, 91, 67}
   330  	r := rune(b[0])
   331  	return bytes.EqualFold(b, right) || r == 'd' || r == 'l'
   332  }
   333  
   334  func isDown(b []byte) bool {
   335  	down := []byte{27, 91, 66}
   336  	r := rune(b[0])
   337  	return bytes.EqualFold(b, down) || r == 's' || r == 'j'
   338  }
   339  
   340  func isUp(b []byte) bool {
   341  	up := []byte{27, 91, 65}
   342  	r := rune(b[0])
   343  	return bytes.EqualFold(b, up) || r == 'w' || r == 'k'
   344  }
   345  
   346  func isQuit(b []byte) bool {
   347  	return rune(b[0]) == 'q'
   348  }
   349  
   350  func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell {
   351  	cellIx := 0
   352  	grassCell := &Cell{RGB(0, 200, 0, ","), "You're standing on a patch of grass in a field of wildflowers."}
   353  	garden := [][]*Cell{}
   354  	streamIx := rand.Intn(geo.Width - 1)
   355  	if streamIx == geo.Width/2 {
   356  		streamIx--
   357  	}
   358  	tint := 0
   359  	for y := 0; y < geo.Height; y++ {
   360  		if cellIx == len(commits)-1 {
   361  			break
   362  		}
   363  		garden = append(garden, []*Cell{})
   364  		for x := 0; x < geo.Width; x++ {
   365  			if (y > 0 && (x == 0 || x == geo.Width-1)) || y == geo.Height-1 {
   366  				garden[y] = append(garden[y], &Cell{
   367  					Char:       RGB(0, 150, 0, "^"),
   368  					StatusLine: "You're standing under a tall, leafy tree.",
   369  				})
   370  				continue
   371  			}
   372  			if x == streamIx {
   373  				garden[y] = append(garden[y], &Cell{
   374  					Char:       RGB(tint, tint, 255, "#"),
   375  					StatusLine: "You're standing in a shallow stream. It's refreshing.",
   376  				})
   377  				tint += 15
   378  				streamIx--
   379  				if rand.Float64() < 0.5 {
   380  					streamIx++
   381  				}
   382  				if streamIx < 0 {
   383  					streamIx = 0
   384  				}
   385  				if streamIx > geo.Width {
   386  					streamIx = geo.Width
   387  				}
   388  				continue
   389  			}
   390  			if y == 0 && (x < geo.Width/2 || x > geo.Width/2) {
   391  				garden[y] = append(garden[y], &Cell{
   392  					Char:       RGB(0, 200, 0, ","),
   393  					StatusLine: "You're standing by a wildflower garden. There is a light breeze.",
   394  				})
   395  				continue
   396  			} else if y == 0 && x == geo.Width/2 {
   397  				garden[y] = append(garden[y], &Cell{
   398  					Char:       RGB(139, 69, 19, "+"),
   399  					StatusLine: fmt.Sprintf("You're standing in front of a weather-beaten sign that says %s.", ghrepo.FullName(geo.Repository)),
   400  				})
   401  				continue
   402  			}
   403  
   404  			if cellIx == len(commits)-1 {
   405  				garden[y] = append(garden[y], grassCell)
   406  				continue
   407  			}
   408  
   409  			chance := rand.Float64()
   410  			if chance <= geo.Density {
   411  				commit := commits[cellIx]
   412  				garden[y] = append(garden[y], &Cell{
   413  					Char:       commits[cellIx].Char,
   414  					StatusLine: fmt.Sprintf("You're standing at a flower called %s planted by %s.", commit.Sha[0:6], commit.Handle),
   415  				})
   416  				cellIx++
   417  			} else {
   418  				garden[y] = append(garden[y], grassCell)
   419  			}
   420  		}
   421  	}
   422  
   423  	return garden
   424  }
   425  
   426  func drawGarden(io *iostreams.IOStreams, garden [][]*Cell, player *Player) {
   427  	out := io.Out
   428  	cs := io.ColorScheme()
   429  
   430  	fmt.Fprint(out, "\033[?25l") // hide cursor. it needs to be restored at command exit.
   431  	sl := ""
   432  	for y, gardenRow := range garden {
   433  		for x, gardenCell := range gardenRow {
   434  			char := ""
   435  			underPlayer := (player.X == x && player.Y == y)
   436  			if underPlayer {
   437  				sl = gardenCell.StatusLine
   438  				char = cs.Bold(player.Char)
   439  
   440  				if strings.Contains(gardenCell.StatusLine, "stream") {
   441  					player.ShoeMoistureContent = 5
   442  				}
   443  			} else {
   444  				char = gardenCell.Char
   445  			}
   446  
   447  			fmt.Fprint(out, char)
   448  		}
   449  		fmt.Fprintln(out)
   450  	}
   451  
   452  	fmt.Println()
   453  	fmt.Fprintln(out, cs.Bold(sl))
   454  }
   455  
   456  func statusLine(garden [][]*Cell, player *Player, io *iostreams.IOStreams) string {
   457  	width := io.TerminalWidth()
   458  	statusLines := []string{garden[player.Y][player.X].StatusLine}
   459  
   460  	if player.ShoeMoistureContent > 1 {
   461  		statusLines = append(statusLines, "Your shoes squish with water from the stream.")
   462  	} else if player.ShoeMoistureContent == 1 {
   463  		statusLines = append(statusLines, "Your shoes seem to have dried out.")
   464  	} else {
   465  		statusLines = append(statusLines, "")
   466  	}
   467  
   468  	for i, line := range statusLines {
   469  		if len(line) < width {
   470  			paddingSize := width - len(line)
   471  			statusLines[i] = line + strings.Repeat(" ", paddingSize)
   472  		}
   473  	}
   474  
   475  	return strings.Join(statusLines, "\n")
   476  }
   477  
   478  func shaToColorFunc(sha string) func(string) string {
   479  	return func(c string) string {
   480  		red, err := strconv.ParseInt(sha[0:2], 16, 64)
   481  		if err != nil {
   482  			panic(err)
   483  		}
   484  
   485  		green, err := strconv.ParseInt(sha[2:4], 16, 64)
   486  		if err != nil {
   487  			panic(err)
   488  		}
   489  
   490  		blue, err := strconv.ParseInt(sha[4:6], 16, 64)
   491  		if err != nil {
   492  			panic(err)
   493  		}
   494  
   495  		return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", red, green, blue, c)
   496  	}
   497  }
   498  
   499  func computeSeed(seed string) int64 {
   500  	lol := ""
   501  
   502  	for _, r := range seed {
   503  		lol += fmt.Sprintf("%d", int(r))
   504  	}
   505  
   506  	result, err := strconv.ParseInt(lol[0:10], 10, 64)
   507  	if err != nil {
   508  		panic(err)
   509  	}
   510  
   511  	return result
   512  }
   513  
   514  func clear(io *iostreams.IOStreams) {
   515  	cmd := exec.Command("clear")
   516  	cmd.Stdout = io.Out
   517  	_ = cmd.Run()
   518  }
   519  
   520  func RGB(r, g, b int, x string) string {
   521  	return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x)
   522  }