#  Copyright (c) 2007, Enthought, Inc.
#  All rights reserved.
#
#  This software is provided without warranty under the terms of the BSD
#  license included in enthought/LICENSE.txt and may be redistributed only
#  under the conditions described in the aforementioned license.  The license
#  is also available online at http://www.enthought.com/licenses/BSD.txt

#  Author: David C. Morrill
#  Date:   03/30/2007
#
#  fixme:
#  - Get custom tree view images.
#  - Write a program to create a directory structure from a lesson plan file.

""" A framework for creating interactive Python tutorials.
"""

#  Imports:

import sys
import os
import re

from string \
    import capwords

from traits.api import (HasPrivateTraits, HasTraits, File, Directory, Instance,
                        Int, Str, List, Bool, Dict, Property,
                        Button, cached_property)

from traitsui.api import (View, VGroup, HGroup, VSplit, HSplit, Tabbed, Item,
                          Heading, Handler, ListEditor, CodeEditor,
                          HTMLEditor, TreeEditor, TitleEditor, ValueEditor,
                          ShellEditor)

from traitsui.menu import NoButtons
from traitsui.tree_node import TreeNode
from pyface.image_resource import ImageResource

try:
    from traitsui.wx.extra.windows.ie_html_editor \
        import IEHTMLEditor

    from traitsui.wx.extra.windows.flash_editor \
        import FlashEditor
except:
    IEHTMLEditor = FlashEditor = None

#  Constants:

# Correct program usage information:
Usage = """
Correct usage is: tutor.py [root_dir]
where:
    root_dir = Path to root of the tutorial tree

If omitted, 'root_dir' defaults to the current directory."""

# The standard list editor used:
list_editor = ListEditor(
    use_notebook=True,
    deletable=False,
    page_name='.title',
    export='DockWindowShell',
    dock_style='fixed',
    )

# The standard code snippet editor used:
snippet_editor = ListEditor(
    use_notebook=True,
    deletable=False,
    page_name='.title',
    export='DockWindowShell',
    dock_style='tab',
    selected='snippet',
    )

# Regular expressions used to match section directories:
dir_pat1 = re.compile(r'^(\d\d\d\d)_(.*)$')
dir_pat2 = re.compile(r'^(.*)_(\d+\.\d+)$')

# Regular expression used to match section header in a Python source file:
section_pat1 = re.compile(r'^#-*\[(.*)\]')  # Normal
section_pat2 = re.compile(r'^#-*<(.*)>')    # Hidden
section_pat3 = re.compile(r'^#-*\((.*)\)')  # Description

# Regular expression used to extract item titles from URLs:
url_pat1 = re.compile(r'^(.*)\[(.*)\](.*)$')  # Normal

# Is this running on the Windows platform?
is_windows = (sys.platform in ('win32', 'win64'))

# Python file section types:
IsCode = 0
IsHiddenCode = 1
IsDescription = 2

# HTML template for a default lecture:
DefaultLecture = """<html>
  <head>
  </head>
  <body>
    <p>This section contains the following topics:</p>
    <ul>
    %s
    </ul>
  </body>
</html>
"""

# HTML template for displaying a .wmv/.avi movie file:
WMVMovieTemplate = """<html>
<head>
</head>
<body>
<p><object classid="clsid:22D6F312-B0F6-11D0-94AB-0080C74C7E95" codebase="http://activex.microsoft.com/activex/controls/mplayer/en/nsmp2inf.cab#Version=6,4,5,715">
<param name="FileName" value="%s">
<param name="AutoStart" value="true">
<param name="ShowTracker" value="true">
<param name="ShowControls" value="true">
<param name="ShowGotoBar" value="false">
<param name="ShowDisplay" value="false">
<param name="ShowStatusBar" value="false">
<param name="AutoSize" value="true">
<embed src="%s" AutoStart="true" ShowTracker="true" ShowControls="true" ShowGotoBar="false" ShowDisplay="false" ShowStatusBar="false" AutoSize="true" pluginspage="http://www.microsoft.com/windows/windowsmedia/download/"></object></p>
</body>
</html>
"""

# HTML template for displaying a QuickTime.mov movie file:
QTMovieTemplate = """<html>
<head>
</head>
<body>
<p><object classid="clsid:02BF25D5-8C17-4B23-BC80-D3488ABDDC6B" codebase="http://www.apple.com/qtactivex/qtplugin.cab" width="100%%" height="100%%">
<param name="src" value="file:///%s">
<param name="scale" value="aspect">
<param name="autoplay" value="true">
<param name="loop" value="false">
<param name="controller" value="true">
<embed src="file:///%s" width="100%%" height="100%%" scale="aspect" autoplay="true" loop="false" controller="true" pluginspage="http://www.apple.com/quicktime/download"></object></p>
</body>
</html>
"""

# HTML template for displaying an image file:
ImageTemplate = """<html>
<head>
</head>
<body>
<img src="%s">
</body>
</html>
"""

# HTML template for playing an MP3 audio file:
MP3Template = """<html>
<head>
<bgsound src="%s">
</head>
<body>
<p>&nbsp;</p>
</body>
</html>
"""


