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 }