From d426892e6a17525bec64dc89aaf4e2cba8ca992a Mon Sep 17 00:00:00 2001
From: Nathan Anderson <n8r@tuta.io>
Date: Thu, 8 Aug 2024 18:51:51 -0600
Subject: [PATCH] Added rendering stuff, updated go mod

---
 cmd/renderer/main.go             |  11 ++
 flake.lock                       |  55 +++++++++
 flake.nix                        |   9 ++
 go.mod                           |   2 +
 go.sum                           |   4 +
 gomod2nix.toml                   |  15 +++
 internal/ansii/ansii.go          | 135 +++++++++++++++++++++
 internal/renderer/renderer.go    | 194 ++++++++++++++++++++++++++++++-
 internal/renderer/user_inputs.go |  25 ++++
 9 files changed, 447 insertions(+), 3 deletions(-)
 create mode 100644 gomod2nix.toml
 create mode 100644 internal/ansii/ansii.go
 create mode 100644 internal/renderer/user_inputs.go

diff --git a/cmd/renderer/main.go b/cmd/renderer/main.go
index e69de29..15370ce 100644
--- a/cmd/renderer/main.go
+++ b/cmd/renderer/main.go
@@ -0,0 +1,11 @@
+package main
+
+import (
+	"fmt"
+	"sshpong/internal/renderer"
+)
+
+func main() {
+	fmt.Println("Starting renderer")
+	renderer.Render()
+}
diff --git a/flake.lock b/flake.lock
index cf99a35..4f05e6c 100644
--- a/flake.lock
+++ b/flake.lock
@@ -18,6 +18,45 @@
         "type": "github"
       }
     },
+    "flake-utils_2": {
+      "inputs": {
+        "systems": "systems_2"
+      },
+      "locked": {
+        "lastModified": 1694529238,
+        "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "gomod2nix": {
+      "inputs": {
+        "flake-utils": "flake-utils_2",
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1722589758,
+        "narHash": "sha256-sbbA8b6Q2vB/t/r1znHawoXLysCyD4L/6n6/RykiSnA=",
+        "owner": "tweag",
+        "repo": "gomod2nix",
+        "rev": "4e08ca09253ef996bd4c03afa383b23e35fe28a1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "tweag",
+        "repo": "gomod2nix",
+        "type": "github"
+      }
+    },
     "nixpkgs": {
       "locked": {
         "lastModified": 1722421184,
@@ -37,6 +76,7 @@
     "root": {
       "inputs": {
         "flake-utils": "flake-utils",
+        "gomod2nix": "gomod2nix",
         "nixpkgs": "nixpkgs"
       }
     },
@@ -54,6 +94,21 @@
         "repo": "default",
         "type": "github"
       }
+    },
+    "systems_2": {
+      "locked": {
+        "lastModified": 1681028828,
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+        "owner": "nix-systems",
+        "repo": "default",
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-systems",
+        "repo": "default",
+        "type": "github"
+      }
     }
   },
   "root": "root",
diff --git a/flake.nix b/flake.nix
index b753109..6de704e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -2,15 +2,22 @@
   description = "An example project using flutter";
   inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
   inputs.flake-utils.url = "github:numtide/flake-utils";
+  inputs.gomod2nix = {
+        url = "github:tweag/gomod2nix";
+        inputs.nixpkgs.follows = "nixpkgs";
+        inputs.utils.follows = "utils";
+      };
   outputs = {
     self,
     flake-utils,
     nixpkgs,
+    gomod2nix,
     ...
   } @ inputs:
     flake-utils.lib.eachDefaultSystem (system: let
       pkgs = import nixpkgs {
         inherit system;
+        overlays = [ gomod2nix.overlays.default ];
       };
     in {
       devShell = pkgs.mkShell {
@@ -19,6 +26,8 @@
           gopls
           gotools
           go-tools
+          gomod2nix.packages.${system}.default
+          sqlite-interactive
         ];
       };
     });