#  Returns the contents of a specified text file (or None):
def read_file(path, mode='rb'):
    """ Returns the contents of a specified text file (or None).
        """
    fh = result = None

    try:
        fh = file(path, mode)
        result = fh.read()
    except:
        pass

    if fh is not None:
        try:
            fh.close()
        except:
            pass

    return result


#  Creates a title from a specified string:
def title_for(title):
    """ Creates a title from a specified string.
    """
    return capwords(title.replace('_', ' '))


#  Returns a relative CSS style sheet path for a specified path and parent
#  section:
def css_path_for(path, parent):
    """ Returns a relative CSS style sheet path for a specified path and parent
        section.
    """
    if os.path.isfile(os.path.join(path, 'default.css')):
        return 'default.css'

    if parent is not None:
        result = parent.css_path
        if result != '':
            if path != parent.path:
                result = os.path.join('..', result)

            return result

    return ''


#  'StdOut' class:
class StdOut(object):
    """ Simulate stdout, but redirect the output to the 'output' string
        supplied by some 'owner' object.
    """

    def __init__(self, owner):
        self.owner = owner

    def write(self, data):
        """ Adds the specified data to the output log.
        """
        self.owner.output += data

    def flush(self):
        """ Flushes all current data to the output log.
        """
        pass


#  'NoDemo' class:
class NoDemo(HasPrivateTraits):

    view = View(
        Heading('No demo defined for this lab.'),
        resizable=True
        )


#  'DemoPane' class:
class DemoPane(HasPrivateTraits):
    """ Displays the contents of a Python lab's *demo* value.
    """

    demo = Instance(HasTraits, factory=NoDemo)

    view = View(
        Item('demo',
             id='demo',
             show_label=False,
             style='custom',
             resizable=True
             ),
        id='enthought.tutor.demo',
        resizable=True
        )


#  'ATutorialItem' class:
class ATutorialItem(HasPrivateTraits):
    """ Defines the abstract base class for each type of item (HTML, Flash,
        text, code) displayed within the tutor.
    """

    # The title for the item:
    title = Str

    # The path to the item:
    path = File

    # The displayable content for the item:
    content = Property


#  'ADescriptionItem' class:
class ADescriptionItem(ATutorialItem):
    """ Defines a common base class for all description items.
    """

    def _path_changed(self, path):
        """ Sets the title for the item based on the item's path name.
        """
        self.title = title_for(os.path.splitext(os.path.basename(
                                                path))[0])


#  'HTMLItem' class:
class HTMLItem(ADescriptionItem):
    """ Defines a class used for displaying a single HTML page within the tutor
        using the default Traits HTML editor.
    """

    url = Str

    view = View(
        Item('content',
             style='readonly',
             show_label=False,
             editor=HTMLEditor()
             )
        )

    def _url_changed(self, url):
        """ Sets the item title when the 'url' is changed.
        """
        match = url_pat1.match(url)
        if match is not None:
            title = match.group(2).strip()
        else:
            title = url.strip()
            col = title.rfind('/')
            if col >= 0:
                title = os.path.splitext(title[col + 1:])[0]

        self.title = title

    @cached_property
    def _get_content(self):
        """ Returns the item content.
        """
        url = self.url
        if url != '':
            match = url_pat1.match(url)
            if match is not None:
                url = match.group(1) + match.group(3)

            return url

        return read_file(self.path)

    def _set_content(self, content):
        """ Sets the item content.
        """
        self._content = content


#  'HTMLStrItem' class:
class HTMLStrItem(HTMLItem):
    """ Defines a class used for displaying a single HTML text string within
        the tutor using the default Traits HTML editor.
    """

    # Make the content a real trait rather than a property:
    content = Str


#  'IEHTMLItem' class:
class IEHTMLItem(HTMLItem):
    """ Defines a class used for displaying a single HTML page within the tutor
        using the Traits Internet Explorer HTML editor.
    """

    view = View(
        Item('content',
             style='readonly',
             show_label=False,
             editor=IEHTMLEditor(),
             )
        )


#  'IEHTMLStrItem' class:
class IEHTMLStrItem(IEHTMLItem):
    """ Defines a class used for displaying a single HTML text string within
        the tutor using the Traits Internet Explorer HTML editor.
    """

    # Make the content a real trait rather than a property:
    content = Str


#  'FlashItem' class:
class FlashItem(HTMLItem):
    """ Defines a class used for displaying a Flash-based animation or video
        within the tutor.
    """

    view = View(
        Item('content',
             style='readonly',
             show_label=False,
             editor=FlashEditor(),
             )
        )


#  'TextItem' class:
class TextItem(ADescriptionItem):
    """ Defines a class used for displaying a text file within the tutor.
    """

    view = View(
        Item('content',
             style='readonly',
             show_label=False,
             editor=CodeEditor(show_line_numbers=False,
                               selected_color=0xFFFFFF)
             )
        )

    @cached_property
    def _get_content(self):
        """ Returns the item content.
        """
        return read_file(self.path)


#  'TextStrItem' class:
class TextStrItem(TextItem):
    """ Defines a class used for displaying a text file within the tutor.
    """

    # Make the content a real trait, rather than a property:
    content = Str


