Redirecting print() to the viewer

(Latest update) The code below redirects print() output from the output pane to one or more panes on the viewer. The code is too long to include in a single comment, and has been divided into three parts. An example of its use follows the code itself.

Inspiration came from Codea written in Codea by @Simeon.

This version includes extra functions suggested and first implemented by @KilamMalik. Early versions of the code were first discussed here.

Part 1/3 of the code:

--
-- Redirect, version 2012.09.18.21.30
--
-- Simple use:
-- print.redirect.font(name, size) to set the font used, before
--     first use
-- print.redirect.on() to turn redirection on
-- print.redirect.off() to turn it off
--
-- Complex use:
-- index = print.redirect.addPane(left, base, width, height, title)
-- print.redirect.removePane(index)
-- print.redirect.currentPane(index)
--
-- Works with Codea's 'dependencies'.

local defaultFont = "Inconsolata"
local defaultFontSize = 17
local stream = false
local pane = {}
local cPane = nil
local paneCount = 0

------------------------------------------------------------------------
-- CloseButton class
-- Handles a close button
------------------------------------------------------------------------
local CloseButton = class()

function CloseButton:init(left, base, size, onTouch, parent)
    self.rect = {
        l = left,
        b = base,
        w = size,
        h = size,
        r = left + size,
        t = base + size
    }
    self.onTouch = onTouch
    self.parent = parent
    return self
end

function CloseButton:draw()
    pushStyle()
    noStroke()
    local l = self.rect.l
    local b = self.rect.b
    local w = self.rect.w
    local h = self.rect.h
    local x = l + w/2
    local y = b + h/2
    fill(0)
    ellipse(x, y, 16, 16)
    fill(255, 0, 0)
    ellipse(x, y, 14, 14)
    fill(255)
    ellipse(x, y + 4, 7, 4)
    fill(255, 127)
    ellipse(x, y - 4, 9, 6)
    popStyle()
end

function CloseButton:touched(touch)
    local state = touch.state
    local x = touch.x
    local y = touch.y
    local l = self.rect.l
    local r = self.rect.r
    local b = self.rect.b
    local t = self.rect.t
    if x >= l and x <= r and y >= b and y <= t then
        if state == BEGAN then
            if self.onTouch then self.onTouch() end
        end
        return true
    end
    return false
end

------------------------------------------------------------------------
-- TitleBar class
-- Handles a title bar
------------------------------------------------------------------------
local TitleBar = class()

function TitleBar:init(left, base, width, height, title, onClose,
    parent)
    self.rect = {
        l = left,
        b = base,
        w = width,
        h = height,
        r = left + width,
        t = base + height
    }
    self.title = title
    self.cb = CloseButton(left, base, height, onClose, self)
    self.parent = parent
    return self
end

function TitleBar:draw()
    local l = self.rect.l
    local b = self.rect.b
    local w = self.rect.w
    local h = self.rect.h
    pushStyle()
    noStroke()
    fill(127, 127)
    rectMode(CORNER)
    rect(l, b, w, h)
    font("Inconsolata")
    fontSize(17)
    textMode(CORNER)
    textAlign(LEFT)
    textWrapWidth(0)
    fill(255)
    text(self.title, l + self.cb.rect.w, self.rect.t - self.cb.rect.h)
    popStyle()
    self.cb:draw()
end

function TitleBar:touched(touch)
    return self.cb:touched(touch)
end

------------------------------------------------------------------------
-- VSBar class
-- Handles a vertical scroll bar
------------------------------------------------------------------------
local VSBar = class()

function VSBar:init(left, base, width, height, min, max, value, length,
    onChanged, parent)
    self.rect = {
        l = left,
        b = base,
        w = width, 
        h = height,    
        r = left + width,
        t = base + height
    }
    self.min = min
    self.max = max
    self.value = value
    self.length = length
    self.onChanged = onChanged
    self.parent = parent
    self.visible = false
    self.touchId = nil
    self.d = 8    
    return self
end

function VSBar:visible(...)
    local n = arg["n"]
    if n > 0 then
        local isVisible = arg[1]
        self.visible = isVisible
    end
    return self.visible
end