diff --git a/go.mod b/go.mod
index ea8f265..eaa812f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,4 +5,6 @@ go 1.22.2
 require (
 	github.com/google/uuid v1.6.0
 	google.golang.org/protobuf v1.34.2
+	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/term v0.22.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 5d8aaa7..17d040b 100644
--- a/go.sum
+++ b/go.sum
@@ -2,3 +2,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
 google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
+golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
diff --git a/gomod2nix.toml b/gomod2nix.toml
new file mode 100644
index 0000000..437c32b
--- /dev/null
+++ b/gomod2nix.toml
@@ -0,0 +1,15 @@
+schema = 3
+
+[mod]
+  [mod."github.com/google/uuid"]
+    version = "v1.6.0"
+    hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+  [mod."golang.org/x/sys"]
+    version = "v0.23.0"
+    hash = "sha256-tC6QVLu72bADgINz26FUGdmYqKgsU45bHPg7sa0ZV7w="
+  [mod."golang.org/x/term"]
+    version = "v0.22.0"
+    hash = "sha256-tRx/y4ZIZzGAlDJ/8JW3AycC9bRXlNuRqO4V48sAEEc="
+  [mod."google.golang.org/protobuf"]
+    version = "v1.34.2"
+    hash = "sha256-nMTlrDEE2dbpWz50eQMPBQXCyQh4IdjrTIccaU0F3m0="
diff --git a/internal/ansii/ansii.go b/internal/ansii/ansii.go
new file mode 100644
index 0000000..7b37fb7
--- /dev/null
+++ b/internal/ansii/ansii.go
@@ -0,0 +1,135 @@
+package ansii
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"golang.org/x/term"
+)
+
+type ANSI string
+
+const (
+	reset       ANSI = "\033[0m"
+	plain       ANSI = ""
+	bold        ANSI = "\033[1m"
+	underline   ANSI = "\033[4m"
+	black       ANSI = "\033[30m"
+	red         ANSI = "\033[31m"
+	green       ANSI = "\033[32m"
+	yellow      ANSI = "\033[33m"
+	blue        ANSI = "\033[34m"
+	purple      ANSI = "\033[35m"
+	cyan        ANSI = "\033[36m"
+	white       ANSI = "\033[37m"
+	blackBg     ANSI = "\033[40m"
+	redBg       ANSI = "\033[41m"
+	greenBg     ANSI = "\033[42m"
+	yellowBg    ANSI = "\033[43m"
+	blueBg      ANSI = "\033[44m"
+	purpleBg    ANSI = "\033[45m"
+	cyanBg      ANSI = "\033[46m"
+	whiteBg     ANSI = "\033[47m"
+	clearScreen ANSI = "\033[2J"
+	hideCursor  ANSI = "\033[?25l"
+	showCursor  ANSI = "\033[?25h"
+)
+
+type Offset struct {
+	X int
+	Y int
+}
+
+type style struct {
+	Reset     ANSI
+	Plain     ANSI
+	Bold      ANSI
+	Underline ANSI
+}
+
+type color struct {
+	Black  ANSI
+	Red    ANSI
+	Green  ANSI
+	Yellow ANSI
+	Blue   ANSI
+	Purple ANSI
+	Cyan   ANSI
+	White  ANSI
+}
+
+type screen struct {
+	ClearScreen ANSI
+	HideCursor  ANSI
+	ShowCursor  ANSI
+}
+
+type ascii struct {
+	Block string
+}
+
+func GetTermSize() (width int, height int) {
+	var fd int = int(os.Stdout.Fd())
+	width, height, err := term.GetSize(fd)
+	if err != nil {
+		fmt.Println(string(Screen.ClearScreen) + "Fatal: error getting terminal size.")
+		os.Exit(1)
+	}
+	return width, height
+}
+
+func MakeTermRaw() (*term.State, error) {
+	var fd int = int(os.Stdout.Fd())
+	return term.MakeRaw(fd)
+}
+
+func RestoreTerm(prev *term.State) error {
+	var fd int = int(os.Stdout.Fd())
+	return term.Restore(fd, prev)
+}
+
+func (s screen) PlaceCursor(offset Offset) ANSI {
+	return ANSI(fmt.Sprintf("\033[%d;%dH", offset.Y, offset.X))
+}
+
+var (
+	Styles = style{Bold: bold, Underline: underline, Reset: reset, Plain: plain}
+	Colors = color{Red: red, Green: green, Yellow: yellow, Blue: blue, Purple: purple, Cyan: cyan, White: white}
+	Screen = screen{ClearScreen: clearScreen, HideCursor: hideCursor, ShowCursor: showCursor}
+	Blocks = ascii{Block: "█"}
+)
+
+// Draws a box of dimensions `height` and `width` at `offset`.
+// The `offset` is the top left cell of the square.
+// Blocks that would be placed off screen are clipped.
+func DrawBox(builder *strings.Builder, offset Offset, height int, width int, style ANSI) {
+	builder.WriteString(string(style))
+	for hIdx := 0; hIdx < height; hIdx++ {
+
+		if hIdx == 0 || hIdx == height-1 {
+			for wIdx := 0; wIdx < width; wIdx++ {
+				DrawPixel(builder, Offset{X: offset.X + wIdx, Y: offset.Y + hIdx})
+			}
+		} else {
+			DrawPixel(builder, Offset{X: offset.X, Y: offset.Y + hIdx})
+			DrawPixel(builder, Offset{X: offset.X + width - 1, Y: offset.Y + hIdx})
+		}
+	}
+	builder.WriteString(string(Styles.Reset))
+	return
+}
+
+func DrawPixel(builder *strings.Builder, offset Offset) {
+	termWidth, termHeight := GetTermSize()
+	if offset.X > termWidth || offset.Y > termHeight || offset.X < 0 || offset.Y < 0 {
+		return
+	}
+	builder.WriteString(string(Screen.PlaceCursor(offset) + ANSI(Blocks.Block)))
+}
+
+func DrawPixelStyle(builder *strings.Builder, offset Offset, style ANSI) {
+	builder.WriteString(string(style))
+	DrawPixel(builder, offset)
+	builder.WriteString(string(Styles.Reset))
+}
diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go
index 531e94a..08c53ad 100644
--- a/internal/renderer/renderer.go
+++ b/internal/renderer/renderer.go
@@ -1,7 +1,195 @@
 package renderer
 