#  'CodeItem' class:
class CodeItem(ATutorialItem):
    """ Defines a class used for displaying a Python source code fragment
        within the tutor.
    """

    # The displayable content for the item (override):
    content = Str

    # The starting line of the code snippet within the original file:
    start_line = Int

    # The currently selected line:
    selected_line = Int

    # Should this section normally be hidden?
    hidden = Bool

    view = View(
        Item('content',
             style='custom',
             show_label=False,
             editor=CodeEditor(selected_line='selected_line')
             )
        )


#  'ASection' abstract base class:
class ASection(HasPrivateTraits):
    """ Defines an abstract base class for a single section of a tutorial.
    """

    # The title of the section:
    title = Str

    # The path to this section:
    path = Directory

    # The parent section of this section (if any):
    parent = Instance('ASection')

    # Optional table of contents (can be used to define/locate the
    # subsections):
    toc = List(Str)

    # The path to the CSS style sheet to use for this section:
    css_path = Property

    # The list of subsections contained in this section:
    subsections = Property  # List(ASection)

    # This section can be executed:
    is_runnable = Bool(True)

    # Should the Python code be automatically executed on start-up?
    auto_run = Bool(False)

    @cached_property
    def _get_subsections(self):
        """ Returns the subsections for this section:
        """
        if len(self.toc) > 0:
            self._load_toc()
        else:
            self._load_dirs()

        # Return the cached list of sections:
        return self._subsections

    @cached_property
    def _get_css_path(self):
        """ Returns the path to the CSS style sheet for this section.
        """
        return css_path_for(self.path, self.parent)

    def _load_dirs(self):
        """ Defines the section's subsections by analyzing all of the section's
            sub-directories.
        """
        # No value cached yet:
        dirs = []
        path = self.path

        # Find every sub-directory whose name begins with a number of the
        # form ddd, or ends with a number of the form _ddd.ddd (used for
        # sorting them into the correct presentation order):
        for name in os.listdir(path):
            dir = os.path.join(path, name)
            if os.path.isdir(dir):
                match = dir_pat1.match(name)
                if match is not None:
                    dirs.append((float(match.group(1)),
                                 match.group(2), dir))
                else:
                    match = dir_pat2.match(name)
                    if match is not None:
                        dirs.append((float(match.group(2)),
                                     match.group(1), dir))

        # Sort the directories by their index value:
        dirs.sort(lambda l, r: cmp(l[0], r[0]))

        # Create the appropriate type of section for each valid directory:
        self._subsections = [
            sf.section for sf in [
                SectionFactory(title=title_for(title),
                               parent=self).trait_set(path=dir)
                for index, title, dir in dirs
                ] if sf.section is not None
            ]

    def _load_toc(self):
        """ Defines the section's subsections by finding matches for the items
            defined in the section's table of contents.
        """
        toc = self.toc
        base_names = [item.split(':', 1)[0] for item in toc]
        subsections = [None] * len(base_names)
        path = self.path

        # Classify all file names that match a base name in the table of
        # contents:
        for name in os.listdir(path):
            try:
                base_name = os.path.splitext(os.path.basename(name))[0]
                index = base_names.index(base_name)
                if subsections[index] is None:
                    subsections[index] = []
                subsections[index].append(name)
            except:
                pass

        # Try to convert each group of names into a section:
        for i, names in enumerate(subsections):

            # Only process items for which we found at least one matching file
            # name:
            if names is not None:

                # Get the title for the section from its table of contents
                # entry:
                parts = toc[i].split(':', 1)
                if len(parts) == 1:
                    title = title_for(parts[0].strip())
                else:
                    title = parts[1].strip()

                # Handle an item with one file which is a directory as a normal
                # section:
                if len(names) == 1:
                    dir = os.path.join(path, names[0])
                    if os.path.isdir(dir):
                        section = SectionFactory(title=title, parent=self)
                        subsections[i] = section.trait_set(path=dir).section
                        continue

                # Otherwise, create a section from the list of matching files:
                subsections[i] = SectionFactory(title=title,
                                                parent=self,
                                                files=names
                                                ).trait_set(path=path).section

        # Set the subsections to the non-None values that are left:
        self._subsections = [subsection for subsection in subsections
                             if subsection is not None]


#  'Lecture' class:
class Lecture(ASection):
    """ Defines a lecture, which is a section of a tutorial with descriptive
        information, but no associated Python code. Can be used to provide
        course overviews, introductory sections, or lead-ins to follow-on
        lessons or labs.
    """

    # The list of descriptive items for the lecture:
    descriptions = List(ATutorialItem)

    # This section can be executed (override):
    is_runnable = False

    view = View(
        Item('descriptions',
             style='custom',
             show_label=False,
             editor=list_editor
             ),
        id='enthought.tutor.lecture'
        )


#  'LabHandler' class:
class LabHandler(Handler):
    """ Defines the controller functions for the Lab view.
    """

    def init(self, info):
        """ Handles initialization of the view.
        """
        # Run the associated Python code if the 'auto-run' feature is enabled:
        if info.object.auto_run:
            info.object.run_code()


