Newer
Older
Radon / modules / canvas.lua
@Alyssa May Alyssa May on 9 Jan 2023 27 KB Config editor
--[[
MIT License

Copyright (c) 2022 emmachase

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]

local floor, ceil, min, max, abs, concat = math.floor, math.ceil, math.min, math.max, math.abs, table.concat
local function round(x) return floor(x + 0.5) end

local _ = require("util.score")

---@alias Color
---| 1
---| 2
---| 4
---| 8
---| 16
---| 32
---| 64
---| 128
---| 256
---| 512
---| 1024
---| 2048
---| 4096
---| 8192
---| 16384
---| 32768

local _ttxChars = setmetatable({}, {__index = error})
for i = 0, 31 do
    _ttxChars[i+1] = string.char(128 + i)
end

local _hex = setmetatable({}, {__index = error})
for i = 0, 15 do
    _hex[2^i] = string.format("%x", i)
end

---@alias Terminal table A computercraft terminal object

---@class PixelCanvas
---@field width integer
---@field height integer
---@field canvas { [integer]: { [integer]: integer } }
---@field dirty { [integer]: { [integer]: boolean } }
---@field allDirty boolean?
---@operator call:PixelCanvas
local PixelCanvas = {}
local PixelCanvas_mt = { __index = PixelCanvas }
setmetatable(PixelCanvas, { __call = function(_, ...) return PixelCanvas.new(...) end })

function PixelCanvas.new(width, height)
    local self = setmetatable({__opaque = true}, PixelCanvas_mt)
    self.width = width
    self.height = height
    self.canvas = {}
    for y = 1, height do
        self.canvas[y] = {}
        for x = 1, width do
            self.canvas[y][x] = nil
        end
    end

    self.dirty = {} -- { [y] = { [x] = true } }

    return self
end

---Set the pixel at {x}, {y} to {c}.
---@param x integer
---@param y integer
---@param c Color
function PixelCanvas:setPixel(x, y, c)
    x, y = floor(x), floor(y)
    -- TODO: remove bounds checking?
    if x < 1 or x > self.width or y < 1 or y > self.height then
        return
    end

    if self.canvas[y][x] ~= c then
        self.canvas[y][x] = c
        self.dirty[y] = self.dirty[y] or {}
        self.dirty[y][x] = true
    end
end

function PixelCanvas:clone()
    local clone = PixelCanvas.new(self.width, self.height)
    for y = 1, self.height do
        clone.dirty[y] = {}
        for x = 1, self.width do
            clone.canvas[y][x] = self.canvas[y][x]
            clone.dirty[y][x] = true
        end
    end

    return clone
end

function PixelCanvas:mapColors(map)
    for y = 1, self.height do
        for x = 1, self.width do
            self.canvas[y][x] = map[self.canvas[y][x]] or self.canvas[y][x]
        end
    end

    return self
end

---Copies the contents of {canvas} into this canvas at {x, y}.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
function PixelCanvas:drawCanvas(canvas, x, y, remapFrom, remapTo)
    for cy = 1, canvas.height do
        for cx = 1, canvas.width do
            local c = canvas.canvas[cy][cx]
            if c == remapFrom then
                if type(remapTo) == "table" then error() end
                c = remapTo
            end

            if c then -- TODO: document this
                self:setPixel(x + cx - 1, y + cy - 1, c)
            end
        end
    end
end

function PixelCanvas:drawCanvas180(canvas, x, y)
    for cy = 1, canvas.height do
        for cx = 1, canvas.width do
            self:setPixel(x + cx - 1, y + canvas.height - cy - 1, canvas.canvas[cy][cx])
        end
    end
end

function PixelCanvas:drawCanvasRotated(canvas, cx, cy, angle)
    local sin, cos = math.sin(angle), math.cos(angle)
    local absSin, absCos = math.abs(sin), math.abs(cos)
    local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos)

    for y = 1, newHeight do
        for x = 1, newWidth do
            local px, py = x - newWidth / 2, y - newHeight / 2
            local rx, ry = px * cos - py * sin, px * sin + py * cos
            rx, ry = rx + canvas.width / 2, ry + canvas.height / 2
            rx, ry = round(rx), round(ry)
            if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then
                self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, canvas.canvas[ry][rx])
            end
        end
    end
