summaryrefslogtreecommitdiff
path: root/internal/render
diff options
context:
space:
mode:
authorBenjamin Chausse <benjamin@chausse.xyz>2024-07-11 12:17:02 -0400
committerBenjamin Chausse <benjamin@chausse.xyz>2024-07-11 12:17:02 -0400
commitc6877f2ca4fdd03c4282e1aa3c9b32358c9ad6ad (patch)
tree0358a4ec4c39860bf6e7acb2173b0762ab2913f9 /internal/render
Initial Commit
Diffstat (limited to 'internal/render')
-rw-r--r--internal/render/instructions.go218
-rw-r--r--internal/render/renderer.go167
2 files changed, 385 insertions, 0 deletions
diff --git a/internal/render/instructions.go b/internal/render/instructions.go
new file mode 100644
index 0000000..685f3f8
--- /dev/null
+++ b/internal/render/instructions.go
@@ -0,0 +1,218 @@
+package render
+
+import (
+ "fmt"
+ "io"
+ "strings"
+)
+
+type (
+ trimDir uint8
+ overlapType uint8
+)
+
+const (
+ ToLeft trimDir = iota
+ ToRight
+ None overlapType = iota
+ Covers
+ Within
+ Left
+ Right
+)
+
+type Instruction interface {
+ // Write writes the instruction to the given io.Writer.
+ Write(io.Writer)
+ // Pos returns the x,y position of the instruction.
+ Pos() (int, int)
+ // Len returns the length of the instructions content.
+ Len() int
+ // Trim removes n characters from one side of the instruction.
+ Trim(n int, d trimDir) Instruction
+ // Copy creates a new instruction with the same content and position.
+ Copy() Instruction
+}
+
+func overlap(over, under Instruction) overlapType {
+ ox, oy := over.Pos()
+ ux, uy := under.Pos()
+ os, us := over.Len(), under.Len()
+
+ // wrong row:
+ if oy != uy {
+ return None
+ }
+
+ // totally covers it
+ if ox <= ux && ox+os >= ux+us {
+ return Covers
+ }
+
+ // Overlap on the left:
+ if ox <= ux && ox+os > ux {
+ return Left
+ }
+
+ // Overlap on the right:
+ if ox < ux+us && ox+os >= ux+us {
+ return Right
+ }
+
+ // centered inside:
+ if ox > ux && ox+os < ux+us {
+ return Within
+ }
+
+ // Only other option is not touching
+ return None
+}
+
+// Squash creates new instruction from overlapping instructions.
+// Whenever an instruction would be written over another instruction,
+// the one under is trimmed to only include the part that is not covered.
+// Three cases are possible:
+// - The under instruction is completely covered by the over instruction.
+// - The under instruction is partially covered on one side
+// - The under instruction is covered in the center (splitting it in two)
+// No matter what, the over instruction never changes so only variations of
+// under needs to be returned.
+func squash(over Instruction, under Instruction, ot overlapType) []Instruction {
+ ox, _ := over.Pos()
+ ux, _ := under.Pos()
+ os, us := over.Len(), under.Len()
+ switch ot {
+ case Covers:
+ return []Instruction{}
+ case Left:
+ overlap := ox + os - ux
+ under.Trim(overlap, ToLeft)
+ return []Instruction{under}
+ case Right:
+ overlap := ux + us - ox
+ under.Trim(overlap, ToRight)
+ return []Instruction{under}
+ case Within:
+ overlapL := ox - ux
+ overlapR := ux + us - (ox + os)
+ left := under.Copy().Trim(overlapL, ToLeft)
+ under.Trim(overlapR, ToRight)
+ return []Instruction{left, under}
+ default:
+ return []Instruction{under}
+ }
+}
+
+func reach(i Instruction) string {
+ x, y := i.Pos()
+ return fmt.Sprintf("\033[%d;%dH", y, x)
+}
+
+// DrawInstruction is a struct that contains content the renderer
+// should draw.
+type DrawInstruction struct {
+ // Content is the string that should be drawn.
+ Content string
+ // Pos is the position in the terminal where the content should be drawn.
+ X int
+ Y int
+ // Decorators is a list of ANSI escape codes used to decorate the content.
+ Decorators []string
+}
+
+func (d DrawInstruction) Pos() (int, int) {
+ return d.X, d.Y
+}
+
+func (d DrawInstruction) Len() int {
+ return len(d.Content)
+}
+
+func (d DrawInstruction) Write(w io.Writer) {
+ msg := reach(d) + strings.Join(d.Decorators, "") + d.Content
+ io.WriteString(w, msg)
+}
+
+func (d DrawInstruction) Trim(n int, from trimDir) Instruction {
+ // Prevents having to error check for negative values.
+ if n < 0 {
+ n = -n
+ }
+ // If n is greater than the length of the content, return nil.
+ if n > len(d.Content) {
+ return nil
+ }
+ switch from {
+ case ToLeft:
+ d.Content = d.Content[n:]
+ d.X += n
+ case ToRight:
+ d.Content = d.Content[:len(d.Content)-n]
+ }
+ return &d
+}
+
+func (d DrawInstruction) Copy() Instruction {
+ return &DrawInstruction{
+ Content: d.Content,
+ X: d.X,
+ Y: d.Y,
+ Decorators: d.Decorators,
+ }
+}
+
+// ClearInstruction is a struct that contains content the renderer should
+// clear. they usually accompany a DrawInstruction (i.e. clear the space
+// behind a moving object) but not always.
+type ClearInstruction struct {
+ // Len is the number of consecutive characters to clear.
+ Size int
+ // Pos is the position in the terminal where the content should be cleared.
+ X int
+ Y int
+}
+
+func (c ClearInstruction) Write(w io.Writer) {
+ // Erase as many characters as the size of the instruction.
+ msg := reach(c) + "\033[K" + strings.Repeat(" ", c.Size)
+ // TODO: maybe implement a way to log stuff like writestring errors
+ io.WriteString(w, msg)
+}
+
+func (c ClearInstruction) Pos() (int, int) {
+ return c.X, c.Y
+}
+
+func (c ClearInstruction) Len() int {
+ if c.Size < 0 {
+ return -c.Size
+ }
+ return c.Size
+}
+
+func (c ClearInstruction) Trim(n int, from trimDir) Instruction {
+ // Prevents having to error check for negative values.
+ if n < 0 {
+ n = -n
+ }
+ // If n is greater than the length of the content, return nil.
+ if n > c.Size {
+ return nil
+ }
+ switch from {
+ case ToLeft:
+ c.Size -= n
+ c.X += n
+ case ToRight:
+ c.Size -= n
+ }
+ return &c
+}
+
+func (c ClearInstruction) Copy() Instruction {
+ return &ClearInstruction{
+ Size: c.Size,
+ X: c.X,
+ Y: c.Y,
+ }
+}
diff --git a/internal/render/renderer.go b/internal/render/renderer.go
new file mode 100644
index 0000000..c527413
--- /dev/null
+++ b/internal/render/renderer.go
@@ -0,0 +1,167 @@
+package render
+
+import (
+ "io"
+ "slices"
+ "time"
+)
+
+const (
+ // bufSize how many instruction a buffer should be capable of holding
+ // before blocking. Increase this if you have a lot of simultaneous
+ // instructions, but be aware that this will increase memory usage.
+ bufSize = 9999
+ // how many times per second the renderer should refresh the screen.
+ refreshRate = 60
+)
+
+// Renderer is used to constantly draw onto the terminal.
+// A lazy method is used to draw, where the renderer will only draw
+// parts of the screen which it has been instructed to draw. Also,
+// the renderer draws at a constant rate, so every object on screen
+// is drawn at the same rate/predictably.
+//
+// To achieve this, the renderer uses two channels that essentially
+// act as stacks (they are private so only push/pop methods can be used).
+// Whenever a new frame is to be drawn, the renderer makes copies over
+// every instruction in it's stacks to temporary buffers (to avoid blocking)
+// and proceeds with the drawing.
+type Renderer struct {
+ refreshRate int
+ // both stacks are only externally accessible through the Push method
+ // which ensures each instruction is sent to the correct stack.
+ drwStack chan Instruction
+ clrStack chan Instruction
+ stream io.Writer
+ ticker *time.Ticker
+ done chan bool
+}
+
+func NewRenderer(stream io.Writer) *Renderer {
+ return &Renderer{
+ refreshRate: refreshRate,
+ drwStack: make(chan Instruction, bufSize),
+ clrStack: make(chan Instruction, bufSize),
+ stream: stream,
+ }
+}
+
+func (r *Renderer) Start() {
+ r.ticker = time.NewTicker(time.Duration(float64(time.Second) / float64(r.refreshRate)))
+ r.done = make(chan bool)
+ go func() {
+ for {
+ select {
+ case <-r.done:
+ return
+ case <-r.ticker.C:
+ r.DrawFrame()
+ }
+ }
+ }()
+}
+
+func (r *Renderer) Stop() {
+ r.ticker.Stop()
+ r.done <- true
+ // Reset done channel to allow for a new Start
+ r.done = nil
+}
+
+func (r *Renderer) bufferize(stack chan Instruction) []Instruction {
+ var buf []Instruction
+ for {
+ select {
+ case i := <-stack:
+ buf = append(buf, i)
+ default:
+ return buf
+ }
+ }
+}
+
+func (r *Renderer) Push(i Instruction) {
+ if i == nil {
+ // fmt.Println("nil instruction")
+ return
+ }
+ if r.drwStack == nil || r.clrStack == nil {
+ // fmt.Println("renderer not started")
+ return
+ }
+ // fmt.Printf("Pushing instruction: %T\n", i)
+ switch inst := i.(type) {
+ case DrawInstruction:
+ // fmt.Println("pushing draw")
+ r.drwStack <- inst
+ // fmt.Println("pushed draw")
+ case ClearInstruction:
+ // fmt.Println("pushing clear")
+ r.clrStack <- inst
+ // fmt.Println("pushed clear")
+ default:
+ // fmt.Println("unsupported instruction type")
+ }
+}
+
+// DrawFrame pushes all instructions from the stacks to temporary buffers
+// and proceeds with the drawing. Before doing so, it must sanitize certain
+// components:
+// - If a ClearInstruction overlaps with a DrawInstruction, the ClearInstruction
+// part that overlaps is squashed (resize + move). An example of why this is
+// necessary is if an object is chasing another. The chased object will send
+// instructions to clear it's trail, but if the chaser is faster, it must not
+// get cleared. Otherwise, the chaser would be invisible.
+// - If two DrawInstructions overlap, the latest instruction is prioritized
+// This avoids unnecessary clearing and redrawing.
+func (r *Renderer) DrawFrame() {
+ // NOTE: Buffer indices closer to zero are older instructions
+ // as they were the first pushed to the stack.
+ // Create buffers
+ drwBuf := r.bufferize(r.drwStack)
+ clrBuf := r.bufferize(r.clrStack)
+ // fmt.Println(len(drwBuf))
+ // fmt.Println(len(clrBuf))
+ // squash overlapping clear instructions
+ for _, drw := range drwBuf {
+ for j, clr := range clrBuf {
+ if ot := overlap(drw, clr); ot != None {
+ newClr := squash(drw, clr, ot)
+ switch len(newClr) {
+ case 0: // Complete overlap -> delete the one under
+ clrBuf = slices.Delete(clrBuf, j, j)
+ case 1: // Partial overlap -> resize the one under
+ clrBuf[j] = newClr[0]
+ default: // Over splits the one under in two -> delete and append parts
+ clrBuf = slices.Delete(clrBuf, j, j)
+ clrBuf = append(clrBuf, newClr...)
+ }
+ }
+ }
+ }
+ // squash overlapping draw instructions
+ for i := 0; i < len(drwBuf); i++ {
+ for j := i + 1; j < len(drwBuf); j++ {
+ older, newer := drwBuf[i], drwBuf[j]
+ if ot := overlap(older, newer); ot != None {
+ newDrw := squash(newer, older, ot)
+ switch len(newDrw) {
+ case 0: // Complete overlap -> delete the one under
+ drwBuf = slices.Delete(drwBuf, i, i)
+ case 1: // Partial overlap -> resize the one under
+ drwBuf[i] = newDrw[0]
+ default:
+ drwBuf = slices.Delete(drwBuf, i, i)
+ drwBuf = append(drwBuf, newDrw...)
+ }
+ }
+ }
+ }
+ // Draw
+ for _, clr := range clrBuf {
+ clr.Write(r.stream)
+ }
+ for _, drw := range drwBuf {
+ drw.Write(r.stream)
+ }
+}