#  'Lab' class:
class Lab(ASection):
    """ Defines a lab, which is a section of a tutorial with only Python code.
        This type of section might typically follow a lecture which introduced
        the code being worked on in the lab.
    """

    # The set-up code (if any) for the lab:
    setup = Instance(CodeItem)

    # The list of code items for the lab:
    snippets = List(CodeItem)

    # The list of visible code items for the lab:
    visible_snippets = Property(depends_on='visible', cached=True)

    # The currently selected snippet:
    snippet = Instance(CodeItem)

    # Should normally hidden code items be shown?
    visible = Bool(False)

    # The dictionary containing the items from the Python code execution:
    values = Dict

    # The run Python code button:
    run = Button(image=ImageResource('run'), height_padding=1)

    # User error message:
    message = Str

    # The output produced while the program is running:
    output = Str

    # The current demo pane (if any):
    demo = Instance(DemoPane, ())

    view = View(
        VSplit(
            VGroup(
                Item('visible_snippets',
                     style='custom',
                     show_label=False,
                     editor=snippet_editor
                     ),
                HGroup(
                    Item('run',
                         style='custom',
                         show_label=False,
                         tooltip='Run the Python code'
                         ),
                    '_',
                    Item('message',
                         springy=True,
                         show_label=False,
                         editor=TitleEditor()
                         ),
                    '_',
                    Item('visible',
                         label='View hidden sections'
                         ),
                    ),
                ),
            Tabbed(
                Item('values',
                     id='values_1',
                     label='Shell',
                     editor=ShellEditor(share=True),
                     dock='tab',
                     export='DockWindowShell',
                     ),
                Item('values',
                     id='values_2',
                     editor=ValueEditor(),
                     dock='tab',
                     export='DockWindowShell',
                     ),
                Item('output',
                     style='readonly',
                     editor=CodeEditor(show_line_numbers=False,
                                       selected_color=0xFFFFFF),
                     dock='tab',
                     export='DockWindowShell',
                     ),
                Item('demo',
                     id='demo',
                     style='custom',
                     resizable=True,
                     dock='tab',
                     export='DockWindowShell',
                     ),
                show_labels=False,
                ),
            id='splitter',
            ),
        id='enthought.tutor.lab',
        handler=LabHandler
        )

    def _run_changed(self):
        """ Runs the current set of snippet code.
        """
        self.run_code()

    @cached_property
    def _get_visible_snippets(self):
        """ Returns the list of code items that are currently visible.
        """
        if self.visible:
            return self.snippets

        return [snippet for snippet in self.snippets if (not snippet.hidden)]

    def run_code(self):
        """ Runs all of the code snippets associated with the section.
        """
        # Reconstruct the lab code from the current set of code snippets:
        start_line = 1
        module = ''
        for snippet in self.snippets:
            snippet.start_line = start_line
            module = '%s\n\n%s' % (module, snippet.content)
            start_line += (snippet.content.count('\n') + 2)

        # Reset any syntax error and message log values:
        self.message = self.output = ''

        # Redirect standard out and error to the message log:
        stdout, stderr = sys.stdout, sys.stderr
        sys.stdout = sys.stderr = StdOut(self)

        try:
            try:
                # Get the execution context dictionary:
                values = self.values

                # Clear out any special variables defined by the last run:
                for name in ('demo', 'popup'):
                    if isinstance(values.get(name), HasTraits):
                        del values[name]

                # Execute the current lab code:
                exec (module[2:] in values, values)

                # fixme: Hack trying to update the Traits UI view of the dict.
                self.values = {}
                self.values = values

                # Handle a 'demo' value being defined:
                demo = values.get('demo')
                if not isinstance(demo, HasTraits):
                    demo = NoDemo()
                self.demo.demo = demo

                # Handle a 'popup' value being defined:
                popup = values.get('popup')
                if isinstance(popup, HasTraits):
                    popup.edit_traits(kind='livemodal')

            except SyntaxError, excp:
                # Convert the line number of the syntax error from one in the
                # composite module to one in the appropriate code snippet:
                line = excp.lineno
                if line is not None:
                    snippet = self.snippets[0]
                    for s in self.snippets:
                        if s.start_line > line:
                            break
                        snippet = s
                    line -= (snippet.start_line - 1)

                    # Highlight the line in error:
                    snippet.selected_line = line

                    # Select the correct code snippet:
                    self.snippet = snippet

                    # Display the syntax error message:
                    self.message = '%s in column %s of line %s' % (
                                   excp.msg.capitalize(), excp.offset, line)
                else:
                    # Display the syntax error message without line # info:
                    self.message = excp.msg.capitalize()
            except:
                import traceback
                traceback.print_exc()
        finally:
            # Restore standard out and error to their original values:
            sys.stdout, sys.stderr = stdout, stderr


