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 }