function VSBar:draw()
    pushStyle()
    fill(127, 127)
    rectMode(CORNER)
    rect(self.rect.l, self.rect.b, self.rect.w, self.rect.h)
    if not self.visible then return end
    local l = self.length/(self.max - self.min + 1) * 
        (self.rect.h - self.d)
    fill(127, 255)
    local t = self.rect.t - self.d/2 - 
        (self.value - self.min)/(self.max - self.min) * 
        (self.rect.h - self.d)
    local x = self.rect.r - self.d - 1
    rect(x, t - l, self.d, l)
    ellipse(x + self.d/2, t, self.d)
    ellipse(x + self.d/2, t - l, self.d)
    popStyle()
end

function VSBar:touched(touch)
    local state = touch.state
    local id = touch.id
    local x = touch.x
    local y = touch.y
    local l = self.rect.l
    local b = self.rect.b
    local r = self.rect.r
    local t = self.rect.t
    if state == BEGAN then
        if x >= l and x <= r and y >= b and y<= t then
            if not self.touchId then
                self.touchId = id
                self.oy = y
                self.ov = self.value
                self.visible = true                
            end
            return true
        end
        return false
    end
    if state == MOVING then
        if id == self.touchId then
            local dv = -math.floor(
                (y - self.oy)/(self.rect.h - self.d) *
                    (self.max - self.min))
            local v = self.ov + dv
            local v = math.max(self.min, v)
            local v = math.min(self.max - self.length, v)
            if v ~= self.value then
                self.value = v
                self.onChanged(self)
            end
            return true
        end
        return false
    end
    if id == self.touchId then
        self.touchId = nil
        self.oy = nil
        self.ov = nil
        self.visible = false
        return true
    else
        return false
    end
end

One of my concerns has been polluting Codea’s global namespace. In version 2012.07.17.21.30, I have moved the functionality into print itself, rather than have a new global variable redirect.

Part 2/3 of the code:


------------------------------------------------------------------------
-- TextBox class
-- Handles a text box
------------------------------------------------------------------------
local TextBox = class()

function TextBox:init(left, base, width, height, fName, fSize, onScroll)    
    self.rect = {              -- View pane rectangle
        l = left,
        b = base,
        w = width,
        h = height,
        r = left + width,
        t = base + height
    }
    self.outputFont = fName
    self.outputFontSize = fSize
    self.onScroll = onScroll
    self.firstLine = 0
    self.heightInLines = 1
    self.lineHeight = 0
    self.output = {}    
    return self
end

-- A private function to assist with setting the line height
function TextBox:setLineHeight()
    local _ = nil
    pushStyle()
    font(self.outputFont)
    fontSize(self.outputFontSize)
    _, self.lineHeight = textSize("text")
    popStyle()
    self.heightInLines = math.floor(self.rect.h/self.lineHeight)
end

-- A private function to assist with setting the first line on
-- screen
function TextBox:resetFirstLine()
    if #self.output > self.heightInLines then
        self.firstLine = #self.output - self.heightInLines
    end
end

-- A private function to check if a string 's' will fit if rendered to
-- the screen
function TextBox:tooWide(s)
    pushStyle()
    font(self.outputFont)
    fontSize(self.outputFontSize)
    local tooWide = textSize(s) > self.rect.w
    popStyle()
    return tooWide
end

