Source code for fancyTextFunctions

"""
The fancy text module allows to write formated text to a Calico shape object.

The fancy text module allows you to write nicely formated text to a Calico Shape
object. It includes the ability to do automatic text wrapping, justification of
text, indentation, and inline objects like buttons.

The core of the fancy text module is the writeText function. The writeText
functions takes two arguments, a target onto which all the text will be drawn
and a list of of strings, shapes, and special commands that indicated what
should be drawn. A simple example would be:
::

    win = Window(1000, 1000)
    rec = Rectangle((0,0), (500,1000), color=Color("white"))
    writeText(rec, ['This is some text.\\n'])
    win.draw(rec)

Which will write "This is some text." to a rectangle and draw it to the window.
Obviously, this is not the main strength of the fancy text module, drawing a
simple piece of text to the screen can be done by Calico as well.

To make use of the full power of the fancy text module, you will have to use 
some of the special commands that come with the module. There are two types of 
commands: global commands and local commands.

Global commands allow you to set properties for all text that will follow next.
For example, the following code will write the above text in a bigger font:
::

    writeText(rec, [Size(30), "This is some text.\\n"])

Local commands allow you to set a property only for a specific piece of text.
For example, the following code will write one word in italics:
::

    writeText(rec, ["This ", Italic("is"), " some text.\\n"])


To enable line-wrapping after 200 pixels, use the :class:`Wrap` command:
::

    text = [Wrap(200), "This text is a little bit to long to fit in 200 pixels,"] 
    text += ["but luckily, it will be wrapped automatically."]
    writeText(rec, text)

You can enable justification (filling the width of the wrapped space), by 
supplying True as the second argument for :class:`Wrap`:
::

    text = [Wrap(200, True), "This text is a little long to fit in 200 pixels,"] 
    text += ["but it will be wrapped and justified by the fancy text writer."]
    writeText(rec, text)

To change the indentation of the text, simply add the :class:`Indent` global command:
::

    text =  [Indent(30), "This text is indented.\\n"]
    text +=  [Indent(0), "This text is not indented.\\n"]
    writeText(rec, text)

Lastly, to add an inline image or other shape to the text, simply add the shape
to the list:
::

    rec = Rectangle((0,0), (20,20))
    writeText(rec, "What follows is a rectangle:", rec)

For more options, see the list of global and local commands below.
"""
from Myro import *
from Graphics import *

from os.path import dirname, realpath
from constructionFunctions import *
from utilityFunctions import getWidth, getHeight
from copy import deepcopy

IMAGE_PATH=dirname(dirname(realpath(__file__)))+"/CommonImages/"

FONT_REGULAR = "Helvetica Neue Light"
FONT_ITALIC = "Helvetica Neue Light Italic"
#FONT_BOLD = "Helvetica Neue"
FONT_BOLD = "Helvetica Neue Bold"
FONT_MONO = "Courier New"

DEFAULT_FONT_FACE = FONT_REGULAR
DEFAULT_FONT_SIZE = 18
DEFAULT_FONT_COLOR = Color("black")
DEFAULT_LINE_HEIGHT = 18
DEFAULT_MARGIN = 10
DEFAULT_V_MARGIN = 4
Y_MARGIN = 4