#  'Lesson' class:
class Lesson(Lab):
    """ Defines a lesson, which is a section of a tutorial with both
        descriptive information and associated Python code.
    """

    # The list of descriptive items for the lesson:
    descriptions = List(ATutorialItem)

    view = View(
        HSplit(
            Item('descriptions',
                 label='Lesson',
                 style='custom',
                 show_label=False,
                 dock='horizontal',
                 editor=list_editor,
                 ),
            VSplit(
                VGroup(
                    Item('visible_snippets',
                         style='custom',
                         show_label=False,
                         editor=snippet_editor,
                         ),
                    HGroup(
                        Item('run',
                             style='custom',
                             show_label=False,
                             tooltip='Run the Python code',
                             ),
                        '_',
                        Item('message',
                             springy=True,
                             show_label=False,
                             editor=TitleEditor(),
                             ),
                        '_',
                        Item('visible',
                             label='View hidden sections',
                             ),
                        ),
                    label='Lab',
                    dock='horizontal',
                    ),
                Tabbed(
                    Item('values',
                         id='values_1',
                         label='Shell',
                         editor=ShellEditor(share=True),
                         dock='tab',
                         export='DockWindowShell',
                         ),
                    Item('values',
                         id='values_2',
                         editor=ValueEditor(),
                         dock='tab',
                         export='DockWindowShell',
                         ),
                    Item('output',
                         style='readonly',
                         editor=CodeEditor(show_line_numbers=False,
                                           selected_color=0xFFFFFF),
                         dock='tab',
                         export='DockWindowShell',
                         ),
                    Item('demo',
                         id='demo',
                         style='custom',
                         resizable=True,
                         dock='tab',
                         export='DockWindowShell',
                         ),
                    show_labels=False,
                    ),
                label='Lab',
                dock='horizontal',
                ),
            id='splitter',
            ),
        id='enthought.tutor.lesson',
        handler=LabHandler,
        )


#  'Demo' class:
class Demo(Lesson):
    """ Defines a demo, which is a section of a tutorial with both descriptive
        information and associated Python code which is executed but not
        shown.
    """

    view = View(
        HSplit(
            Item('descriptions',
                 label='Lesson',
                 style='custom',
                 show_label=False,
                 dock='horizontal',
                 editor=list_editor,
                 ),
            Item('demo',
                 id='demo',
                 style='custom',
                 show_label=False,
                 resizable=True,
                 dock='horizontal',
                 export='DockWindowShell',
                 ),
            id='splitter',
            ),
        id='enthought.tutor.demo',
        handler=LabHandler,
        )