-- A private function to write out a string 'l' with wrapping. It
-- returns any tail that does not fit.
function TextBox:write(l)
    local p = #l
    while (self:tooWide(string.sub(l, 1, p))) do
        p = p - 1
    end
    self.output[#self.output + 1] = string.sub(l, 1, p)
    if #self.output > self.heightInLines then
        self:onScroll()
    end
    return string.sub(l, p + 1)
end

-- A private function to process a string 's' that may contain new
-- line (\
) or tab (\\t) characters
function TextBox:process(s)
    if self.lineHeight == 0 then self:setLineHeight() end
    -- Separate s into lines 'l' ending in \

    for l in string.gmatch(s, "([^\
]*)\
") do
        local b = ""
        local p1 = 1
        while p1 <= #l do
            if string.sub(l, p1, p1)=="\\t" then
                local temp = b..string.rep(" ", 8 - #b % 8)
                if self:tooWide(temp) then
                    self:write(b)
                else
                    b = temp
                end
                p1 = p1 + 1                    
            else
                local p2
                p1, p2 = string.find(l, "[^\\t]+", p1)
                b = b..string.sub(l, p1, p2)
                while self:tooWide(b) do
                    b = self:write(b)
                end
                p1 = p2  + 1
            end
        end
        self:write(b)
    end
end

function TextBox:draw()
    pushStyle()
    rectMode(CORNER)
    noStroke()  
    font(self.outputFont)
    fontSize(self.outputFontSize)
    textMode(CORNER)
    textAlign(LEFT)
    textWrapWidth(0)
    local h = self.rect.t
    local fli = self.firstLine + 1
    local lli = math.min(
        #self.output, self.firstLine + self.heightInLines)
    for j = fli, lli  do
        local l = self.output[j]
        h = h - self.lineHeight
        fill(0, 127)
        text(l, 1 + self.rect.l, h - 1)
        fill(255)
        text(l, self.rect.l, h)
    end        
end

------------------------------------------------------------------------
-- Pane class
-- Handles output to a rectangular region on the viewer
------------------------------------------------------------------------
local Pane = class()

function Pane:init(left, base, width, height, title, fName, fSize)
    self.showOutput = false
    self.rect = {              -- View pane rectangle
        l = left,
        b = base,
        w = width,
        h = height,
        r = left + width,
        t = base + height
    }
    self.background = 0             -- Opacity of view pane
    local tbh = 20
    self.tb = TitleBar(left, self.rect.t - tbh, width, tbh,
        title, function () self:visible(false) end, self)
    self.box = TextBox(left, base, width, height - tbh, fName, fSize,
    function (sender)
        self.vsb = VSBar(self.rect.r - 10, self.rect.b,
            10, self.rect.h - self.tb.rect.h,
        0, #sender.output, sender.firstLine, sender.heightInLines, 
        function (sender)
            self.box.firstLine = sender.value
        end,
        self)
    end)
    return self
end

function Pane:process(s)
    if self.box then
        self.box:process(s)
        self.box:resetFirstLine()
    end
end

function Pane:draw()
    if not self.showOutput then return end   
    if self.box then self.box:draw() end
    if self.tb then self.tb:draw() end
    if self.vsb then self.vsb:draw() end end

function Pane:touched(touch)
    local touchHandled = false
    local id = touch.id
    if not self.vsb or (self.vsb and id ~= self.vsb.touchId) then
        touchHandled = self.tb:touched(touch)
    end
    if not touchHandled and self.vsb then
        touchHandled = self.vsb:touched(touch)
    end
    return touchHandled   
end

function Pane:visible(...)
    local n = arg["n"]
    if n > 0 then
        local isVisible = arg[1]
        self.showOutput = isVisible
    end
    return self.showOutput
end

Part 3/3 of the code:


------------------------------------------------------------------------
-- Overwrite print
------------------------------------------------------------------------
-- A private function to clean up a string 's'
local function clean(s)
    -- Remove all control characters other than carriage returns (\\r),
    -- new lines (\
) and tabs (\\t)
    s = string.gsub(s, "[^%C\\r\
\\t]", "")
    -- Replace \\r\
 by \
, and eliminate any leading spaces
    s = string.gsub(s, "[% \\t]*\\r\
", "\
")
    -- Replace \
\\r by \
, and eliminate any leading spaces
    s = string.gsub(s, "[% \\t]*\
\\r", "\
")
    -- Replace remaining \\r by \

    s = string.gsub(s, "\\r", "\
")
    -- Eliminate any leading spaces before \

    s = string.gsub(s, "[% \\t]*\
", "\
")
    return s
end

local oldprint = print
print = {}
setmetatable(print, print)
print.__call = function (self, ...)
    if not stream then oldprint(unpack(arg)) return end
    local l = ""
    for i, v in ipairs(arg) do
        if i > 1 then
            l = l.."\\t"
        end
        l = l .. tostring(v)
    end
    l = clean(l).."\
"
    pane[cPane]:process(l)    
    pane[cPane]:visible(true)   
end

------------------------------------------------------------------------
-- Initialise overwrite of draw() and touched()
------------------------------------------------------------------------
local isInitialised = false
local function init()
    if isInitialised then return end
    isInitialised = true

-- Overwrite draw
    local olddraw = draw
    draw = function ()
        olddraw()
        for i, p in pairs(pane) do
            p:draw()
        end
    end

-- Overwrite touched
    local oldtouched = touched
    touched = function (touch)
        local touchHandled = false
        for i, p in pairs(pane) do
            touchHandled = p:touched(touch)
            if touchHandled then break end
        end    
        if oldtouched and not touchHandled then oldtouched(touch) end       
    end
end

-----------------------------------------------------------------------

local font = function (fName, fSize)
    if not fName then fName = "Inconsolata" end
    if not fSize then fSize = 17 end    
    defaultFont = fName
    defaultFontSize = fSize
    return defaultFont, defaultFontSize
end

local function addPane(...)
    paneCount = paneCount + 1
    local newPane = nil
    local n = arg["n"]
    if n == 0 then
        newPane = Pane(0, 0, WIDTH, HEIGHT,
            "Output", defaultFont, defaultFontSize)
    elseif n == 1 then
        local size = arg[1]
        newPane = Pane((WIDTH - size)/2, (HEIGHT - size)/2, size, size,
            "Output", defaultFont, defaultFontSize)
    elseif n == 2 then
        local w = arg[1]
        local h = arg[2]
        newPane = Pane((WIDTH - w)/2, (HEIGHT - h)/2, w, h,
            "Output", defaultFont, defaultFontSize)
    elseif n == 3 then
        local size = arg[1]
        local cx = arg[2]
        local cy = arg[3]
        newPane = Pane(cx - size/2, cy - size/2, size, size,
            "Output", defaultFont, defaultFontSize)
    elseif n == 4 then
        local l = arg[1]
        local b = arg[2]
        local w = arg[3]
        local h = arg[4]
        newPane = Pane(l, b, w, h,
            "Output", defaultFont, defaultFontSize)
    else
        local l = arg[1]
        local b = arg[2]
        local w = arg[3]
        local h = arg[4]
        local title = arg[5]
        newPane = Pane(l, b, w, h,
            title, defaultFont, defaultFontSize) 
    end
    table.insert(pane, paneCount, newPane)
    return paneCount
end

local function removePane(index)
    if pane[index] then
        pane[index] = nil
        if cPane == index then
            cPane = nil
        end
        return true
    end
    return false
end

local function panes()
    local panes = {}
    for i in pairs(pane) do
        table.insert(panes, i)
    end
    return panes
end

local function currentPane(...)
    local n = arg["n"]
    if n > 0 then
        local index = arg[1]
        if pane[index] then
            cPane = index
        else
            return nil
        end
    end
    return cPane
end

local on = function ()
    init()
    if not cPane then
        cPane = addPane(0, 0, WIDTH, HEIGHT,
            "Output", defaultFont, defaultFontSize)
    end
    stream = true
end

local off = function ()
    stream = false
end

------------------------------------------------------------------------

print.redirect = {
    on = on,
    off = off,
    font = font,
    addPane = addPane,
    removePane = removePane,
    panes = panes,
    currentPane = currentPane
}

An example of its use is below. This example adds two panes for future use (with titles “Pane 1” and “Pane 2”) and then switches the current pane between them.


supportedOrientations(LANDSCAPE_ANY)

function setup()
    print.redirect.font("Inconsolata", 17)
    pane1 = print.redirect.addPane(25, 25, 350, 200, "Pane 1")
    pane2 = print.redirect.addPane(WIDTH/2, 0, WIDTH/2, HEIGHT, "Pane 2")
    print.redirect.currentPane(pane1)
    print.redirect.on()
    print("Touch the grey square in the centre of the screen to "..
    "generate more redirected text. The scroll bar does not feature "..
    "until enough output requires it.\
"..
    "This is a long piece of text intended to test the wrapping of "..
    "text. Wrapping occurs at the level of individual characters, "..
    "not (as is the case for the output pane) whole words.")
end

function draw()
    background(0, 0, 0, 255)
    noStroke()
    local n = 40
    local l = math.min(WIDTH, HEIGHT) / no
    local g = 127 * (1 + math.sin(ElapsedTime)) / 2
    for j = 1, n do
        for i = 1, n do
            local r = 127 * i / n
            local b = 127 * j / n
            fill(r, b, g)
            rect((i - 1) * l, (j - 1) * l, l, l)
        end
    end
    fill(127)
    rect(WIDTH/3, HEIGHT/3, WIDTH/3, HEIGHT/3)
end

local ready = false
local batch = 0
function touched(touch)
    if touch.state == BEGAN and not ready then ready = true end
    if touch.state == ENDED and ready then
        if touch.x > WIDTH/3 and touch.x < 2 * WIDTH/3 and
            touch.y > HEIGHT/3 and touch.y < 2 * HEIGHT/3 then
            print.redirect.currentPane(pane1)
            print("Sending batch "..tostring(batch).." to pane 2.")
            print.redirect.currentPane(pane2)
            for i = batch * 10 + 1, batch * 10 + 10 do
                print("Batch "..batch..": ", i, "squared is", i*i)
            end
            batch = batch + 1
            ready = false
        end
    end
end    

Hello @KilamMalik. Version 2012.07.22.17.00 above (now in three parts) is my first attempt at a multi-pane version of the code. I welcome any suggestions you may have.

in

TextBox:setLineHeight()

miss

function

Thank you @Juanjo56. Something went wrong in the copy-pasting. Now fixed.

Hi @mpilgrem. Looks very good :slight_smile: I like the code cleanup by splitting it into classes. Also, the idea of multiple panes is very nice.

My only suggestion would be to make the whole pane visible by a transparent rectangle in the background. I’ve quickly added this by changing this function:

function Pane:draw()
    if not self.showOutput then return end   
    pushStyle()
        noStroke()
        fill(0, 0, 0, 60)
        rect(self.rect.l, self.rect.b, self.rect.w, self.rect.h)
    popStyle()
    if self.box then self.box:draw() end
    if self.tb then self.tb:draw() end
    if self.vsb then self.vsb:draw() end
end

Would be better to pass the transparency value than doing it static like in my quick hack.

Here is a screenshot of how I use your print together with my watch:

https://dl.dropbox.com/u/80475380/CodeaForumPictures/log_and_watch.jpg

Hi @mpilgrem and @KilamMalik, your work has been very useful for debugging in my music player. I’ve got the debug library working through the project dependencies feature, but not print.redirect. Any ideas?
Also, I found the scroll bar a little too small for my fingers, so expanded it. Couldn’t see how to make the slider bit wider though…?

Hello @Fred. ‘Redirect’ piggybacks on top of the existing draw() and touched() functions, so these functions have to have been created before the ‘Redirect’ code itself is reached.

The width of the VSBar slider element is set as self.d = 8 when the bar is initialised. Perhaps setting it to self.d = width - 2 would work?

@mpilgrem: do you have any plans to fix up redirect so that it can work with the dependencies feature? I stumbled upon the same issue as @Fred tonight while I was trying to upgrade some of my code to use your latest redirect code (I had been using the version you originally posted). It would seem the key reason the initial redirect code works with dependencies is because you do the draw() and touched() “hijacking” inside an init() method (which are called by on(), off(), and font() if it has not already been called), which guarantees that the original draw() and touched() methods are created before redirect “hijacks” them. Is there any reason the same could not be done in the newest version of redirect?

EDIT: I’ve taken the liberty of moving the hijacking into an init() method, and calling init() from on() if it has not previously been called. I don’t think it needs to be called from off() or font(), please correct me if I’m wrong. It seems to have done the trick. Alternatively, I’m considering just removing the init() automation altogether, and just calling it explicitly in my setup() method.

Hello @toadkick. I hope that version 2012.09.18.21.30 above now works with dependencies.

That’s got it @mpilgrem, thanks. Very nice to be able to add fast console output with just 2 taps and a line of code. This is an outstanding piece of software :slight_smile:

Oh wow, this is great! Thanks!

This is exactly what I need, thanks