-import "fmt"
+import (
+	"fmt"
+	"os"
+	"sshpong/internal/ansii"
+	"strings"
+	"time"
+	"unicode/utf8"
+)
 
-func render() {
-	fmt.Println("Test")
+var (
+	targetFps            float64 = 60.0
+	targetFpMilli        float64 = float64(targetFps) / 1000.0
+	millisecondTimeFrame float64 = float64(1 / targetFpMilli)
+	quit                 chan bool
+	userInput            chan rune
+	playerX              int = 10
+	playerY              int = 10
+)
+
+func Render() {
+	now := time.Now()
+	err := doFpsTest()
+	if err != nil {
+		fmt.Println("Error ", err)
+		return
+	}
+	then := time.Now()
+
+	total := float64(float64(then.UnixMicro()-now.UnixMicro()) / 1000.0)
+	fmt.Printf("\n\nTook %.3f milliseconds\n", total)
+}
+
+func writeCheckerBoard(height int, width int, builder *strings.Builder) {
+	for i := 0; i < height; i++ {
+		for j := 0; j < width; j++ {
+			if i%2 == 0 {
+				if j%2 == 0 {
+					builder.WriteString("█")
+				} else {
+
+					builder.WriteString(" ")
+				}
+			} else {
+				if j%2 == 0 {
+					builder.WriteString(" ")
+				} else {
+					builder.WriteString("█")
+				}
+			}
+		}
+	}
+}
+
+func drawScreen(frameNum int, startMs float64) (frameTimeMs float64) {
+	_ = frameNum
+	_, height := ansii.GetTermSize()
+	var builder = strings.Builder{}
+	builder.WriteString(string(ansii.Screen.ClearScreen))
+	// writeCheckerBoard(height, width, &builder)
+	// var xOffset = frameNum % width
+	// ansii.DrawBox(&builder, ansii.Offset{X: 0, Y: 0}, 5, 8, ansii.Colors.Purple)
+	ansii.DrawBox(&builder, ansii.Offset{X: playerX, Y: playerY}, 5, 1, ansii.Colors.Cyan)
+	ansii.DrawPixelStyle(&builder, ansii.Offset{X: playerX, Y: playerY}, ansii.Colors.Purple)
+	ansii.DrawPixelStyle(&builder, ansii.Offset{X: playerX, Y: playerY + 5}, ansii.Colors.Purple)
+	// Quit instructions
+	builder.WriteString(string(ansii.Screen.PlaceCursor(ansii.Offset{X: 0, Y: height})))
+	builder.WriteString("q to quit")
+
+	os.Stdout.WriteString(builder.String())
+	var frameTimeMilli = (float64(time.Now().UnixNano()) / 1_000_000.0) - startMs
+	// builder.WriteString(string(ansii.Screen.Coordinate(0, 0)))
+	return frameTimeMilli
+}
+
+func drawFrameStats(frameNum int, frameTimeMs float64) {
+	width, height := ansii.GetTermSize()
+	var spareTimeMilli = millisecondTimeFrame - frameTimeMs
+	os.Stdout.WriteString(string(ansii.Screen.PlaceCursor(ansii.Offset{X: width - 12, Y: height - 2})))
+	os.Stdout.WriteString(fmt.Sprintf("Frame #: %d", frameNum))
+	os.Stdout.WriteString(string(ansii.Screen.PlaceCursor(ansii.Offset{X: width - 19, Y: height - 1})))
+	os.Stdout.WriteString(fmt.Sprintf("Frame Time: %.4fms", frameTimeMs))
+	os.Stdout.WriteString(string(ansii.Screen.PlaceCursor(ansii.Offset{X: width - 20, Y: height})))
+	os.Stdout.WriteString(fmt.Sprintf("Spare Time: %.4fms", spareTimeMilli))
+}
+
+func handleInput(rawInput rune) {
+	action := ProcessInput(rawInput)
+	width, height := ansii.GetTermSize()
+
+	switch action {
+	case Quit:
+		fmt.Println("Quitting...")
+		close(quit)
+	case Left, LeftArrow:
+		playerX = max(playerX-1, 0)
+	case Right, RightArrow:
+		playerX = min(playerX+1, width)
+	case Up, UpArrow:
+		playerY = max(playerY-1, 0)
+	case Down, DownArrow:
+		playerY = min(playerY+1, height)
+	case Unknown:
+	default:
+		os.Stdout.WriteString(string(ansii.Screen.PlaceCursor(ansii.Offset{X: 0, Y: height - 2})))
+		os.Stdout.WriteString("Unrecognized Input: " + string(action))
+		close(quit)
+	}
+}
+
+func waitForFpsLock(startMs float64) {
+	for {
+		var nowMs = float64(time.Now().UnixNano()) / 1_000_000.0
+		if nowMs-startMs >= millisecondTimeFrame {
+			break
+		}
+	}
+}
+
+func doFpsTest() error {
+	prev, err := ansii.MakeTermRaw()
+	if err != nil {
+		return err
+	}
+	defer ansii.RestoreTerm(prev)
+	quit = make(chan bool, 1)
+	userInput = make(chan rune, 1)
+
+	// User input loop
+	go func() {
+		for {
+			buf := make([]byte, 3)
+			n, err := os.Stdin.Read(buf)
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "Error reading from stdin: %v\n", err)
+				continue
+			}
+
+			if n > 0 {
+				if buf[0] == 0x1b { // ESC
+					if n > 1 && buf[1] == '[' { // ESC [
+						switch buf[2] {
+						case 'A':
+							userInput <- '↑' // Up arrow
+						case 'B':
+							userInput <- '↓' // Down arrow
+						case 'C':
+							userInput <- '→' // Right arrow
+						case 'D':
+							userInput <- '←' // Left arrow
+						default:
+							userInput <- '?'
+						}
+					} else {
+						userInput <- '?'
+					}
+				} else {
+					r, _ := utf8.DecodeRune(buf)
+					userInput <- r
+				}
+			}
+		}
+	}()
+	// Rendering loop
+	go func() {
+		for i := 0; i <= 10_000; i++ {
+			startMs := float64(time.Now().UnixNano()) / 1_000_000.0
+			select {
+			case <-quit:
+				return
+			default:
+				frameTimeMs := drawScreen(i, startMs)
+				drawFrameStats(i, frameTimeMs)
+				waitForFpsLock(startMs)
+			}
+		}
+		close(quit)
+	}()
+
+	os.Stdout.WriteString(string(ansii.Screen.HideCursor))
+	defer os.Stdout.WriteString(string(ansii.Screen.ShowCursor))
+	for {
+		select {
+		case <-quit:
+			fmt.Println("Exiting")
+			return nil
+		// case ui := <-userInput:
+		case input := <-userInput:
+			handleInput(input)
+		// fmt.Println(ui)
+		default:
+		}
+	}
 }
diff --git a/internal/renderer/user_inputs.go b/internal/renderer/user_inputs.go
new file mode 100644
index 0000000..f547756
--- /dev/null
+++ b/internal/renderer/user_inputs.go
@@ -0,0 +1,25 @@
+package renderer
+
+type UiAction rune
+
+const (
+	Unknown    UiAction = iota
+	Quit       UiAction = 81 // 'Q'
+	Left       UiAction = 65
+	Up         UiAction = 87
+	Right      UiAction = 68
+	Down       UiAction = 83
+	LeftArrow  UiAction = 8592
+	UpArrow    UiAction = 8593
+	RightArrow UiAction = 8594
+	DownArrow  UiAction = 8595
+)
+
+func ProcessInput(rawInput rune) (action UiAction) {
+	inputVal := int(rawInput)
+	// Convert to UpperCase
+	if inputVal >= 97 && inputVal <= 122 {
+		inputVal = inputVal - 32
+	}
+	return UiAction(inputVal)
+}