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