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 }