end

---Copies the contents of {canvas} into this canvas at {x, y} with color {c}.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
---@param c integer
function PixelCanvas:drawTint(canvas, x, y, c)
    for cy = 1, canvas.height do
        for cx = 1, canvas.width do
            if canvas.canvas[cy][cx] then
                self:setPixel(x + cx - 1, y + cy - 1, c)
            end
        end
    end
end

---Copies the contents of {canvas} into this canvas at {x, y} with the specified bounds.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
---@param startX integer
---@param startY integer
---@param endX integer
---@param endY integer
function PixelCanvas:drawCanvasClip(canvas, x, y, startX, startY, endX, endY)
    for cy = startY, endY do
        for cx = startX, endX do
            self:setPixel(x + cx - startX, y + cy - startY, canvas.canvas[cy][cx])
        end
    end
end

function PixelCanvas:drawRect(c, x, y, w, h)
    for cy = 1, h do
        for cx = 1, w do
            self:setPixel(x + cx - 1, y + cy - 1, c)
        end
    end
end

---Mark the pixel at {x, y} as dirty, indicating that it should be updated during frame composition.
---@param x integer
---@param y integer
function PixelCanvas:mark(x, y)
    x, y = floor(x), floor(y)
    -- TODO: remove bounds checking?
    if x < 1 or x > self.width or y < 1 or y > self.height then
        return
    end

    self.dirty[y] = self.dirty[y] or {}
    self.dirty[y][x] = true
end

---@param x integer
---@param y integer
---@param width integer
---@param height integer
function PixelCanvas:markRect(x, y, width, height)
    x, y = floor(x), floor(y)
    -- TODO: remove bounds checking?

    for cy = y, y + height - 1 do
        if cy < 1 or cy > self.height then
            -- out of bounds
        else
            self.dirty[cy] = self.dirty[cy] or {}
            for cx = x, x + width - 1 do
                if cx < 1 or cx > self.width then
                    -- out of bounds
                else
                    self.canvas[cy][cx] = nil
                    self.dirty[cy][cx] = true
                end
            end
        end
    end
end

---@param x integer
---@param y integer
---@param width integer
---@param height integer
function PixelCanvas:dirtyRect(x, y, width, height)
    x, y = floor(x), floor(y)
    -- TODO: remove bounds checking?

    for cy = y, y + height - 1 do
        if cy < 1 or cy > self.height then
            -- out of bounds
        else
            self.dirty[cy] = self.dirty[cy] or {}
            for cx = x, x + width - 1 do
                if cx < 1 or cx > self.width then
                    -- out of bounds
                else
                    self.dirty[cy][cx] = true
                end
            end
        end
    end
end

---Mark the rectangle formed by the given canvas at {x, y} as dirty.
---@param canvas PixelCanvas
---@param x integer
---@param y integer
function PixelCanvas:markCanvas(canvas, x, y)
    for cy = 1, canvas.height do
        for cx = 1, canvas.width do
            self:setPixel(x + cx - 1, y + cy - 1, nil)
        end
    end
end

function PixelCanvas:markCanvasRotated(canvas, cx, cy, angle)
    local sin, cos = math.sin(angle), math.cos(angle)
    local absSin, absCos = math.abs(sin), math.abs(cos)
    local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos)

    for y = 1, newHeight do
        for x = 1, newWidth do
            local px, py = x - newWidth / 2, y - newHeight / 2
            local rx, ry = px * cos - py * sin, px * sin + py * cos
            rx, ry = rx + canvas.width / 2, ry + canvas.height / 2
            rx, ry = round(rx), round(ry)
            if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then
                self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, nil)
            end
        end
    end
end

---Creates a new PixelCanvas with the same width and height as this canvas.
---@return PixelCanvas
function PixelCanvas:newFromSize()
    return PixelCanvas.new(self.width, self.height)
end

