diff options
author | Benjamin Chausse <benjamin@chausse.xyz> | 2024-07-11 12:17:02 -0400 |
---|---|---|
committer | Benjamin Chausse <benjamin@chausse.xyz> | 2024-07-11 12:17:02 -0400 |
commit | c6877f2ca4fdd03c4282e1aa3c9b32358c9ad6ad (patch) | |
tree | 0358a4ec4c39860bf6e7acb2173b0762ab2913f9 /internal/render |
Initial Commit
Diffstat (limited to 'internal/render')
-rw-r--r-- | internal/render/instructions.go | 218 | ||||
-rw-r--r-- | internal/render/renderer.go | 167 |
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) + } +} |