#Can't use Line. Line is a class in Graphics.
class Entry():
    """Class representing one line in a fancy text."""
    def __init__(self):
        """
        Constructs an entry.

        Args:
            min_height: this line can never be lower than this.
        """
        self.elements = []
        self.width = 0
        self.wrap = -1
        self.vmargin = 0
        self.spaces = 0
        self.justify = False
        self.hspace = None
        self.indent = 0
        self.stretch = 0
        self.vMin = 0
        self.vMax = 0
        
    def __iter__(self):
        """Enables 'for each' over all elements."""
        return self.elements.__iter__()
        
    def append(self, context, element):
        """
        Adds an element to this line.

        Args:
            element: the element to add.
            width: the width of the element, if applicable.
            space: whether or not to add a space at the end of this element.
            justify: whether to justify this line (affects entire line)
        """
        self.elements.append(element)
        self.width += getWidth(element)
        self.vMin = min(self.vMin, -context.top().baselineOffset)
        self.vMax = max(self.vMax, getHeight(element) - context.top().baselineOffset)
        if isinstance(element, Space): self.spaces += 1
        
        
    def getSpaces(self):
        """Returns the number of spaces in this entry."""
        return self.spaces
        
    def popSpace(self):
        """Removes a trailing space from this entry."""
        if len(self.elements) > 0:
            if isinstance(self.elements[-1], Space):
                self.width -= self.elements[-1].width
                self.elements.pop()
                self.spaces-=1
        
    def setMinHeight(self, height):
        """
        Sets the minimum height of this entry.

        Args:
            height: The new minimum height of this entry.
        """
        if height > self.vMax - self.vMin:
            self.vMax = height + self.vMin

    def adjustSpace(self):
        """Adjusts the size of the spaces to justify the text."""
        if self.wrap > 0 and self.spaces > 0 and self.justify:
            remaining = self.wrap - (self.indent + self.width)
            self.stretch = float(remaining)/float(self.spaces)
        else:
            self.stretch = 0
            
    def finalize(self, context, justify=False):
        """
        Finalizes the Entry once all content is added.

        Args:
            context: Context holding the current settings to be applied.
            justify: If True, marks this entry as needing justification.
        """
        if justify:
            self.justify = context.top().justify
        else:
            self.justify = False
        self.wrap = context.top().wrap
        self.setMinHeight(context.top().fontSize)
        self.indent = context.top().indent
        self.vmargin = context.top().vmargin
        self.popSpace()
        
    def getHeigth(self):
        """Returns the height of this entry."""
        return self.vMax - self.vMin + self.vmargin


#########################################################
######### Private global modifiers ######################
#########################################################

# JH:
# These modifiers are either superflous (a new line can be
# added with "\n", a space can be added with " ", etc.),
# or require an understanding of the underlying mechanisms.
# I believe the documentation can remain cleaner if we 
# consider these commands `private'.

class GlobalCommand(object):
    """Base class for global commands, does nothing."""
    def applyContext(self, context, lines):
        pass
        
    def applyTransformation(self, pos, line):
        pass 


class NewLine(GlobalCommand):
    """Adds a new line to the text."""
    def applyContext(self, context, lines):
        lines[-1].finalize(context)
        lines.append(Entry())


class PopContext(GlobalCommand):
    """Pops the current context."""
    def applyContext(self, context, lines):
        context.pop()
    
    
class PushContext(GlobalCommand):
    """Pushes a new context onto the stack."""
    def applyContext(self, context, lines):
        context.push()


class Backup(GlobalCommand):
    """Resests the curser all the way to the start of the same line."""
    def applyTransformation(self, pos, line):
        pos[0] = line.indent
        pos[1] -= line.getHeigth()

                            
class Space(GlobalCommand):
    """Adds a stretchable space to the text"""
    width = 0
    
    def applyContext(self, context, lines):
        self.width = context.top().fontSize / 3

    def applyTransformation(self, pos, line):
        pos[0] += self.width + line.stretch


#########################################################
######### Public global modifiers #######################
#########################################################

# Documentation note: these classes are so simple that we don't
# document their constructor. Instead, we describe the constructor
# arguments in the class description itself.