#  'SectionFactory' class:
class SectionFactory(HasPrivateTraits):
    """ Defines a class that creates Lecture, Lesson or Lab sections (or None),
        based on the content of a specified directory. None is returned if the
        directory does not contain any recognized files.
    """

    # The path the section is to be created for:
    path = Directory

    # The list of files contained in the section:
    files = List(Str)

    # The parent of the section being created:
    parent = Instance(ASection)

    # The section created from the path:
    section = Instance(ASection)

    # The title for the section:
    title = Str

    # The optional table of contents for the section:
    toc = List(Str)

    # The list of descriptive items for the section:
    descriptions = List(ADescriptionItem)

    # The list of code snippet items for the section:
    snippets = List(CodeItem)

    # The path to the CSS style sheet for the section:
    css_path = Property

    # Should the Python code be automatically executed on start-up?
    auto_run = Bool(False)

    def _path_changed(self, path):
        """ Creates the appropriate section based on the value of the path.
        """
        # Get the list of files to process:
        files = self.files
        if len(files) == 0:
            # If none were specified, then use all files in the directory:
            files = os.listdir(path)

            # Process the description file (if any) first:
            for name in files:
                if os.path.splitext(name)[1] == '.desc':
                    self._add_desc_item(os.path.join(path, name))
                    break

        # Try to convert each file into one or more 'xxxItem' objects:
        toc = [item.split(':', 1)[0].strip() for item in self.toc]
        for name in files:
            file_name = os.path.join(path, name)

            # Only process the ones that are actual files:
            if os.path.isfile(file_name):

                # Use the file extension to determine the file's type:
                root, ext = os.path.splitext(name)
                if (root not in toc) and (len(ext) > 1):

                    # If we have a handler for the file type, invoke it:
                    method = getattr(self, '_add_%s_item' % ext[1:].lower(),
                                     None)
                    if method is not None:
                        method(file_name)

        # Based on the type of items created (if any), create the corresponding
        # type of section:
        if len(self.descriptions) > 0:
            if len(self.snippets) > 0:
                if len([snippet for snippet in self.snippets
                        if (not snippet.hidden)]) > 0:
                    self.section = Lesson(
                        title=self.title,
                        path=path,
                        toc=self.toc,
                        parent=self.parent,
                        descriptions=self.descriptions,
                        snippets=self.snippets,
                        auto_run=self.auto_run,
                        )
                else:
                    self.section = Demo(
                        title=self.title,
                        path=path,
                        toc=self.toc,
                        parent=self.parent,
                        descriptions=self.descriptions,
                        snippets=self.snippets,
                        auto_run=True,
                        )
            else:
                self.section = Lecture(
                    title=self.title,
                    path=path,
                    toc=self.toc,
                    parent=self.parent,
                    descriptions=self.descriptions,
                    )
        elif len(self.snippets) > 0:
            self.section = Lab(
                title=self.title,
                path=path,
                toc=self.toc,
                parent=self.parent,
                snippets=self.snippets,
                auto_run=self.auto_run,
                )
        else:
            # No descriptions or code snippets were found. Create a lecture
            # anyway:
            section = Lecture(
                title=self.title,
                path=path,
                toc=self.toc,
                parent=self.parent,
                )

            # If the lecture has subsections, then return the lecture and add
            # a default item containing a description of the subsections of the
            # lecture:
            if len(section.subsections) > 0:
                self._create_html_item(
                    path=path,
                    content=DefaultLecture % ('\n'.join(
                        ['<li>%s</li>' % subsection.title
                         for subsection in section.subsections]))
                    )
                section.descriptions = self.descriptions
                self.section = section

    def _get_css_path(self):
        """ Returns the path to the CSS style sheet for the section.
        """
        return css_path_for(self.path, self.parent)

    def _add_py_item(self, path):
        """ Creates the code snippets for a Python source file.
        """
        source = read_file(path)
        if source is not None:
            lines = source.replace('\r', '').split('\n')
            start_line = 0
            title = 'Prologue'
            type = IsCode

            for i, line in enumerate(lines):
                match = section_pat1.match(line)
                if match is not None:
                    next_type = IsCode
                else:
                    match = section_pat2.match(line)
                    if match is not None:
                        next_type = IsHiddenCode
                    else:
                        next_type = IsDescription
                        match = section_pat3.match(line)

                if match is not None:
                    self._add_snippet(title, path, lines, start_line, i - 1,
                                      type)
                    start_line = i + 1
                    title = match.group(1).strip()
                    type = next_type

            self._add_snippet(title, path, lines, start_line, i, type)

    def _add_txt_item(self, path):
        """ Creates a description item for a normal text file.
        """
        self.descriptions.append(TextItem(path=path))

    def _add_htm_item(self, path):
        """ Creates a description item for an HTML file.
        """
        # Check if there is a corresponding .rst (restructured text) file:
        dir, base_name = os.path.split(path)
        rst = os.path.join(dir, os.path.splitext(base_name)[0] + '.rst')

        # If no .rst file exists, just add the file as a normal HTML file:
        if not os.path.isfile(rst):
            self._create_html_item(path=path)

    def _add_html_item(self, path):
        """ Creates a description item for an HTML file.
        """
        self._add_htm_item(path)

    def _add_url_item(self, path):
        """ Creates a description item for a file containing URLs.
        """
        data = read_file(path)
        if data is not None:
            for url in [line for line in data.split('\n')
                        if line.strip()[:1] not in ('', '#')]:
                self._create_html_item(url=url.strip())

    def _add_rst_item(self, path):
        """ Creates a description item for a ReSTructured text file.
        """
        # If docutils is not installed, just process the file as an ordinary
        # text file:
        try:
            from docutils.core import publish_cmdline
        except:
            self._add_txt_item(path)
            return

        # Get the name of the HTML file we will write to:
        dir, base_name = os.path.split(path)
        html = os.path.join(dir, os.path.splitext(base_name)[0] + '.htm')

        # Try to find a CSS style sheet, and set up the docutil overrides if
        # found:
        settings = {}
        css_path = self.css_path
        if css_path != '':
            css_path = os.path.join(self.path, css_path)
            settings['stylesheet_path'] = css_path
            settings['embed_stylesheet'] = True
            settings['stylesheet'] = None
        else:
            css_path = path

        # If the HTML file does not exist, or is older than the restructured
        # text file, then let docutils convert it to HTML:
        is_file = os.path.isfile(html)
        if ((not is_file) or
                (os.path.getmtime(path) > os.path.getmtime(html)) or
                (os.path.getmtime(css_path) > os.path.getmtime(html))):

            # Delete the current HTML file (if any):
            if is_file:
                os.remove(html)

            # Let docutils create a new HTML file from the restructured text
            # file:
            publish_cmdline(writer_name='html',
                            argv=[path, html],
                            settings_overrides=settings)

        if os.path.isfile(html):
            # If there is now a valid HTML file, use it:
            self._create_html_item(path=html)

        else:
            # Otherwise, just use the original restructured text file:
            self._add_txt_item(path)

    def _add_swf_item(self, path):
        """ Creates a description item for a Flash file.
        """
        if is_windows:
            self.descriptions.append(FlashItem(path=path))

    def _add_mov_item(self, path):
        """ Creates a description item for a QuickTime movie file.
        """
        path2 = path.replace(':', '|')
        self._create_html_item(path=path,
                               content=QTMovieTemplate % (path2, path2))

    def _add_wmv_item(self, path):
        """ Creates a description item for a Windows movie file.
        """
        self._create_html_item(path=path,
                               content=WMVMovieTemplate % (path, path))

    def _add_avi_item(self, path):
        """ Creates a description item for an AVI movie file.
        """
        self._add_wmv_item(path)

    def _add_jpg_item(self, path):
        """ Creates a description item for a JPEG image file.
        """
        self._create_html_item(path=path,
                               content=ImageTemplate % path)

    def _add_jpeg_item(self, path):
        """ Creates a description item for a JPEG image file.
        """
        self._add_jpg_item(path)

    def _add_png_item(self, path):
        """ Creates a description item for a PNG image file.
        """
        self._add_jpg_item(path)

    def _add_mp3_item(self, path):
        """ Creates a description item for an mp3 audio file.
        """
        self._create_html_item(path=path,
                               content=MP3Template % path)

    def _add_desc_item(self, path):
        """ Creates a section title from a description file.
        """
        # If we've already processed a description file, then we're done:
        if len(self.toc) > 0:
            return

        lines = []
        desc = read_file(path)
        if desc is not None:
            # Split the file into lines and save the non-empty, non-comment
            # lines:
            for line in desc.split('\n'):
                line = line.strip()
                if (len(line) > 0) and (line[0] != '#'):
                    lines.append(line)

        if len(lines) == 0:
            # If the file didn't have anything useful in it, set a title based
            # on the description file name:
            self.title = title_for(os.path.splitext(os.path.basename(path))[0])
        else:
            # Otherwise, set the title and table of contents from the lines in
            # the file:
            self.title = lines[0]
            self.toc = lines[1:]

    def _add_snippet(self, title, path, lines, start_line, end_line, type):
        """ Adds a new code snippet or restructured text item to the list of
            code snippet or description items.
        """
        # Trim leading and trailing blank lines from the snippet:
        while start_line <= end_line:
            if lines[start_line].strip() != '':
                break
            start_line += 1

        while end_line >= start_line:
            if lines[end_line].strip() != '':
                break
            end_line -= 1

        # Only add if the snippet is not empty:
        if start_line <= end_line:

            # Check for the title containing the 'auto-run' flag ('*'):
            if title[:1] == '*':
                self.auto_run = True
                title = title[1:].strip()

            if title[-1:] == '*':
                self.auto_run = True
                title = title[:-1].strip()

            # Extract out just the lines we will use:
            content_lines = lines[start_line: end_line + 1]

            if type == IsDescription:
                # Add the new restructured text description:
                self._add_description(content_lines, title)
            else:
                # Add the new code snippet:
                self.snippets.append(CodeItem(
                    title=title or 'Code',
                    path=path,
                    hidden=(type == IsHiddenCode),
                    content='\n'.join(content_lines)
                    ))

    def _add_description(self, lines, title):
        """ Converts a restructured text string to HTML and adds it as
            description item.
        """
        # Scan the lines for any imbedded Python code that should be shown as
        # a separate snippet:
        i = 0
        while i < len(lines):
            if lines[i].strip()[-2:] == '::':
                i = self._check_embedded_code(lines, i + 1)
            else:
                i += 1

        # Strip off any docstring style triple quotes (if necessary):
        content = '\n'.join(lines).strip()
        if content[:3] in ('"""', "'''"):
            content = content[3:]

        if content[-3:] in('"""', "'''"):
            content = content[:-3]

        content = content.strip()

        # If docutils is not installed, just add it as a text string item:
        try:
            from docutils.core import publish_string
        except:
            self.descriptions.append(TextStrItem(content=content,
                                                 title=title))
            return

        # Try to find a CSS style sheet, and set up the docutil overrides if
        # found:
        settings = {}
        css_path = self.css_path
        if css_path != '':
            css_path = os.path.join(self.path, css_path)
            settings['stylesheet_path'] = css_path
            settings['embed_stylesheet'] = True
            settings['stylesheet'] = None

        # Convert it from restructured text to HTML:
        html = publish_string(content, writer_name='html',
                              settings_overrides=settings)

        # Choose the right HTML renderer:
        if is_windows:
            item = IEHTMLStrItem(content=html, title=title)
        else:
            item = HTMLStrItem(content=html, title=title)

        # Add the resulting item to the descriptions list:
        self.descriptions.append(item)

    def _create_html_item(self, **traits):
        """ Creates a platform specific html item and adds it to the list of
            descriptions.
        """
        if is_windows:
            item = IEHTMLItem(**traits)
        else:
            item = HTMLItem(**traits)

        self.descriptions.append(item)

    def _check_embedded_code(self, lines, start):
        """ Checks for an embedded Python code snippet within a description.
        """
        n = len(lines)
        while start < n:
            line = lines[start].strip()

            if line == '':
                start += 1
                continue

            if (line[:1] != '[') or (line[-1:] != ']'):
                break

            del lines[start]

            n -= 1
            title = line[1:-1].strip()
            line = lines[start] + '.'
            pad = len(line) - len(line.strip())
            clines = []

            while start < n:
                line = lines[start] + '.'
                len_line = len(line.strip())
                if (len_line > 1) and ((len(line) - len_line) < pad):
                    break

                if (len(clines) > 0) or (len_line > 1):
                    clines.append(line[pad: -1])

                start += 1

            # Add the new code snippet:
            self.snippets.append(CodeItem(
                title=title or 'Code',
                content='\n'.join(clines)
                ))

            break

        return start

