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