function PixelCanvas.is(obj)
    return getmetatable(obj) == PixelCanvas_mt
end

---@param others { [1]: PixelCanvas, [2]: integer, [3]: integer }[]
function PixelCanvas:composite(others)
    for _, other in ipairs(others) do
        self:drawCanvas(other[1], other[2], other[3])
    end
end

---@class TextCanvas
---@field width number
---@field height number
---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string } } }
---@field dirty { [integer]: { [integer]: boolean } }
---@operator call:PixelCanvas
local TextCanvas = {}
local TextCanvas_mt = { __index = TextCanvas }
setmetatable(TextCanvas, { __call = function(_, ...) return TextCanvas.new(...) end })

function TextCanvas.new(width, height)
    local self = setmetatable({__opaque = true}, TextCanvas_mt)
    self.width = width
    self.height = height
    self.brand = "TextCanvas"
    self.canvas = {}
    for y = 1, height do
        self.canvas[y] = {}
        self.canvas[y].t = {}
        self.canvas[y].c = {}
        self.canvas[y].b = {}
        -- for x = 1, width do
        --     [x] = nil
        -- end
    end

    self.dirty = {} -- { [y] = { [x] = true } }

    return self
end

---Write a string to the canvas at {x, y}.
---@param text string
---@param x integer
---@param y integer
---@param c integer
---@param b integer
function TextCanvas:write(text, x, y, c, b)
    x, y = floor(x), floor(y)
    if x < 1 or x > self.width or y < 1 or y > self.height then
        return
    end

    c = _hex[c]
    b = _hex[b]

    self.dirty[y] = self.dirty[y] or {}
    for i = 1, #text do
        if x + i - 1 > self.width then
            break
        end

        self.dirty[y][x + i - 1] = true
        self.canvas[y].t[x + i - 1] = text:sub(i, i)
        self.canvas[y].c[x + i - 1] = c
        self.canvas[y].b[x + i - 1] = b
    end
end

---Mark a string to the canvas at {x, y}.
---@param text string
---@param x integer
---@param y integer
function TextCanvas:markText(text, x, y)
    x, y = floor(x), floor(y)
    if x < 1 or x > self.width or y < 1 or y > self.height then
        return
    end

    self.dirty[y] = self.dirty[y] or {}
    for i = 1, #text do
        if x + i - 1 > self.width then
            break
        end

        self.dirty[y][x + i - 1] = true
        self.canvas[y].t[x + i - 1] = nil
    end
end

function TextCanvas.is(self)
    return getmetatable(self) == TextCanvas_mt
end

---@class TeletextCanvas
---@field width integer
---@field height integer
---@field clear integer The color to use when a pixel is transparent.
---@field pixelCanvas PixelCanvas
---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string }, direct: { [integer]: boolean } } }
---@field dirty { [integer]: { [integer]: boolean } }
---@field dirtyRows { [integer]: { [1]: integer, [2]: integer } }
---@operator call:TeletextCanvas
local TeletextCanvas = {}
local TeletextCanvas_mt = { __index = TeletextCanvas }
setmetatable(TeletextCanvas, { __call = function(_, ...) return TeletextCanvas.new(...) end })

function TeletextCanvas.new(clear, width, height)
    local self = setmetatable({__opaque = true}, TeletextCanvas_mt)
    self.width = width
    self.height = height
    self.clear = clear or colors.black

    self.pixelCanvas = PixelCanvas(width*2, height*3)

    self.canvas = {}
    for y = 1, height do
        self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} }
        for x = 1, width do
            self.canvas[y].t[x] = " "
            self.canvas[y].c[x] = "0"
            self.canvas[y].b[x] = _hex[clear]
            self.canvas[y].direct[x] = false
        end
    end

    self.dirty = {} -- { [y] = { [x] = true } }
    self.dirtyRows = {}

    return self
end

function TeletextCanvas:reset(clear)
    for y = 1, self.height do
        self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} }
        for x = 1, self.width do
            self.canvas[y].t[x] = " "
            self.canvas[y].c[x] = "0"
            self.canvas[y].b[x] = _hex[clear]
            self.canvas[y].direct[x] = false
        end
    end
    self.dirty = {} -- { [y] = { [x] = true } }
    self.dirtyRows = {}