[docs]class Font(GlobalCommand): """ Changes the font of all future text. The fancy text module defines 4 global variables for fonts: * FONT_REGULAR * FONT_BOLD * FONT_ITALIC * FONT_MONO Args: font: string indicating the font to switch to. """ def __init__(self, font): self.font = font def applyContext(self, context, lines): context.top().fontFace = self.font
[docs]class Size(GlobalCommand): """ Class to change font size. Args: size: integer representing the new fontsize. """ def __init__(self, size): self.size = size def applyContext(self, context, lines): context.top().fontSize = self.size context.top().fontSize = self.size
[docs]class Wrap(GlobalCommand): """ Enables automatic line wrapping. Args: wrapWidth: after how many pixels the line should be wrapped. justify: should the text be justified. """ def __init__(self, wrapWidth, justify=True): self.wrapWidth = wrapWidth self.justify = justify def applyContext(self, context, lines): context.top().wrap = self.wrapWidth context.top().justify = self.justify
[docs]class Indent(GlobalCommand): """ Indents this and all subsequent text. Args: space: the distance this text should be indented. """ def __init__(self, space): self.space = space def applyContext(self, context, lines): context.top().indent = self.space
class CopyHeight(GlobalCommand): """ Indents this and all subsequent text. Args: space: the distance this text should be indented. """ def __init__(self, target, source): self.source = source self.target = target def applyContext(self, context, lines): lines[self.target].vMax = lines[self.source].vMax
[docs]class SkipV(GlobalCommand): """ Advances the vertical cursor position. Args: space: the distance to skip. """ def __init__(self, space): self.space = space def applyTransformation(self, pos, line): pos[1] += self.space
[docs]class SkipH(GlobalCommand): """ Advances the horizontal cursor position. Args: space: the distance to skip. """ def __init__(self, space): self.width = space def applyTransformation(self, pos, line): pos[0] += self.width
class SetV(GlobalCommand): """ Sets the vertical cursor position. Args: y: the new y position of the cursor. """ def __init__(self, y): self.y = y def applyTransformation(self, pos, line): pos[1] = self.y class SetH(GlobalCommand): """ Sets the horizontal cursor position. Args: x: the new x position of the cursor. """ def __init__(self, x): self.x = x def applyTransformation(self, pos, line): pos[0] = self.x class SetSpacing(GlobalCommand): """ Sets the spacing between lines. Args: spacing: the new spacing between lines. """ def __init__(self, spacing): self.spacing = spacing def applyContext(self, context, lines): context.top().vmargin = self.spacing ######################################################### ######### Local modifiers ############################## ######################################################### class LocalCommand(object): """Base class for local commands, does nothing.""" elements = [] def applyContext(self, context, lines): pass
[docs]class Bullet(LocalCommand): """ Creates a bullet point for in a bulleted list. Args: bullet: the text or object to be used as a bullet. text: the text to display with this shorthand. indent: the size of indent to go with the bullet. """ def __init__(self, bullet, text, indent=30, parskip=4): self.elements = [bullet, Backup(), NewLine(), Indent(indent), text, NewLine(), SkipV(parskip)] def applyContext(self, context, lines): self.elements.append(CopyHeight(len(lines)-1, len(lines)))
[docs]class Offset(LocalCommand): """ Moves a shape or piece of text vertically. Args: text: the text (or shapes) to be displaced. offset: the distance by which the text should be moved. """ def __init__(self, text, offset): self.offset = offset self.elements = [SkipV(offset), text, SkipV(-offset)] def applyContext(self, context, lines): context.top().baselineOffset = self.offset
[docs]class Mono(LocalCommand): """ Displays the provided text in a mono-spaced font. Args: text: the text to be displayed. """ def __init__(self, text): self.elements = [Font(FONT_MONO), text]
[docs]class Code(LocalCommand): """ Displays the provided text as inline code. Args: text: the text to be displayed. """ def __init__(self, text): self.elements = [Font(FONT_MONO), text, SkipH(4)]
[docs]class Bold(LocalCommand): """ Displays the provided text in a bold. Args: text: the text to be displayed. """ def __init__(self, text): self.elements = [Font(FONT_BOLD), text]
[docs]class Italic(LocalCommand): """ Displays the provided text in italics. Args: text: the text to be displayed. """ def __init__(self, text): self.elements = [Font(FONT_ITALIC), text]
INLINE_BUTTON_WIDTH_2 = 80 INLINE_BUTTON_HEIGHT_2 = 20 class Inline2(LocalCommand): """ Adds an inline button to the briefscreen. Args: text: the text to appear on the button. tag: the tag used to determine what should happen if this button is clicked. """ def __init__(self,text,tag=None,color=Color("white")): if tag: tag = tag else: tag = text tmp = Rectangle((0,0),(100,100)) newButton, _ = createPanelButton(tmp, 0, 0, INLINE_BUTTON_WIDTH_2, INLINE_BUTTON_HEIGHT_2, text, tag, buttonColor=color) self.elements = [Offset(newButton, 5)] class MyShape(object): def getP1(self): return point(0,0) def getP2(self): return point(0,0) def moveTo(self,x,y): print("I'm being moved!") def draw(self, target): print("I'm being drawn!") # JH: This doesn't work because, while shapes can be connected # They can never be disconnected ## class MyButton(MyShape): ## def __init__(self, button,action=None): ## self.button = button ## self.action = action ## ## def getP1(self): ## return self.button.getP1() ## ## def getP2(self): ## return self.button.getP2() ## ## def moveTo(self,x,y): ## self.button.moveTo(x,y) ## ## def draw(self, target): ## self.button.draw(target) ## self.button.connect("click", self.onClick) ## ## def onClick(self,o,e): ## print("You clicked on:", self.button.tag) ## if self.action is not None: self.action() class LRCButton(LocalCommand): """ Adds an inline button to the briefscreen. Args: text: the text to appear on the button. tag: the tag used to determine what should happen if this button is clicked. """ def __init__(self,text,action=None,color=Color("white")): tmp = Rectangle((0,0),(100,100)) newButton, _ = createPanelButton(tmp, 0, 0, INLINE_BUTTON_WIDTH_2, INLINE_BUTTON_HEIGHT_2, text, "button", buttonColor=color) self.elements = [Offset(newButton, 5)] self.button = newButton self.action = action def hit(self, x, y): return self.button.hit(x, y) RELATED_PAGES_BUTTON_WIDTH_2 = 92 RELATED_PAGES_BUTTON_HEIGHT_2 = 35 class Related(LocalCommand): """ Adds an inline button to the briefscreen. Args: text: the text to appear on the button. tag: the tag used to determine what should happen if this button is clicked. """ def __init__(self,text,tag=None,color=Color("white")): if tag: tag = tag else: tag = text tmp = Rectangle((0,0),(100,100)) newButton, _ = createPanelButton(tmp, 0, 0, RELATED_PAGES_BUTTON_WIDTH_2, RELATED_PAGES_BUTTON_HEIGHT_2, text, tag, buttonColor=color) self.elements = [Offset(newButton, 5)] ######################################################### ######### Context ###################################### ######################################################### class Context(object): """Class to keep context information""" fontFace = DEFAULT_FONT_FACE fontSize = DEFAULT_FONT_SIZE fontColor = DEFAULT_FONT_COLOR justify = False wrap = -1 indent = 0 vmargin = DEFAULT_V_MARGIN baselineOffset = 0 class ContextStack(object): """Class to keep track of a context stack""" stack = None def __init__(self): self.stack = [Context()] self.win = getWindow() def top(self): return self.stack[-1] def add(self, context): context.applyContext(self) def push(self): self.stack.append(deepcopy(self.stack[-1])) def pop(self): del self.stack[-1] ######################################################### ######### Fancy text functions ########################## ######################################################### def _processText(context, element): """ Creates a number of Text objects out of the provided element. Args: contex: The current context, holding font size, font color, etc. element: The element to be turned in to text, usually a string. Return: A list of Text objects, Space objects, and NewLine objects, in the form [Text, Space, Text, Space, Text, Newline]. """ result = [] text = str(element) if text == "": return result split_string = text.split("\n") for string in split_string: for word in string.split(" "): if word != "": text_obj = Text((0,0), word) #The only alignment that works is bottom text_obj.yJustification = "bottom" text_obj.xJustification = "left" text_obj.fill = context.top().fontColor text_obj.fontFace = context.top().fontFace text_obj.fontSize = context.top().fontSize text_obj.window = getWindow() result.append(text_obj) result.append(Space()) result.pop() result.append(NewLine()) result.pop() return result def _processShape(context, lines, element): """ Adds a shape to the current lines. Args: context: The current context, holding font size, font color, etc. lines: The current list of lines. element: The shape to be added. """ new_width = getWidth(element) total_width = context.top().indent + lines[-1].width + new_width if context.top().wrap > 0 and total_width > context.top().wrap: lines[-1].finalize(context, True) lines.append(Entry()) lines[-1].append(context, element) def _processElements(context, lines, elements): """ Processes the list of elements. Args: context: The current context, holding font size, font color, etc. lines: The current list of lines. elements: A list of elements to be added. """ for element in elements: if isinstance(element, GlobalCommand): element.applyContext(context, lines) lines[-1].append(context, element) elif isinstance(element, LocalCommand): context.push() element.applyContext(context, lines) _processElements(context, lines, element.elements) context.pop() elif hasattr(element, '__iter__'): _processElements(context, lines, element) elif isinstance(element, Shape) or isinstance(element, MyShape): _processShape(context, lines, element) elif hasattr(element, '__str__'): textShapes = _processText(context, element) _processElements(context, lines, textShapes) else: print("WARNING: unreconized element", element, "ignored") def _createEntries(elements): """ Creates a set of lines out of a list of elements. Args: elements: A list of elements to be added. Return: A list of lines. """ context = ContextStack() lines = [ Entry() ] _processElements(context, lines, elements) lines[-1].finalize(context) return lines def _writeLines(target, lines, x_pos_start, y_pos_start): """ Writes a list of lines to a target object. Args: target: The object to draw to. lines: The list of lines. x_pos_start: The x coordinate from where to start writing. y_pos_start: The y coordinate from where to start writing. Return: The final y coordinate. """ pos = [0, 0] for i in xrange(len(lines)): line = lines[i] pos[0] = line.indent line.adjustSpace() for element in line: if isinstance(element, GlobalCommand): element.applyTransformation(pos, line) elif hasattr(element, "moveTo") and hasattr(element, "draw"): width = getWidth(element) if isinstance(element, Text): x = x_pos_start + pos[0] y = y_pos_start + pos[1] + line.vMax else: height = getHeight(element) x = x_pos_start + pos[0] + width/2 y = y_pos_start + pos[1] + line.vMax - height/2 element.moveTo(x, y) element.draw(target) pos[0] += width pos[1] += line.getHeigth() return pos[1]
[docs]def writeText(target, elements, x_pos_start=None, y_pos_start=None): """ Write the list of elements to the target. Args: target: The target object to draw to. elemetns: A list of elemetns indicating what, and how to draw. x_pos_start: The x coordinate from where to start writing. y_pos_start: The y coordinate from where to start writing. Return: The final y coordinate. """ if not x_pos_start: x_pos_start = -getWidth(target)/2 + DEFAULT_MARGIN if not y_pos_start: y_pos_start = -getHeight(target)/2 + DEFAULT_MARGIN lines = _createEntries(elements) return _writeLines(target, lines, x_pos_start, y_pos_start)
def testTextFunctions(): """ Informal unit test for the text drawing functions. """ win = Window(1000, 1000) rec = Rectangle((0,0), (500,1000), color=Color("white")) # Test basic text text = ["Hi! This is a test, to see if things go wonky at some point.|\n\n"] # Test changing the font text += [Font(FONT_ITALIC), "Font should be italic.\n", Font(FONT_REGULAR)] text += ["This text should not be italic.\n\n"] # Test shorthand text += [Italic("This text should be in italic.\n")] text += [Italic(["This italic text...", SkipH(50), "has a gap.\n\n"])] # Test text wrapping and justification text += [Wrap(200, True), "This automatically wrapped piece of text should"] text += [" be justified,"] text += [" and this space", SkipH(50), "should count for wrapping.\n\n"] # Test indentation text += [Indent(30), "This text is indented.\n"] text += ["Just like this text.\n"] text += [Indent(0), "But unlike this text.\n\n"] # Test bullet points text += [Bullet("-", "This is an indent, which should work on wrap.\n")] text += ["This text is not indented.\n\n"] # Test inline shapes inlineRec1 = Rectangle((0,0), (20,20)) text += [Wrap(-1, False)] text += ["This text has an inline rectangle: ", inlineRec1, "\n"] inlineRec2 = Rectangle((0,0), (20,20)) text += ["This rectangle is shifted down: "] text += [SkipV(5), inlineRec2, SkipV(-5), "\n", SkipV(5)] text += ["Multiple spaces are not ignored.\n"] inlineRec3 = Rectangle((0,0), (100,100)) text += ["This large rectangle is shifted down: "] text += [Offset(inlineRec3, 50), "\n"] # Test converting numbers to text text += ["The following numbers are displayed as text: "] text += [1, " ", 5, " ", 17, " ", 3.5, "\n\n"] # Test spacing text += [SetSpacing(0), "Line 1\n"] text += ["Line 2\n"] text += ["Line 3\n"] text += ["Line 4\n"] text += ["Line 5\n"] # Test font size text += [Size(30), "This text is larger than normal!\n"] text += [Size(10), "This text is smaller than normal!\n", Size(18)] # Test bullet points with inline images inlineRec4 = Rectangle((0,0), (30,30)) text += [Wrap(300, False), Bullet("1.", ["This is an indent ", Offset(inlineRec4, 10), " with a rectangle in it.\n"])] writeText(rec, text) win.draw(rec)