#  Tutor tree editor:

tree_editor = TreeEditor(
    nodes=[
        TreeNode(
            children='subsections',
            label='title',
            rename=False,
            copy=False,
            delete=False,
            delete_me=False,
            insert=False,
            auto_open=True,
            auto_close=False,
            node_for=[ASection],
            icon_group='<group>'
            )
        ],
    editable=False,
    auto_open=1,
    selected='section'
    )


#  'Tutor' class:
class Tutor(HasPrivateTraits):
    """ The main tutorial class which manages the presentation and navigation
        of the entire tutorial.
    """

    # The path to the files distributed with the tutor:
    home = Directory

    # The path to the root of the tutorial tree:
    path = Directory

    # The root of the tutorial lesson tree:
    root = Instance(ASection)

    # The current section of the tutorial being displayed:
    section = Instance(ASection)

    # The next section:
    next_section = Property(depends_on='section', cached=True)

    # The previous section:
    previous_section = Property(depends_on='section', cached=True)

    # The previous section button:
    previous = Button(image=ImageResource('previous'), height_padding=1)

    # The next section button:
    next = Button(image=ImageResource('next'), height_padding=1)

    # The parent section button:
    parent = Button(image=ImageResource('parent'), height_padding=1)

    # The reload tutor button:
    reload = Button(image=ImageResource('reload'), height_padding=1)

    # The title of the current session:
    title = Property(depends_on='section')

    view = View(
        VGroup(
            HGroup(
                Item('previous',
                     style='custom',
                     enabled_when='previous_section is not None',
                     tooltip='Go to previous section',
                     ),
                Item('parent',
                     style='custom',
                     enabled_when='(section is not None) and '
                                  '(section.parent is not None)',
                     tooltip='Go up one level',
                     ),
                Item('next',
                     style='custom',
                     enabled_when='next_section is not None',
                     tooltip='Go to next section',
                     ),
                '_',
                Item('title',
                     springy=True,
                     editor=TitleEditor(),
                     ),
                '_',
                Item('reload',
                     style='custom',
                     tooltip='Reload the tutorial',
                     ),
                show_labels=False,
                ),
            '_',
            HSplit(
                Item('root',
                     label='Table of Contents',
                     editor=tree_editor,
                     dock='horizontal',
                     export='DockWindowShell',
                     ),
                Item('section',
                     id='section',
                     label='Current Lesson',
                     style='custom',
                     resizable=True,
                     dock='horizontal',
                     ),
                id='splitter',
                show_labels=False,
                )
            ),
        title='Python Tutor',
        id='dmorrill.tutor.tutor:1.0',
        buttons=NoButtons,
        resizable=True,
        width=0.8,
        height=0.8,
        )

    def _path_changed(self, path):
        """ Handles the tutorial root path being changed.
        """
        self.init_tutor()

    def _next_changed(self):
        """ Displays the next tutorial section.
        """
        self.section = self.next_section

    def _previous_changed(self):
        """ Displays the previous tutorial section.
        """
        self.section = self.previous_section

    def _parent_changed(self):
        """ Displays the parent of the current tutorial section.
        """
        self.section = self.section.parent

    def _reload_changed(self):
        """ Reloads the tutor from the original path specified.
        """
        self.init_tutor()

    @cached_property
    def _get_next_section(self):
        """ Returns the next section of the tutorial.
        """
        next = None
        section = self.section
        if len(section.subsections) > 0:
            next = section.subsections[0]
        else:
            parent = section.parent
            while parent is not None:
                index = parent.subsections.index(section)
                if index < (len(parent.subsections) - 1):
                    next = parent.subsections[index + 1]
                    break

                parent, section = parent.parent, parent

        return next

    @cached_property
    def _get_previous_section(self):
        """ Returns the previous section of the tutorial.
        """
        previous = None
        section = self.section
        parent = section.parent
        if parent is not None:
            index = parent.subsections.index(section)
            if index > 0:
                previous = parent.subsections[index - 1]
                while len(previous.subsections) > 0:
                    previous = previous.subsections[-1]
            else:
                previous = parent

        return previous

    def _get_title(self):
        """ Returns the title of the current section.
        """
        section = self.section
        if section is None:
            return ''

        return ('%s: %s' % (section.__class__.__name__, section.title))

    def init_tutor(self):
        """ Initials the tutor by creating the root section from the specified
            path.
        """
        path = self.path
        title = title_for(os.path.splitext(os.path.basename(path))[0])
        section = SectionFactory(title=title).trait_set(path=path).section
        if section is not None:
            self.section = self.root = section

#  Run the program:

# Only run the program if we were invoked from the command line:
if __name__ == '__main__':

    # Validate the command line arguments:
    if len(sys.argv) > 2:
        print Usage
        sys.exit(1)

    # Determine the root path to use for the tutorial files:
    if len(sys.argv) == 2:
        path = sys.argv[1]
    else:
        path = os.getcwd()

    # Create a tutor and display the tutorial:
    tutor = Tutor(home=os.path.dirname(sys.argv[0])).trait_set(path=path)
    if tutor.root is not None:
        tutor.configure_traits()
    else:
        print """No traits tutorial found in %s.

Correct usage is: python tutor.py [tutorial_path]
where: tutorial_path = Path to the root of the traits tutorial.

If tutorial_path is omitted, the current directory is assumed to be the root of
the tutorial.""" % path