end

---Composites the given pixel canvases onto the teletext's internal 
---pixel canvas and recomputes the teletext's character data.
---@param ... { [1]: PixelCanvas, [2]: integer, [3]: integer }
function TeletextCanvas:composite(...)
    local others = {...}

    -- Partition the screen based on canvas x/y/w/h
    -- local t1 = os.epoch("utc")
    local partitionSize = 20
    local partitions = {}
    for y = 1, self.height*3, partitionSize do
        partitions[ceil(y/partitionSize)] = {}
        for x = 1, self.width*2, partitionSize do
            partitions[ceil(y/partitionSize)][ceil(x/partitionSize)] = {}
        end
    end
    -- local t2 = os.epoch("utc")
    -- print("Partitioning took " .. (t2-t1) .. "ms")

    -- t1 = os.epoch("utc")
    for _, other in ipairs(others) do
        other[2] = floor(other[2])
        other[3] = floor(other[3])
        -- if PixelCanvas.is(other) then
        local otherCanvas, otherX, otherY = other[1], other[2], other[3]

        local originPartitionX = (ceil(otherX/partitionSize) - 1)*partitionSize + 1
        local originPartitionY = (ceil(otherY/partitionSize) - 1)*partitionSize + 1

        --TODO: This is a hack, should use -1 instead of +1
        for y = originPartitionY, otherY+otherCanvas.height+1, partitionSize do
            for x = originPartitionX, otherX+otherCanvas.width+1, partitionSize do
                local px = ceil(x/partitionSize)
                local py = ceil(y/partitionSize)
                local partition = (partitions[py] or {})[px]
                if partition then
                    partition[#partition + 1] = other
                end
            end
        end
        -- end
    end
    -- t2 = os.epoch("utc")
    -- print("Canvas partition assignment took " .. (t2-t1) .. "ms")

    -- t1 = os.epoch("utc")
    local queuedDirty = {}
    local c = 0
    for _, other in ipairs(others) do
        
        local ocanvas, ox, oy = other[1], other[2]-1, other[3]-1
        local isPixel = ocanvas.brand ~= "TextCanvas"
        if ocanvas.allDirty then
            for y = 1, ocanvas.height do
                for x = 1, ocanvas.width do
                    if isPixel then
                        local tx = x+ox
                        local ty = y+oy
                        if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
                            queuedDirty[ty] = queuedDirty[ty] or {}
                            queuedDirty[ty][tx] = true
                            c = c + 1
                        end
                    else
                        local tx = x*2-1 + ox
                        local ty = y*3-2 + oy
                        -- print(ty)
                        if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
                            queuedDirty[ty] = queuedDirty[ty] or {}
                            queuedDirty[ty][tx] = true
                            c = c + 1
                        end
                    end
                end
            end
        else
            for y, row in pairs(ocanvas.dirty) do
                for x, _ in pairs(row) do
                    if isPixel then
                        local tx = x+ox
                        local ty = y+oy
                        if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
                            queuedDirty[ty] = queuedDirty[ty] or {}
                            queuedDirty[ty][tx] = true
                            c = c + 1
                        end
                    else
                        local tx = x*2-1 + ox
                        local ty = y*3-2 + oy
                        -- print(ty)
                        if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then
                            queuedDirty[ty] = queuedDirty[ty] or {}
                            queuedDirty[ty][tx] = true
                            c = c + 1
                        end
                        -- print(y)
                        -- for gayY = y-10,y+10 do
                        --     if gayY > 1 and gayY <= #ocanvas.dirty then
                        --         for gayX = x-10,x+10 do
                        --             if gayX > 1 and gayX <= #row then
                        --                 queuedDirty[y*3] = queuedDirty[y*3] or {}
                        --                 queuedDirty[y*3][x*2] = true
                        --                 queuedDirty[y*3][x*2-1] = true
                        --                 queuedDirty[y*3-1] = queuedDirty[y*3-1] or {}
                        --                 queuedDirty[y*3-1][x*2] = true
                        --                 queuedDirty[y*3-1][x*2-1] = true
                        --                 queuedDirty[y*3-2] = queuedDirty[y*3-2] or {}
                        --                 queuedDirty[y*3-2][x*2] = true
                        --                 queuedDirty[y*3-2][x*2-1] = true
                        --             end
                        --         end
                        --     end
                        -- end
                    end

                    -- else
                    --     -- TODO: ewwwwwwww
                        
                    -- end
                end
            end
        end

        ocanvas.dirty = {} -- TODO: is this necessary?
    end
    -- t2 = os.epoch("utc")

    -- t1 = os.epoch("utc")
    for y, row in pairs(queuedDirty) do
        local targetY = ceil(y / 3)
        self.dirty[targetY] = self.dirty[targetY] or {}
        -- print("row size: " .. #row)
        for x, _ in pairs(row) do
            local targetX = ceil(x / 2)
            local currPixel = self.pixelCanvas.canvas[y][x]
            partitionY = ceil(y/partitionSize) --math.min(floor(y/partitionSize)+1, #partitions)
            partitionX = ceil(x/partitionSize) --math.min(floor(x/partitionSize)+1, #partitions[1])
            --print("getting partition (partizion size " .. partitionSize .. ") at x: " .. x .. ", y: " .. y .. " -> ".. floor(x/partitionSize)+1 .. ", " .. floor(y/partitionSize)+1)
            --print("Partitions size: " .. #partitions[1] .. ", " .. #partitions)
            local partition = partitions[partitionY][partitionX]

            local found = false
            -- local foundText = false
            for i = #partition, 1, -1 do
                local other = partition[i]
                local otherCanvas, ox, oy = other[1], other[2], other[3]
                if PixelCanvas.is(otherCanvas) then ---@cast other PixelCanvas
                    t1 = os.epoch("utc")
                    if otherCanvas.canvas[y-oy+1] then
                        local otherPixel = (otherCanvas.canvas[y-oy+1] or {})[x-ox+1]
                        if otherPixel then
                            found = true

                            if otherPixel ~= currPixel then
                                self.pixelCanvas.canvas[y][x] = otherPixel
                                self.dirty[targetY][targetX] = true
                            end

                            if self.canvas[targetY].direct[targetX] then
                                self.canvas[targetY].direct[targetX] = false
                                self.dirty[targetY][targetX] = true
                            end
                            break
                        end
                    end
                t2 = os.epoch("utc")
                else ---@cast other TextCanvas
                    -- print( targetX .. ", " .. targetY .. ": " .. ox .. ", " .. oy .. " -> " .. x .. ", " .. y)
                    local ty = ceil((y-oy+1)/3) --ceil(targetY - oy / 3)
                    local tx = ceil((x-ox+1)/2) --ceil(targetX - ox / 2)
                    -- print(tx .. " " .. ty)
                    -- if ty == 1 then
                        -- print("fuck")
                        -- print(#otherCanvas.canvas)
                        -- print(#otherCanvas.canvas[ty].t)
                        -- sleep(1)
                    -- end
                    if otherCanvas.canvas[ty] and otherCanvas.canvas[ty].c[tx] then
                        -- print("found text")
                        local otherRow = otherCanvas.canvas[ty]
                        local otherT = otherRow.t[tx]
                        local otherC = otherRow.c[tx]
                        local otherB = otherRow.b[tx]
                        if otherT then
                            found = true
                            foundText = true

                            local currRow = self.canvas[targetY]
                            local currT = currRow.t[targetX]
                            local currC = currRow.c[targetX]
                            local currB = currRow.b[targetX]

                            if otherT ~= currT or otherC ~= currC or otherB ~= currB then
                                currRow.t[targetX] = otherT
                                currRow.c[targetX] = otherC
                                currRow.b[targetX] = otherB
                                currRow.direct[targetX] = true
                                self.dirty[targetY][targetX] = false -- Already processed

                                local dirtyRow = self.dirtyRows[targetY] or {}
                                local minX, maxX = dirtyRow[1], dirtyRow[2]
                                minX = minX and min(minX, targetX) or targetX
                                maxX = maxX and max(maxX, targetX) or targetX
                                self.dirtyRows[targetY] = { minX, maxX }
                            end

                            break
                        else
                            local currRow = self.canvas[targetY]
                            currRow.direct[targetX] = false
                        end
                    else
                        -- local currRow = self.canvas[targetY]
                        -- currRow.direct[targetX] = false
                    end
                end
            end

            if not found then
                self.pixelCanvas.canvas[y][x] = self.clear
                self.dirty[targetY][targetX] = true
                self.canvas[targetY].direct[targetX] = false
            end

            -- if not foundText then
            --     if self.canvas[targetY].direct[targetX] then
            --         self.canvas[targetY].direct[targetX] = false
            --         self.dirty[targetY][targetX] = true
            --     end
            -- end
        end
    end
    -- t2 = os.epoch("utc")
    -- print("Canvas merging took " .. (t2-t1) .. "ms")

    -- Recalculate teletext canvas
    local clear = self.clear
    for y, row in pairs(self.dirty) do
        local oy = (y - 1) * 3

        local dirtyRow = self.dirtyRows[y] or {}
        local minX, maxX = dirtyRow[1], dirtyRow[2]
        for x, _ in pairs(row) do
            minX = minX and min(minX, x) or x
            maxX = maxX and max(maxX, x) or x

            local ox = (x - 1) * 2
            local sub, char, c1, c2, c3, c4, c5, c6 = 32768, 1,
                self.pixelCanvas.canvas[oy + 1][ox + 1] or clear,
                self.pixelCanvas.canvas[oy + 1][ox + 2] or clear,
                self.pixelCanvas.canvas[oy + 2][ox + 1] or clear,
                self.pixelCanvas.canvas[oy + 2][ox + 2] or clear,
                self.pixelCanvas.canvas[oy + 3][ox + 1] or clear,
                self.pixelCanvas.canvas[oy + 3][ox + 2] or clear

            if c1 ~= c6 then
                sub = c1
                char = 2
            end
            if c2 ~= c6 then
                sub = c2
                char = char + 2
            end
            if c3 ~= c6 then
                sub = c3
                char = char + 4
            end
            if c4 ~= c6 then
                sub = c4
                char = char + 8
            end
            if c5 ~= c6 then
                sub = c5
                char = char + 16
            end

            if self.canvas[y].direct[x] == false then
                self.canvas[y].t[x] = _ttxChars[char]
                self.canvas[y].c[x] = _hex[sub]
                self.canvas[y].b[x] = _hex[c6]
            end
        end

        self.dirtyRows[y] = { minX, maxX }
    end
end

---Output any dirty rows to the given {out} terminal.
---@param out Terminal
function TeletextCanvas:outputDirty(out)
    for y, row in pairs(self.dirtyRows) do
        local minX, maxX = row[1], row[2]
        if minX then
            local t,c,b
            if maxX - minX == 0 then
                t = self.canvas[y].t[minX]
                c = self.canvas[y].c[minX]
                b = self.canvas[y].b[minX]
            else
                t = concat(self.canvas[y].t, "", minX, maxX)
                c = concat(self.canvas[y].c, "", minX, maxX)
                b = concat(self.canvas[y].b, "", minX, maxX)
            end

            out.setCursorPos(minX, y)
            out.blit(t, c, b)
        end
    end

    self.dirty = {}
    self.dirtyRows = {}
end

---Output the entire canvas to the given {out} terminal.
---@param out Terminal
function TeletextCanvas:outputFlush(out)
    for y = 1, self.height do
        local t = concat(self.canvas[y].t, "")
        local c = concat(self.canvas[y].c, "")
        local b = concat(self.canvas[y].b, "")

        out.setCursorPos(1, y)
        out.blit(t, c, b)
    end
end

return {
    PixelCanvas = PixelCanvas,
    TeletextCanvas = TeletextCanvas,
    TextCanvas = TextCanvas,
}