changeset 16:34545dfacdc8

Added enso.ui.messages.primarywindow.
author Atul Varma <varmaa@toolness.com>
date Fri, 22 Feb 2008 16:04:29 -0600
parents 0c9569096feb
children 981fc94ec6d0
files TODO enso/ui/messages/primarywindow.py
diffstat 2 files changed, 532 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- a/TODO	Fri Feb 22 15:45:29 2008 -0600
+++ b/TODO	Fri Feb 22 16:04:29 2008 -0600
@@ -16,3 +16,7 @@
 
 * Lots of constants are in enso.ui.quasimode.layout that might
   best be moved to enso.config.
+
+* There are some constants in enso.ui.messages.primarywindow
+  that we may want to move to enso.config.  Also consider moving
+  the styles for document, p, caption tags, etc.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/enso/ui/messages/primarywindow.py	Fri Feb 22 16:04:29 2008 -0600
@@ -0,0 +1,528 @@
+# Copyright (c) 2008, Humanized, Inc.
+# All rights reserved.
+# 
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#    1. Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#
+#    2. Redistributions in binary form must reproduce the above copyright
+#       notice, this list of conditions and the following disclaimer in the
+#       documentation and/or other materials provided with the distribution.
+#
+#    3. Neither the name of Enso nor the names of its contributors may
+#       be used to endorse or promote products derived from this
+#       software without specific prior written permission.
+# 
+# THIS SOFTWARE IS PROVIDED BY Humanized, Inc. ``AS IS'' AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL Humanized, Inc. BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+# ----------------------------------------------------------------------------
+#
+#   enso.ui.messages.primarywindow
+#
+# ----------------------------------------------------------------------------
+
+"""
+    Implements the various Message windows.
+"""
+
+# ----------------------------------------------------------------------------
+# Imports
+# ----------------------------------------------------------------------------
+
+import logging
+
+from enso import graphics
+from enso.graphics import xmltextlayout
+from enso.graphics.measurement import pointsToPixels, pixelsToPoints
+from enso.graphics.measurement import inchesToPoints
+from enso.ui.graphics import drawRoundedRect, ALL_CORNERS
+from enso.utils.xml_tools import escapeXml
+from enso.ui import events
+from enso.ui.messages.windows import MessageWindow, computeWidth
+
+
+# ----------------------------------------------------------------------------
+# Constants
+# ----------------------------------------------------------------------------
+
+# Total length of time from dismissal to full fade-out (in ms)
+ANIMATION_TIME = 250
+
+# Amount of time (in ms) to wait after primary message creation before
+# allowing dismissal events to trigger the animation
+WAIT_TIME = 80
+
+
+# ----------------------------------------------------------------------------
+# Visual Layout Constants
+# ----------------------------------------------------------------------------
+
+# The width, height, and margins of primary messages.
+PRIM_MSG_WIDTH = inchesToPoints( 8 )
+MAX_MSG_HEIGHT = inchesToPoints( 6 )
+PRIM_MSG_MARGIN = inchesToPoints( .2 )
+
+MSG_BGCOLOR = [ .2, .2, .2, .85 ]
+
+# Text sizes for main text and captions.
+SCALE = [
+    ( 20, 12 ),
+    ( 24, 14 ),
+    ( 30, 18 ),
+    ]
+    
+PRIM_TEXT_SIZE = 24
+CAPTION_TEXT_SIZE = 16
+LINE_SPACING = 1
+# Distance between the main text block and the caption block.
+CAPTION_OFFSET = 0
+
+
+# ----------------------------------------------------------------------------
+# The Primary Message Window class
+# ----------------------------------------------------------------------------
+
+class PrimaryMsgWind( MessageWindow ):
+    """
+    Class that implements the primary message singleton's appearance
+    and behavior.
+
+    Essentially, setMessage() sets the current primary message.
+
+    Immediately after the message is set, it is rendered, and the
+    class goes into a brief wait cycle, so that user actions don't make
+    the message disappear before it can be seen.
+
+    After this wait cycle is completed, the singleton registers itself
+    as a responder to dismissal events.  When a dismissal event
+    happens, the singleton animates the fading out of the primary message.
+    Also, the singleton notifies the message manager that the primary
+    message has been dismissed.
+    """
+    
+    def __init__( self, msgMan ):
+        """
+        Initializes the PrimaryMessage singleton
+        """
+
+        # Instantiate the underlying MessageWindow to the
+        # maxsize suggested by the module constants.
+        width = min( PRIM_MSG_WIDTH,
+                     pixelsToPoints(graphics.getDesktopSize()[0])-1 )
+
+        height = min( MAX_MSG_HEIGHT,
+                      pixelsToPoints(graphics.getDesktopSize()[1])-1 )
+
+        maxSize = ( width, height )
+        MessageWindow.__init__( self, maxSize )
+
+        self.__msgManager = msgMan
+        self.__msg = None
+        self.__waiting = False
+        self.__animating = False
+
+
+    def setMessage( self, message ):
+        """
+        Sets the current primary message to "message".
+        """
+        
+        if self.__msg != None:
+            # If there already is a primary message, then "interupt" it:
+            self.__interupt()
+
+        # Set the current primary message, and draw it.
+        self.__msg = message
+        self.__drawMessage()
+
+        # Now, set a time-responder to wait for a bit, so that the
+        # user doesn't accidentally clear the message before it registers
+        # as existing.
+        self.__timeSinceCreated = 0
+        eventMan = events.eventManager
+        eventMan.registerResponder( self.waitTick, "timer" )
+        self.__waiting = True
+        
+
+    def onDismissal( self ):
+        """
+        Called on a dismissal event, to start the animation process
+        and make sure the underlying message does what it needs to
+        when it ceases being a primary message.
+        """
+
+        self.__msgManager.onDismissal()
+        
+        eventMan = events.eventManager
+        eventMan.removeResponder( self.onDismissal )
+        self.__timeSinceDismissal = 0
+        eventMan.registerResponder( self.animationTick, "timer" )
+        self.__animating = True
+        
+
+    def animationTick( self, msPassed ):
+        """
+        Called on a timer event to animate the window's fadeout.
+        """
+        
+        self.__timeSinceDismissal += msPassed
+        if self.__timeSinceDismissal > ANIMATION_TIME:
+            self.__onAnimationFinished()
+            return
+
+        timeLeft  = ANIMATION_TIME - self.__timeSinceDismissal
+        frac = timeLeft / float(ANIMATION_TIME)
+        opacity = int( 255*frac )
+        self._wind.setOpacity( opacity )
+        self._wind.update()
+
+
+    def waitTick ( self, msPassed ):
+        """
+        Called on a timer event, to give some time between the message
+        appearing and when it can disappear.
+        """
+        
+        self.__timeSinceCreated += msPassed
+        if self.__timeSinceCreated > WAIT_TIME:
+            eventMan = events.eventManager
+            eventMan.registerResponder( self.onDismissal, "dismissal" )
+            eventMan.removeResponder( self.waitTick )
+            self.__waiting = False
+
+            # The following message may be used by system tests.
+            logging.info( "newMessage: %s" % self.__msg.getPrimaryXml() )
+
+
+    def __position( self ):
+        """
+        Centers the message window horizontally using the current size.
+        """
+        
+        desksize = graphics.getDesktopSize()
+        # getDesktopSize() returns pixels; we work in points.
+        desksize = [ pixelsToPoints(a) for a in desksize ]
+
+        xPos = ( desksize[0] - self.getSize()[0] ) / 2
+        # Set the height based on the "maximum" height, so that the
+        # message always appears at the same vertical offset from the
+        # top of the screen.
+        yPos = ( desksize[1] - self.getMaxSize()[1] ) / 2
+        self.setPos( xPos, yPos )
+
+
+    def __interupt( self ):
+        """
+        "interupts" the current primary message, terminating
+        its animation, and/or
+        """
+        
+        eventMan = events.eventManager
+
+        if self.__msg != None:
+            # If there's an old message, then we've got an
+            # event responder registered:
+            eventMan = events.eventManager
+            if self.__waiting:
+                eventMan.removeResponder( self.waitTick )
+                self.__waiting = False
+            if self.__animating:
+                eventMan.removeResponder( self.animationTick )
+                self.__animating = False
+            else:
+                eventMan.removeResponder( self.onDismissal )
+
+        if self.__waiting:
+            eventMan.removeResponder( self.waitTick )
+
+    def __drawMessage( self ):
+        """
+        Draws the current message to the underlying Cario context.
+        """
+        
+        # This function is the master drawing function; all layout and
+        # rendering methods are called from here.
+
+        text = self.__msg.getPrimaryXml()
+        self.clearWindow()
+
+        msgText, capText = splitContent( text )
+        width,height = self.getMaxSize()
+        width -= 2*PRIM_MSG_MARGIN
+        height -= 2*PRIM_MSG_MARGIN
+        msgDoc, capDoc = self.__layoutText( msgText,
+                                            capText,
+                                            width,
+                                            height )
+        width, height, msgPos, capPos = \
+               self.__layoutBlocks( msgDoc, capDoc )
+
+        # Set the window size and draw the outlining rectangle
+        self.__setupBackground( width, height )
+        # Draw the text.
+        msgDoc.draw( msgPos[0], msgPos[1], self._context )
+        if capDoc != None:
+            capDoc.draw( capPos[0], capPos[1], self._context )
+
+        # Set the window opacity (which can be left at 0 by the animation)
+        self._wind.setOpacity( 255 )
+        # Show and update the window.
+        self.show()
+
+
+    def __isOneLineMsg( self, msgDoc, capDoc ):
+        """
+        Determines whether msgDoc and capDoc are both one line.
+        """
+        
+        numMsgLines = 0
+        for block in ( msgDoc.blocks ):
+            numMsgLines += len( block.lines )
+        numCapLines = 0
+        for block in ( capDoc.blocks ):
+            numCapLines += len( block.lines )
+        return (numCapLines == 1 and numMsgLines == 1)
+
+
+    def __layoutText( self, msgText, capText, width, height ):
+        """
+        Lays out msgText and capText into two seperate document
+        objects.
+
+        Returns a tuple: ( msgDoc, capDoc )
+        NOTE: capDoc can be None, if capText is None.
+        """
+
+        root = "<document>%s</document>"
+
+        for msgSize, capSize in reversed( SCALE[1:] ):
+            try:
+                msgDoc = layoutMessageXml(
+                    xmlMarkup = root % msgText,
+                    width = width,
+                    height = height,
+                    size = msgSize,
+                    )
+                if capText != None:
+                    capDoc = layoutMessageXml(
+                        xmlMarkup = root % capText,
+                        width = width,
+                        height = height - msgDoc.height,
+                        size = capSize
+                        )
+                else:
+                    capDoc = None
+                return msgDoc, capDoc
+            except:
+                # LONGTERM TODO: Lookup exact error.
+                pass
+            
+        # This time, ellipsify.
+        msgSize, capSize = SCALE[0]
+        msgDoc = layoutMessageXml(
+            xmlMarkup = root % msgText,
+            width = width,
+            height = height * .8,
+            size = msgSize,
+            ellipsify = "true",
+            )
+        if capText != None:
+            capDoc = layoutMessageXml(
+                xmlMarkup = root % capText,
+                width = width,
+                height = height * .2,
+                size = capSize,
+                ellipsify = "true",
+                )
+        else:
+            capDoc = None
+        return msgDoc, capDoc
+
+
+    def __setupBackground( self, width, height ):
+        """
+        Given a text region of width and height, sets the size of the
+        underlying window to be that plus margins, and draws a rounded
+        background rectangle.
+        """
+
+        width += (2*PRIM_MSG_MARGIN)-2
+        height += (2*PRIM_MSG_MARGIN)-2
+        width = int(width)
+        height = int(height)
+        assert width <= self.getMaxSize()[0], \
+               "width %s, self.getMaxSize()[0] %s" \
+               % (width, self.getMaxSize()[0])
+        self.setSize( width, height )
+        self.__position()
+        
+        cr = self._context
+        drawRoundedRect(
+            context = cr,
+            rect = ( 0, 0, width, height ),
+            softenedCorners = ALL_CORNERS,
+            )
+        cr.set_source_rgba( *MSG_BGCOLOR )
+        cr.fill_preserve()
+
+
+    def __layoutBlocks( self, messageDoc, captionDoc ):
+        """
+        Determines how the documents messageDoc and captionDoc should
+        be combined to form a complete message window.
+
+        Returns a tuple:
+          ( width, height, messagePosition, captionPosition )
+        """
+
+        capDoc, msgDoc = captionDoc, messageDoc
+        if capDoc == None:
+            width = computeWidth( msgDoc )
+            height = msgDoc.height
+            msgPos = ( PRIM_MSG_MARGIN, PRIM_MSG_MARGIN )
+            capPos = None
+        elif self.__isOneLineMsg( msgDoc, capDoc ):
+            msgWidth = computeWidth( msgDoc )
+            capWidth = computeWidth( capDoc )
+            width = max( msgWidth, capWidth )
+            height = msgDoc.height + capDoc.height
+            msgPos = ( PRIM_MSG_MARGIN + ( ( width - msgWidth ) / 2 ), 
+                       PRIM_MSG_MARGIN )
+            capPos = ( PRIM_MSG_MARGIN + ( ( width - capWidth ) / 2 ),
+                       msgPos[1] + msgDoc.height )
+        else:
+            msgWidth = computeWidth( msgDoc )
+            capWidth = computeWidth( capDoc ) 
+            width = max( msgWidth, capWidth )
+            height = msgDoc.height + capDoc.height
+            msgPos = ( PRIM_MSG_MARGIN, PRIM_MSG_MARGIN )
+            capPos = ( width - capWidth + PRIM_MSG_MARGIN,
+                       msgPos[1] + msgDoc.height )
+        return width, height, msgPos, capPos
+        
+
+    def __onAnimationFinished( self ):
+        """
+        Called when the animation is finished.
+        """
+
+        if self.__animating:
+            eventMan = events.eventManager
+            eventMan.removeResponder( self.animationTick )
+            self.__animating = False
+        self.hide()
+        self.__msg = None
+
+        self.__msgManager.onPrimaryMessageFinished()
+
+
+# ----------------------------------------------------------------------------
+# Xml Layout
+# ----------------------------------------------------------------------------
+
+# The master style registry for primary messages.
+_styles = xmltextlayout.StyleRegistry()
+
+_styles.add(
+    "document",
+    margin_top = "0.0pt",
+    margin_bottom = "0.0pt",
+    font_family = "Gentium",
+    font_style = "normal",
+    max_lines = "0",
+    ellipsify = "false",
+    text_align = "left",
+    )
+
+_styles.add(
+    "p",
+    color = "#ffffff",
+    margin_top = "0pt",
+    margin_bottom = "0pt",
+    )
+
+_styles.add(
+    "caption",
+    color = "#669900",
+    margin_top = "%spt" % CAPTION_OFFSET,
+    margin_bottom = "0pt",
+    )
+
+_styles.add(
+    "command",
+    color = "#669900",
+    )
+
+# The tag aliases for primary message XML.
+_tagAliases = xmltextlayout.XmlMarkupTagAliases()
+_tagAliases.add( "p", baseElement = "block" )
+_tagAliases.add( "caption", baseElement = "block" )
+_tagAliases.add( "command", baseElement = "inline" )
+
+
+def layoutMessageXml( xmlMarkup, width, size, height, ellipsify="false",
+                      raiseLayoutExceptions=False ):
+    """
+    Lays out the xmlMarkup in a block that is width wide.
+
+    if raiseLayoutExceptions is False, then this function will
+    suppress any exceptions raised when parsing xmlMarkup and replace
+    it with a message that tells the end-user that the message was
+    broken, providing the end-user with as much of the original
+    message as possible.  If raiseLayoutExceptions is True, however,
+    any exceptions raised will be passed through to the caller.
+    """
+
+    maxLines = int( height / (size*LINE_SPACING) )
+    
+    _styles.update( "document",
+                    width = "%fpt" % width,
+                    line_height = "%spt" % int(size*LINE_SPACING),
+                    max_lines = maxLines,
+                    font_size = "%spt" % size,
+                    ellipsify = ellipsify,
+                    )
+
+    try:
+        document = xmltextlayout.xmlMarkupToDocument(
+            xmlMarkup,
+            _styles,
+            _tagAliases
+            )
+    except Exception, e:
+        if raiseLayoutExceptions:
+            raise
+        logging.warn( "Could not layout message text %s; got error %s"
+                      % ( xmlMarkup, e ) )
+        document = xmltextlayout.xmlMarkupToDocument(
+            "<document><p>%s</p>%s</document>" %
+                 ( escapeXml( xmlMarkup.strip() ),
+                   "<caption>from a broken message</caption>" ),
+            _styles,
+            _tagAliases
+            )
+
+    return document
+
+
+def splitContent(  messageXml ):
+    """
+    Splits messageXml into two parts: main, and caption.
+    """
+    
+    capLocation = messageXml.find( "<caption>" )
+    if capLocation == -1:
+        return ( messageXml, None )
+    else:
+        return ( messageXml[:capLocation], messageXml[capLocation:] )