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