#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright (C) 2006-2009 Derrick Moser <derrick_moser@yahoo.com>
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the licence, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program.  You may also obtain a copy of the GNU General Public License
# from the Free Software Foundation by visiting their web site
# (http://www.fsf.org/) or by writing to the Free Software Foundation, Inc.,
# 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import gettext
import sys

# translation location: '../share/locale/<LANG>/LC_MESSAGES/diffuse.mo'
# where '<LANG>' is the language key
gettext.textdomain('diffuse')
_ = gettext.gettext

APP_NAME = 'Diffuse'
VERSION = '0.3.1'
COPYRIGHT = _('Copyright © 2006-2009 Derrick Moser')
WEBSITE = 'http://diffuse.sourceforge.net/'

# process help options
if __name__ == '__main__':
    args = sys.argv
    argc = len(args)
    if argc == 2 and args[1] in [ '-v', '--version' ]:
        print '%s %s\n%s' % (APP_NAME, VERSION, COPYRIGHT)
        sys.exit(0)
    if argc == 2 and args[1] in [ '-h', '-?', '--help' ]:
        print _("""Usage:
    diffuse [ [OPTION...] [FILE...] ]...
    diffuse ( -h | -? | --help | -v | --version )

Diffuse is a graphical tool for merging and comparing text files.  Diffuse is
able to compare an arbitrary number of files side-by-side and gives users the
ability to manually adjust line matching and directly edit files.  Diffuse can
also retrieve revisions of files from Bazaar, CVS, Darcs, Git, Mercurial,
Monotone, Subversion, and SVK repositories for comparison and merging.

Help Options:
  ( -h | -? | --help )             Display this usage information
  ( -v | --version )               Display version and copyright information

Configuration Options:
  --no-rcfile                      Do not read any resource files
  --rcfile <file>                  Specify explicit resource file

General Options:
  ( -c | --commit ) <rev>          File revisions <rev-1> and <rev>
  ( -D | --close-if-same )         Close all tabs with no differences
  ( -e | --encoding ) <codec>      Use <codec> to read and write files
  ( -r | --revision ) <rev>        File revision <rev>
  ( -s | --separate )              Create a separate tab for each file
  ( -t | --tab )                   Start a new tab

Display Options:
  ( -b | --ignore-space-change )   Ignore changes to white space
  ( -B | --ignore-blank-lines )    Ignore changes in blank lines
  ( -i | --ignore-case )           Ignore case differences
  ( -w | --ignore-all-space )      Ignore white space differences

Interactive Mode Navigation:
  Line Editing Mode
    <enter>   - Enter character editing mode
    <space>   - Enter alignment editing mode
  Character Editing Mode
    <escape>  - Return to line editing mode
  Alignment Editing Mode
    <space>   - Align and return to line editing mode
    <escape>  - Return to line editing mode""")
        sys.exit(0)

import pygtk
pygtk.require('2.0')
import gtk

import codecs
import difflib
import encodings
import glob
import gobject
import locale
import os
import pango
import re
import shlex
import stat
import string
import subprocess
import urllib
import webbrowser

# platform tests
def isWindows():
    return os.name == 'nt'

def isCygwin():
    return sys.platform == 'cygwin'

if not hasattr(__builtins__, 'WindowsError'):
    # define 'WindowsError' so 'except' statements will work on all platforms
    WindowsError = IOError

# use the program's location as a starting place to search for supporting files
# such as icon and help documentation
if hasattr(sys, 'frozen'):
    bin_dir = os.path.dirname(sys.executable)
else:
    bin_dir = os.path.dirname(sys.argv[0])

# avoid some dictionary lookups when string.whitespace is used in loops
whitespace = string.whitespace

# colour resources
class Colour:
    def __init__(self, r, g, b, a=1.0):
        # the individual colour components as floats in the range [0, 1]
        self.red = r
        self.green = g
        self.blue = b
        self.alpha = a

    # multiply by scalar
    def __mul__(self, s):
        return Colour(s * self.red, s * self.green, s * self.blue, s * self.alpha)

    # add colours
    def __add__(self, other):
        return Colour(self.red + other.red, self.green + other.green, self.blue + other.blue, self.alpha + other.alpha)

    # over operator
    def over(self, other):
        a = self.alpha
        c = self + other * (1 - a)
        c.alpha = 1 - (1 - a) * (1 - other.alpha)
        return c

# class to build and run a finite state machine for identifying syntax tokens
class SyntaxParser:
    # create a new state machine that begins in initial_state and classifies
    # all characters not matched by the patterns as default_token_type
    def __init__(self, initial_state, default_token_type):
        # initial state for the state machine when parsing a new file
        self.initial_state = initial_state
        # default classification of characters that are not explicitly matched
        # by any state transition patterns
        self.default_token_type = default_token_type
        # mappings from a state to a list of (pattern, token_type, next_state)
        # tuples indicating the new state for the state machine when 'pattern'
        # is matched and how to classify the matched characters
        self.transitions_lookup = { initial_state : [] }

    # Adds a new edge to the finite state machine from prev_state to
    # next_state.  Characters will be identified as token_type when pattern is
    # matched.  Any newly referenced state will be added.  Patterns for edges
    # leaving a state will be tested in the order they were added to the finite
    # state machine.
    def addPattern(self, prev_state, next_state, token_type, pattern):
        lookup = self.transitions_lookup
        for state in prev_state, next_state:
            if not lookup.has_key(state):
                lookup[state] = []
        lookup[prev_state].append([pattern, token_type, next_state])

    # given a string and an initial state, idenitify the final state and tokens
    def parse(self, state_name, s):
        lookup = self.transitions_lookup
        transitions, blocks, start = lookup[state_name], [], 0
        while start < len(s):
            for pattern, token_type, next_state in transitions:
                m = pattern.match(s, start)
                if m is not None:
                     end, state_name = m.span()[1], next_state
                     transitions = lookup[state_name]
                     break
            else:
                end, token_type = start + 1, self.default_token_type
            if len(blocks) > 0 and blocks[-1][2] == token_type:
                blocks[-1][1] = end
            else:
                blocks.append([start, end, token_type])
            start = end
        return state_name, blocks

# This class to hold all customisable behaviour not exposed in the preferences
# dialogue: hotkey assignment, colours, syntax highlighting, etc.
# Syntax highlighting is implemented in supporting '*.syntax' files normally
# read from the system wide initialisation file '/etc/diffuserc'.
# The personal initialisation file '~/diffuse/diffuserc' can be used to change
# default behaviour.
class Resources:
    def __init__(self):
        # default keybindings
        self.keybindings = {}
        self.keybindings_lookup = {}
        set_binding = self.setKeyBinding
        set_binding('menu', 'open_file', 'Ctrl+o')
        set_binding('menu', 'reload_file', 'Shift+Ctrl+R')
        set_binding('menu', 'save_file', 'Ctrl+s')
        set_binding('menu', 'save_file_as', 'Shift+Ctrl+S')
        set_binding('menu', 'new_2_way_file_merge', 'Ctrl+2')
        set_binding('menu', 'new_3_way_file_merge', 'Ctrl+3')
        set_binding('menu', 'quit', 'Ctrl+q')
        set_binding('menu', 'undo', 'Ctrl+z')
        set_binding('menu', 'redo', 'Shift+Ctrl+Z')
        set_binding('menu', 'cut', 'Ctrl+x')
        set_binding('menu', 'copy', 'Ctrl+c')
        set_binding('menu', 'paste', 'Ctrl+v')
        set_binding('menu', 'select_all', 'Ctrl+a')
        set_binding('menu', 'find', 'Ctrl+f')
        set_binding('menu', 'find_next', 'Ctrl+g')
        set_binding('menu', 'find_previous', 'Shift+Ctrl+G')
        set_binding('menu', 'go_to_line', 'Shift+Ctrl+l')
        set_binding('menu', 'decrease_indenting', 'Shift+Ctrl+less')
        set_binding('menu', 'increase_indenting', 'Shift+Ctrl+greater')
        set_binding('menu', 'convert_to_dos', 'Shift+Ctrl+E')
        set_binding('menu', 'convert_to_unix', 'Ctrl+e')
        set_binding('menu', 'previous_tab', 'Ctrl+Page_Up')
        set_binding('menu', 'next_tab', 'Ctrl+Page_Down')
        set_binding('menu', 'close_tab', 'Ctrl+w')
        set_binding('menu', 'shift_pane_left', 'Shift+Ctrl+Left')
        set_binding('menu', 'shift_pane_right', 'Shift+Ctrl+Right')
        set_binding('menu', 'preferences', 'Ctrl+p')
        set_binding('menu', 'realign_all', 'Ctrl+l')
        set_binding('menu', 'first_difference', 'Shift+Ctrl+Up')
        set_binding('menu', 'previous_difference', 'Ctrl+Up')
        set_binding('menu', 'next_difference', 'Ctrl+Down')
        set_binding('menu', 'last_difference', 'Shift+Ctrl+Down')
        set_binding('menu', 'clear_edits', 'Ctrl+r')
        set_binding('menu', 'merge_from_left', 'Ctrl+Left')
        set_binding('menu', 'merge_from_right', 'Ctrl+Right')
        set_binding('menu', 'isolate', 'Ctrl+i')
        set_binding('menu', 'help_contents', 'F1')
        set_binding('line_mode', 'enter_align_mode', 'space')
        set_binding('line_mode', 'enter_character_mode', 'Return')
        set_binding('line_mode', 'up', 'Up')
        set_binding('line_mode', 'extend_up', 'Shift+Up')
        set_binding('line_mode', 'down', 'Down')
        set_binding('line_mode', 'extend_down', 'Shift+Down')
        set_binding('line_mode', 'left', 'Left')
        set_binding('line_mode', 'extend_left', 'Shift+Left')
        set_binding('line_mode', 'right', 'Right')
        set_binding('line_mode', 'extend_right', 'Shift+Right')
        set_binding('line_mode', 'page_up', 'Page_Up')
        set_binding('line_mode', 'extend_page_up', 'Shift+Page_Up')
        set_binding('line_mode', 'page_down', 'Page_Down')
        set_binding('line_mode', 'extend_page_down', 'Shift+Page_Down')
        set_binding('line_mode', 'delete_text', 'BackSpace')
        set_binding('line_mode', 'delete_text', 'Delete')
        set_binding('line_mode', 'first_line', 'Home')
        set_binding('line_mode', 'extend_first_line', 'Shift+Home')
        set_binding('line_mode', 'last_line', 'End')
        set_binding('line_mode', 'extend_last_line', 'Shift+End')
        set_binding('line_mode', 'first_difference', 'Ctrl+Home')
        set_binding('line_mode', 'last_difference', 'Ctrl+End')
        set_binding('align_mode', 'enter_line_mode', 'Escape')
        set_binding('align_mode', 'enter_character_mode', 'Return')
        set_binding('align_mode', 'up', 'Up')
        set_binding('align_mode', 'down', 'Down')
        set_binding('align_mode', 'left', 'Left')
        set_binding('align_mode', 'right', 'Right')
        set_binding('align_mode', 'page_up', 'Page_Up')
        set_binding('align_mode', 'page_down', 'Page_Down')
        set_binding('align_mode', 'align', 'space')
        set_binding('character_mode', 'enter_line_mode', 'Escape')

        # default colours
        self.colours = {
            'align' : Colour(1.0, 1.0, 0.0),
            'char_selection' : Colour(0.7, 0.7, 1.0),
            'cursor' : Colour(0.0, 0.0, 0.0),
            'difference_1' : Colour(1.0, 0.625, 0.625),
            'difference_2' : Colour(0.85, 0.625, 0.775),
            'difference_3' : Colour(0.85, 0.775, 0.625),
            'hatch' : Colour(0.8, 0.8, 0.8),
            'line_number' : Colour(0.0, 0.0, 0.0),
            'line_number_background' : Colour(0.75, 0.75, 0.75),
            'line_selection' : Colour(0.7, 0.7, 1.0),
            'map_background' : Colour(0.6, 0.6, 0.6),
            'modified' : Colour(0.5, 1.0, 0.5),
            'text' : Colour(0.0, 0.0, 0.0),
            'text_background' : Colour(1.0, 1.0, 1.0) }
        # list of unknown colour resources we have already issued warnings for
        self.unknown_colours = {}

        # default floats
        self.floats = {
           'align_alpha' : 1.0,
           'char_difference_alpha' : 0.4,
           'char_selection_alpha' : 0.4,
           'line_difference_alpha' : 0.3,
           'line_selection_alpha' : 0.4,
           'modified_alpha' : 0.4 }
        # list of unknown float resources we have already issued warnings for
        self.unknown_floats = {}

        # handle platform specific icon
        if isWindows():
            iconpath = os.path.join(bin_dir, 'diffuse.ico')
        else:
            iconpath = os.path.join(bin_dir, '../share/pixmaps/diffuse.png')
        # default strings
        self.strings = {
           'help_browser': '',
           'help_file': '',
           'help_url': WEBSITE + 'manual.html',
           'icon': iconpath }

        # list of unknown string resources we have already issued warnings for
        self.unknown_strings = {}

        self.default_colour = Colour(0.0, 0.0, 0.0)
        self.char_classes = {}

        # syntax highlighting support
        self.syntaxes = {}
        self.syntax_file_patterns = {}
        self.current_syntax = None

        # list of imported resources files (we only import each file once)
        self.resource_files = {}

        # special string resources
        self.setDifferenceColours('difference_1 difference_2 difference_3')
        self.setCharacterClasses('48-57:48 65-90:48 97-122:48 95:48 9:32')

    # keyboard action processing
    def setKeyBinding(self, ctx, s, v):
        action_tuple = (ctx, s)
        modifiers = 0
        key = None
        for token in v.split('+'):
            if token == 'Shift':
                modifiers |= gtk.gdk.SHIFT_MASK
            elif token == 'Ctrl':
                modifiers |= gtk.gdk.CONTROL_MASK
            elif len(token) == 0 or token[0] == '_':
                raise ValueError()
            else:
                if token[0].isdigit():
                    token = '_' + token
                if not hasattr(gtk.keysyms, token):
                   raise ValueError()
                key = getattr(gtk.keysyms, token)
        if key is None:
           raise ValueError()
        key_tuple = (ctx, (key, modifiers))

        # remove any existing binding
        if self.keybindings_lookup.has_key(key_tuple):
            self._removeKeyBinding(key_tuple)

        # ensure we have a set to hold this action
        if not self.keybindings.has_key(action_tuple):
            self.keybindings[action_tuple] = {}
        bindings = self.keybindings[action_tuple]

        # menu items can only have one binding
        if ctx == 'menu':
            for k in bindings.keys():
                self._removeKeyBinding(k)

        # add the binding
        bindings[key_tuple] = None
        self.keybindings_lookup[key_tuple] = action_tuple

    def _removeKeyBinding(self, key_tuple):
        action_tuple = self.keybindings_lookup[key_tuple]
        del self.keybindings_lookup[key_tuple]
        del self.keybindings[action_tuple][key_tuple]

    def getActionForKey(self, ctx, key, modifiers):
        tuple = (ctx, (key, modifiers))
        if self.keybindings_lookup.has_key(tuple):
            return self.keybindings_lookup[tuple][1]

    def getKeyBindings(self, ctx, s):
        tuple = (ctx, s)
        if self.keybindings.has_key(tuple):
            return [ t for c, t in self.keybindings[tuple].keys() ]
        return []

    # colours used for indicating differences
    def setDifferenceColours(self, s):
        colours = s.split()
        if len(colours) > 0:
            self.difference_colours = colours

    def getDifferenceColour(self, i):
        n = len(self.difference_colours)
        return self.getColour(self.difference_colours[(i + n - 1) % n])

    # mapping used to identify similar character to select when double-clicking
    def setCharacterClasses(self, s):
        self.char_classes = {}
        for ss in s.split():
            a = ss.split(':')
            if len(a) == 2:
                r = a[0].split('-')
                c = int(a[1])
                for a in range(int(r[0]), int(r[-1]) + 1):
                    self.char_classes[a] = c

    def getCharacterClass(self, c):
        c = ord(c)
        if self.char_classes.has_key(c):
            return self.char_classes[c]
        return c

    # colour resources
    def getColour(self, symbol):
        if self.colours.has_key(symbol):
            return self.colours[symbol]
        if not self.unknown_colours.has_key(symbol):
            print _('Warning: unknown colour %s') % (repr(symbol), )
            self.unknown_colours[symbol] = None
        return self.default_colour

    # float resources
    def getFloat(self, symbol):
        if self.floats.has_key(symbol):
            return self.floats[symbol]
        if not self.unknown_floats.has_key(symbol):
            print _('Warning: unknown float %s') % (repr(symbol), )
            self.unknown_floats[symbol] = None
        return 0.5

    # string resources
    def getString(self, symbol):
        if self.strings.has_key(symbol):
            return self.strings[symbol]
        if not self.unknown_strings.has_key(symbol):
            print _('Warning: unknown string %s') % (repr(symbol), )
            self.unknown_strings[symbol] = None

    # syntax highlighting
    def getSyntaxNames(self):
        return self.syntaxes.keys()

    def getSyntax(self, name):
        if self.syntaxes.has_key(name):
            return self.syntaxes[name]

    def getSyntaxByFilename(self, name):
        name = os.path.basename(name)
        for key in self.syntax_file_patterns.keys():
            if self.syntax_file_patterns[key].search(name):
                return self.getSyntax(key)

    # parse resource files
    def parse(self, file_name):
        # only process files once
        if not self.resource_files.has_key(file_name):
            self.resource_files[file_name] = None
            try:
                f = open(file_name, 'r')
                ss = f.readlines()
                f.close()
            except IOError:
                print _('Error reading %s.') % (repr(file_name), )
                return

            # FIXME: improve validation
            for i, s in enumerate(ss):
                args = shlex.split(s, True)
                if len(args) > 0:
                   try:
                       # eg. add Python syntax highlighting:
                       #    import /usr/share/diffuse/syntax/python.syntax
                       if args[0] == 'import' and len(args) == 2:
                           path = os.path.expanduser(args[1])
                           # relative paths are relative to the parsed file
                           path = os.path.join(os.path.dirname(file_name), path)
                           paths = glob.glob(path)
                           if len(paths) == 0:
                               paths = [ path ]
                           for path in paths:
                               self.parse(path)
                       # eg. make Ctrl+o trigger the open_file menu item
                       #    keybinding menu open_file Ctrl+o
                       elif args[0] == 'keybinding' and len(args) == 4:
                           self.setKeyBinding(args[1], args[2], args[3])
                       # eg. set the regular background colour to white
                       #    colour text_background 1.0 1.0 1.0
                       elif args[0] in [ 'colour', 'color' ] and len(args) == 5:
                           self.colours[args[1]] = Colour(float(args[2]), float(args[3]), float(args[4]))
                       # eg. set opacity of the line_selection colour
                       #    float line_selection_alpha 0.4
                       elif args[0] == 'float' and len(args) == 3:
                           self.floats[args[1]] = float(args[2])
                       # eg. set the help browser
                       #    string help_browser gnome-help
                       elif args[0] == 'string' and len(args) == 3:
                           self.strings[args[1]] = args[2]
                           if args[1] == 'difference_colours':
                               self.setDifferenceColours(args[2])
                           elif args[1] == 'character_classes':
                               self.setCharacterClasses(args[2])
                       # eg. start a syntax specification for Python
                       #    syntax Python normal text
                       # where 'normal' is the name of the default state and
                       # 'text' is the classification of all characters not
                       # explicitly matched by a syntax highlighting rule
                       elif args[0] == 'syntax' and (len(args) == 2 or len(args) == 4):
                           if len(args) == 2:
                               # remove a syntax specification
                               self.current_syntax = None
                               if self.syntaxes.has_key(args[1]):
                                   del self.syntaxes[args[1]]
                           else:
                               self.current_syntax = SyntaxParser(args[2], args[3])
                               self.syntaxes[args[1]] = self.current_syntax
                       # eg. transition from state 'normal' to 'comment' when
                       # the pattern '#' is matched and classify the matched
                       # characters as 'python_comment'
                       #    syntax_pattern normal comment python_comment '#'
                       elif args[0] == 'syntax_pattern' and self.current_syntax is not None and len(args) >= 5:
                           flags = 0
                           for arg in args[5:]:
                               if arg == 'ignorecase':
                                   flags |= re.IGNORECASE
                               else:
                                   raise ValueError()
                           self.current_syntax.addPattern(args[1], args[2], args[3], re.compile(args[4], flags))
                       # eg. default to the Python syntax rules when viewing
                       # a file ending with '.py' or '.pyw'
                       #    syntax_files Python '\.pyw?$'
                       elif args[0] == 'syntax_files' and (len(args) == 2 or len(args) == 3):
                           key = args[1]
                           if len(args) == 2:
                               # remove file pattern for a syntax specification
                               if self.syntax_file_patterns.has_key(key):
                                   del self.syntax_file_patterns[key]
                           else:
                               flags = 0
                               if isWindows():
                                   flags |= re.IGNORECASE
                               self.syntax_file_patterns[key] = re.compile(args[2], flags)
                       else:
                           raise ValueError()
                   except: # Grr... the 're' module throws weird errors
                   #except ValueError:
                       print _('Error parsing line %(line)d of "%(file)s".') % { 'line': i + 1, 'file': file_name }

theResources = Resources()

# convenience class for displaying a message dialogue
class MessageDialog(gtk.MessageDialog):
    def __init__(self, parent, type, msg):
        if type == gtk.MESSAGE_QUESTION:
           buttons = gtk.BUTTONS_OK_CANCEL
        else:
           buttons = gtk.BUTTONS_OK
        gtk.MessageDialog.__init__(self, parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, type, buttons, msg)
        self.set_title(APP_NAME)

# text entry widget with a button to help pick file names
class FileEntry(gtk.HBox):
    def __init__(self, parent, title):
        gtk.HBox.__init__(self)
        self.toplevel = parent
        self.title = title
        self.entry = entry = gtk.Entry()
        self.pack_start(entry, True, True, 0)
        entry.show()
        button = gtk.Button()
        image = gtk.Image()
        image.set_from_stock(gtk.STOCK_OPEN, gtk.ICON_SIZE_MENU)
        button.add(image)
        image.show()
        button.connect('clicked', self.chooseFile)
        self.pack_start(button, False, False, 0)
        button.show()

    # action performed when the pick file button is pressed
    def chooseFile(self, widget):
        dialog = gtk.FileChooserDialog(self.title, self.toplevel, gtk.FILE_CHOOSER_ACTION_OPEN, (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OPEN, gtk.RESPONSE_OK))
        if dialog.run() == gtk.RESPONSE_OK:
            self.entry.set_text(dialog.get_filename())
        dialog.destroy()

    def set_text(self, s):
        self.entry.set_text(s)

    def get_text(self):
        return self.entry.get_text()

# adaptor class to allow a gtk.FontButton to be read like a gtk.Entry
class FontButton(gtk.FontButton):
    def __init__(self):
        gtk.FontButton.__init__(self)

    def get_text(self):
        return self.get_font_name()

# escape arguments for use with bash
def bashEscape(s):
    return "'" + s.replace("'", "'\\''") + "'"

# class to store preferences and construct a dialogue for manipulating them
class Preferences:
    def __init__(self):
        self.bool_prefs = {}
        self.int_prefs = {}
        self.string_prefs = {}

        # find available encodings
        encs = {}
        for e in encodings.aliases.aliases.values():
            encs[e] = None
        self.encodings = sorted(encs.keys())

        if isWindows():
            font = 'Courier 10'
            git_bin = 'git.cmd'
            svk_bin = 'svk.bat'
        else:
            font = 'Monospace 10'
            git_bin = 'git'
            svk_bin = 'svk'

        # self.template describes how preference dialogue layout
        #
        # this will be traversed later to build the preferences dialogue and
        # discover which preferences exist
        #
        # folders are described using:
        #    [ 'FolderSet', label1, template1, label2, template2, ... ]
        # lists are described using:
        #    [ 'List', template1, template2, template3, ... ]
        # individual preferences are described using one of the following
        # depending upon its type and the desired widget:
        #    [ 'Boolean', name, default, label ]
        #    [ 'Integer', name, default, label ]
        #    [ 'String', name, default, label ]
        #    [ 'File', name, default, label ]
        #    [ 'Font', name, default, label ]
        self.template = [ 'FolderSet',
            _('Display'),
            [ 'List',
              [ 'Font', 'display_font', font, _('Font') ],
              [ 'Integer', 'display_tab_width', 8, _('Tab Width') ],
              [ 'Boolean', 'display_ignore_whitespace', False, _('Ignore White Space') ],
              [ 'Boolean', 'display_ignore_whitespace_changes', False, _('Ignore Changes to White Space') ],
              [ 'Boolean', 'display_ignore_blanklines', False, _('Ignore Blank Lines') ],
              [ 'Boolean', 'display_ignore_case', False, _('Ignore Case') ],
              [ 'Boolean', 'display_hide_endofline', True, _('Hide End of Line Characters') ]
            ],
            _('Alignment'),
            [ 'List',
              [ 'Boolean', 'align_ignore_whitespace', True, _('Ignore White Space') ],
              [ 'Boolean', 'align_ignore_whitespace_changes', False, _('Ignore Changes to White Space') ],
              [ 'Boolean', 'align_ignore_blanklines', False, _('Ignore Blank Lines') ],
              [ 'Boolean', 'align_ignore_case', False, _('Ignore Case') ]
            ],
            _('Editor'),
            [ 'List',
              [ 'Integer', 'editor_soft_tab_width', 8, _('Soft Tab Width') ],
              [ 'Boolean', 'editor_expand_tabs', False, _('Expand Tabs to Spaces') ]
            ],
            _('Regional Settings'),
            [ 'List',
              [ 'String', 'encoding_auto_detect_codecs', 'utf_8 latin_1', _('Order of codecs used to identify encoding') ]
            ],
        ]
        if isWindows() or isCygwin():
            if os.environ.has_key('SYSTEMDRIVE'):
                root = os.environ['SYSTEMDRIVE']
                if not root.endswith('\\'):
                    root += '\\'
            else:
                root = 'C:\\'
            self.template.extend([
                    _('Cygwin'),
                    [ 'List',
                      [ 'File', 'cygwin_root', os.path.join(root, 'cygwin'), _('Root Directory') ],
                      [ 'String', 'cygwin_cygdrive_prefix', '/cygdrive', _('Cygdrive Prefix') ]
                    ]
                ])

        # create template for Version Control options
        vcs = [ ('bzr', 'Bazaar', 'bzr', '-1'),
                ('cvs', 'CVS', 'cvs', 'BASE'),
                ('darcs', 'Darcs', 'darcs', ''),
                ('git', 'Git', git_bin, 'HEAD'),
                ('hg', 'Mercurial', 'hg', 'tip'),
                ('mtn', 'Monotone', 'mtn', 'h:'),
                ('svn', 'Subversion', 'svn', 'BASE'),
                ('svk', 'SVK', svk_bin, 'HEAD') ]

        vcs_template = [ 'FolderSet' ]
        for key, name, cmd, rev in vcs:
            temp = [ 'List',
                     [ 'File', key + '_bin', cmd, _('Command') ],
                     [ 'String', key + '_default_revision', rev, _('Default Revision') ]
                   ]
            if isWindows():
                temp.append([ 'Boolean', key + '_bash', False, _('Launch from a Bash login shell') ])
                if key != 'git':
                    temp.append([ 'Boolean', key + '_cygwin', False, _('Update paths for Cygwin') ])
            vcs_template.extend([ name, temp ])

        self.template.extend([ _('Version Control'), vcs_template ])
        self._initFromTemplate(self.template)
        # load the user's preferences
        self.path = os.path.expanduser('~/.diffuse/prefs')
        if os.path.isfile(self.path):
            try:
                f = open(self.path, 'r')
                ss = f.readlines()
                f.close()
                for j, s in enumerate(ss):
                    try:
                        a = shlex.split(s, True)
                        if len(a) > 0:
                            if len(a) == 2 and self.bool_prefs.has_key(a[0]):
                                self.bool_prefs[a[0]] = (a[1] == 'True')
                            elif len(a) == 2 and self.int_prefs.has_key(a[0]):
                                self.int_prefs[a[0]] = int(a[1])
                            elif len(a) == 2 and self.string_prefs.has_key(a[0]):
                                self.string_prefs[a[0]] = a[1]
                            else:
                                raise ValueError()
                    except ValueError:
                        print _('Error parsing line %(line)d of "%(file)s".') % { 'line': j + 1, 'file': self.path }
            except IOError:
                print _('Error reading %s.') % (repr(self.path), )

    # recursively traverses 'template' to discover the preferences and
    # initialise their default values in self.bool_prefs, self.int_prefs, and
    # self.string_prefs
    def _initFromTemplate(self, template):
        if template[0] == 'FolderSet' or template[0] == 'List':
            i = 1
            while i < len(template):
                if template[0] == 'FolderSet':
                    i += 1
                self._initFromTemplate(template[i])
                i += 1
        elif template[0] == 'Boolean':
            self.bool_prefs[template[1]] = template[2]
        elif template[0] == 'Integer':
            self.int_prefs[template[1]] = template[2]
        elif template[0] in [ 'String', 'File', 'Font' ]:
            self.string_prefs[template[1]] = template[2]

    # display the dialogue and update the preference values if the accept
    # button was pressed
    def runDialog(self, parent):
        dialog = gtk.Dialog(_('Preferences'), parent, gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_OK))

        widgets = {}
        w = self._buildPrefsDialog(parent, widgets, self.template)
        dialog.vbox.add(w)
        w.show()

        accept = (dialog.run() == gtk.RESPONSE_OK)
        if accept:
            try:
                ss = []
                for k in self.bool_prefs.keys():
                    ss.append('%s %s\n' % (k, widgets[k].get_active()))
                for k in self.int_prefs.keys():
                    ss.append('%s %s\n' % (k, widgets[k].get_value_as_int()))
                for k in self.string_prefs.keys():
                    ss.append('%s "%s"\n' % (k, widgets[k].get_text().replace('\\', '\\\\').replace('"', '\\"')))
                ss.sort()
                f = open(self.path, 'w')
                f.write('# This prefs file was generated by %s %s.\n\n' % (APP_NAME, VERSION))
                for s in ss:
                    f.write(s)
                f.close()
            except IOError:
                print _('Error writing %s.') % (repr(self.path), )
                m = MessageDialog(parent, gtk.MESSAGE_ERROR, _('Error writing %s.') % (repr(self.path), ))
                m.run()
                m.destroy()
            for k in self.bool_prefs.keys():
                self.bool_prefs[k] = widgets[k].get_active()
            for k in self.int_prefs.keys():
                self.int_prefs[k] = widgets[k].get_value_as_int()
            for k in self.string_prefs.keys():
                self.string_prefs[k] = widgets[k].get_text()
        dialog.destroy()
        return accept

    # recursively traverses 'template' to build the preferences dialogue
    # and the individual preference widgets into 'widgets' so their value
    # can be easily queried by the caller
    def _buildPrefsDialog(self, parent, widgets, template):
        type = template[0]
        if type == 'FolderSet':
            notebook = gtk.Notebook()
            notebook.set_border_width(10)
            i = 1
            while i < len(template):
                label = gtk.Label(template[i])
                i += 1
                w = self._buildPrefsDialog(parent, widgets, template[i])
                i += 1
                notebook.append_page(w, label)
                w.show()
                label.show()
            return notebook
        else:
            n = len(template) - 1
            table = gtk.Table(2, n)
            table.set_border_width(10)
            for i, tpl in enumerate(template[1:]):
                type = tpl[0]
                if type == 'FolderSet':
                    w = self._buildPrefsDialog(parent, widgets, tpl)
                    table.attach(w, 0, 2, i, i + 1, gtk.FILL, gtk.FILL)
                    w.show()
                elif type == 'Boolean':
                    button = gtk.CheckButton(tpl[3])
                    button.set_active(self.bool_prefs[tpl[1]])
                    widgets[tpl[1]] = button
                    table.attach(button, 1, 2, i, i + 1, gtk.FILL, gtk.FILL)
                    button.show()
                else:
                    label = gtk.Label(tpl[3] + ': ')
                    label.set_alignment(1.0, 0.5)
                    table.attach(label, 0, 1, i, i + 1, gtk.FILL, gtk.FILL)
                    label.show()
                    if tpl[0] in [ 'Font', 'Integer' ]:
                        entry = gtk.HBox()
                        if tpl[0] == 'Font':
                            button = FontButton()
                            button.set_font_name(self.string_prefs[tpl[1]])
                        else:
                            button = gtk.SpinButton()
                            button.set_range(1, 128)
                            button.set_value(self.int_prefs[tpl[1]])
                            button.set_increments(1, 1)
                        widgets[tpl[1]] = button
                        entry.pack_start(button, False, False, 0)
                        button.show()
                    else:
                        if tpl[0] == 'File':
                            entry = FileEntry(parent, tpl[3])
                        else:
                            entry = gtk.Entry()
                        widgets[tpl[1]] = entry
                        entry.set_text(self.string_prefs[tpl[1]])
                    table.attach(entry, 1, 2, i, i + 1, gtk.EXPAND|gtk.FILL, gtk.FILL)
                    entry.show()
                table.show()
            return table

    # get/set methods to manipulate the preference values
    def getBool(self, name):
        return self.bool_prefs[name]

    def setBool(self, name, value):
        self.bool_prefs[name] = value

    def getInt(self, name):
        return self.int_prefs[name]

    def getString(self, name):
        return self.string_prefs[name]

    def getEncodings(self):
        return self.encodings

    def _getDefaultEncodings(self):
        return self.string_prefs['encoding_auto_detect_codecs'].split()

    def getDefaultEncoding(self):
        encodings = self._getDefaultEncodings()
        if len(encodings) > 0:
            return encodings[0]
        return 'utf_8'

    # attempt to convert a string to unicode from an unknown encoding
    def convertToUnicode(self, ss):
        for encoding in self._getDefaultEncodings():
            try:
                result = []
                for s in ss:
                    if s is not None:
                        s = unicode(s, encoding)
                    result.append(s)
                return result, encoding
            except (UnicodeDecodeError, LookupError):
                pass
        result = []
        for s in ss:
            if s is not None:
                s = ''.join([unichr(ord(c)) for c in s])
            result.append(s)
        return result, None

    # cygwin and native applications can be used on windows, use this method
    # to convert a path to the usual form expected on sys.platform
    def convertToNativePath(self, s):
        if isWindows() or isCygwin():
            prefix = self.getString('cygwin_cygdrive_prefix')
            prefix = ''.join([ '/' + p for p in prefix.split('/') if p != '' ])
            if isCygwin():
                # convert to a cygwin style path
                p = s.replace('\\', '/').split('/')
                if p[0].endswith(':'):
                    p[0] = prefix + '/' + p[0][:-1]
                s = '/'.join(p)
            else:
                # convert to a Windows native style path
                p = s.replace('/', '\\').split('\\')
                if p[0] == '':
                    pr = prefix.split('/')
                    n = len(pr)
                    if len(p) > n and p[:n] == pr and len(p[n]) == 1:
                        p[:n + 1] = [ p[n] + ':' ]
                    elif len(p) > 1:
                        if p[1] != '':
                            p[0] = self.getString('cygwin_root')
                s = '\\'.join(p)
        return s

# convenience method for creating a menu according to a template
def createMenu(specs, accel_group=None):
    menu = gtk.Menu()
    for spec in specs:
        if len(spec) > 0:
            item = gtk.ImageMenuItem(spec[0])
            cb = spec[1]
            if cb is not None:
                data = spec[2]
                item.connect('activate', cb, data)
            if len(spec) > 3 and spec[3] is not None:
                image = gtk.Image()
                image.set_from_stock(spec[3], gtk.ICON_SIZE_MENU)
                item.set_image(image)
            if accel_group is not None and len(spec) > 4:
                a = theResources.getKeyBindings('menu', spec[4])
                if len(a) > 0:
                    key, modifier = a[0]
                    item.add_accelerator('activate', accel_group, key, modifier, gtk.ACCEL_VISIBLE)
            if len(spec) > 5:
                item.set_sensitive(spec[5])
            if len(spec) > 6 and spec[6] is not None:
                item.set_submenu(createMenu(spec[6], accel_group))
        else:
            item = gtk.SeparatorMenuItem()
        menu.append(item)
        item.show()
    return menu

# convenience method for creating a menu bar according to a template
def createMenuBar(specs, accel_group):
    menu_bar = gtk.MenuBar()
    for label, spec in specs:
        menu = gtk.MenuItem(label)
        menu.set_submenu(createMenu(spec, accel_group))
        menu_bar.append(menu)
        menu.show()
    return menu_bar

# convenience method for packing buttons into a container according to a
# template
def appendButtons(box, size, specs):
    for spec in specs:
        if len(spec) > 0:
            button = gtk.Button()
            button.set_relief(gtk.RELIEF_NONE)
            image = gtk.Image()
            image.set_from_stock(spec[0], size)
            button.add(image)
            image.show()
            if len(spec) > 2:
                button.connect('clicked', spec[1], spec[2])
                if len(spec) > 3:
                    if hasattr(button, 'set_tooltip_text'):
                        # only available in pygtk >= 2.12
                        button.set_tooltip_text(spec[3])
            box.pack_start(button, False, False, 0)
            button.show()
        else:
            separator = gtk.VSeparator()
            box.pack_start(separator, False, False, 5)
            separator.show()

# True if the string ends with '\r\n'
def has_dos_line_ending(s):
    return s is not None and len(s) > 1 and s[-2:] == '\r\n'

# True if the string ends with '\n' but not '\r\n'
def has_unix_line_ending(s):
    if s is not None and len(s) > 0 and s[-1] == '\n':
        return len(s) == 1 or s[-2] != '\r'
    return False

# masks used to indicate the presence of DOS line endings and Unix line endings
# 0 indicates no line endings, while 3 indicates mixed line endings
DOS_FORMAT = 1
UNIX_FORMAT = 2

# returns the format mask for a list of strings
def getFormat(ss):
    flags = 0
    for s in ss:
        if has_dos_line_ending(s):
            flags |= DOS_FORMAT
        elif has_unix_line_ending(s):
            flags |= UNIX_FORMAT
    return flags

# returns the number of characters in the string excluding any line ending
# characters
def len_minus_line_ending(format, s):
    if s is None:
        return 0
    n = len(s)
    if n > 0 and s[-1] == '\n':
        if format == DOS_FORMAT and n > 1 and s[-2] == '\r':
            n -= 2
        else:
            n -= 1
    return n

# returns the string without the line ending characters
def strip_line_ending(format, s):
    if s is not None:
        return s[:len_minus_line_ending(format, s)]

# the following are convenience methods to change the line ending of a string
def convert_to_dos(s):
    if has_unix_line_ending(s):
        s = s[:-1] + '\r\n'
    return s

def convert_to_unix(s):
    if has_dos_line_ending(s):
        s = s[:-2] + '\n'
    return s

def convert_to_format(s, format):
   if format == DOS_FORMAT:
       s = convert_to_dos(s)
   elif format == UNIX_FORMAT:
       s = convert_to_unix(s)
   return s

# contains information about a file
class FileSpec:
    def __init__(self, name=None, revision=None, vcs=None, encoding=None):
        # file name
        self.name = name
        # revision used to retrieve file from the VCS
        self.revision = revision
        # the VCS object
        self.vcs = vcs
        # name of codec used to translate the file contents to unicode text
        self.encoding = encoding

    # returns a description suitable for a label
    def get_label(self):
        if self.name is None:
            s = ''
        else:
            s = self.name
            if self.revision is not None:
                s = '%s (%s)' % (s, self.revision)
        return s

# helper function prevent files from being confused with command line options
# by prepending './' to the basename
def safeLocalPath(s, prefs, cygwin_pref):
    s = os.path.join(os.path.curdir, os.path.basename(s))
    if isWindows():
        if prefs.getBool(cygwin_pref):
            s = s.replace('\\', '/')
        else:
            s = s.replace('/', '\\')
    return s

class VCSs:
    # Bazaar support
    class Bzr:
        def __init__(self, proj_dir):
            pass

        def getSingleFileSpecs(self, prefs, name):
            # FIXME: merge conflicts?
            return [ (name, prefs.getString('bzr_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'bzr_bash', [ prefs.getString('bzr_bin'), 'cat', '-r', rev, safeLocalPath(name, prefs, 'bzr_cygwin') ]

    # CVS support
    class Cvs:
        def getSingleFileSpecs(self, prefs, name):
            return [ (name, prefs.getString('cvs_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'cvs_bash', [ prefs.getString('cvs_bin'), '-Q', 'update', '-p', '-r', rev, safeLocalPath(name, prefs, 'cvs_cygwin') ]

    # Darcs support
    class Darcs:
        def __init__(self, proj_dir):
            pass

        def getSingleFileSpecs(self, prefs, name):
            # FIXME: merge conflicts?
            return [ (name, prefs.getString('darcs_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'darcs_bash', [ prefs.getString('darcs_bin'), 'show', 'contents', '-p', rev, safeLocalPath(name, prefs, 'darcs_cygwin') ]

    # Git support
    class Git:
        def __init__(self, proj_dir):
            self.proj_dir = proj_dir

        def getSingleFileSpecs(self, prefs, name):
            return [ (name, prefs.getString('git_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            name = os.path.abspath(name)
            if name.startswith(self.proj_dir):
                i = len(self.proj_dir)
                while i < len(name) and name[i] == os.path.sep:
                    i += 1
                name = name[i:]
            return 'git_bash', [ prefs.getString('git_bin'), 'show', '%s:%s' % (rev, name.replace(os.sep, '/')) ]

    # Mercurial support
    class Hg:
        def __init__(self, proj_dir):
            pass

        def getSingleFileSpecs(self, prefs, name):
            # FIXME: merge conflicts?
            return [ (name, prefs.getString('hg_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'hg_bash', [ prefs.getString('hg_bin'), 'cat', '-r', rev, safeLocalPath(name, prefs, 'hg_cygwin') ]

    # Monotone support
    class Mtn:
        def __init__(self, proj_dir):
            pass

        def getSingleFileSpecs(self, prefs, name):
            # FIXME: merge conflicts?
            return [ (name, prefs.getString('mtn_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'mtn_bash', [ prefs.getString('mtn_bin'), 'cat', '--quiet', '-r', rev, safeLocalPath(name, prefs, 'mtn_cygwin') ]

    # Subversion support
    class Svn:
        def getSingleFileSpecs(self, prefs, name):
            # merge conflict
            left = glob.glob(name + '.merge-left.r*')
            right = glob.glob(name + '.merge-right.r*')
            if len(left) > 0 and len(right) > 0:
                return [ (left[-1], None), (name, None), (right[-1], None) ]
            # update conflict
            left = sorted(glob.glob(name + '.r*'))
            right = glob.glob(name + '.mine')
            right.extend(glob.glob(name + '.working'))
            if len(left) > 0 and len(right) > 0:
                return [ (left[-1], None), (name, None), (right[0], None) ]
            # default case
            return [ (name, prefs.getString('svn_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'svn_bash', [ prefs.getString('svn_bin'), 'cat', '-r', rev, safeLocalPath(name, prefs, 'svn_cygwin') ]

    # SVK support
    def isSvkManaged(self, name):
        # parse the ~/.svk/config file to discover which directories are part
        # of SVK repositories
        if isWindows():
            name = name.upper()
        if os.path.isfile(self.svkconfig):
            try:
                # find working copies by parsing the config file
                f = open(self.svkconfig, 'r')
                ss = f.readlines()
                f.close()
                projs, hash, sep = {}, False, os.sep
                # find the separator character
                for s in ss:
                    if s.startswith('  sep: ') and len(s) > 7:
                        sep = s[7]
                # find the project directories
                for s in ss:
                    if hash:
                        if not s.startswith('    '):
                            break
                        n = len(s)
                        if n > 4 and s[4] != ' ':
                            if s.endswith(': \n'):
                                n -= 3
                            # parse directory path
                            tt = []
                            i = 4
                            while i < n: 
                                if s[i] == '"':
                                    # quoted string
                                    i += 1
                                    while i < n:
                                        if s[i] == '"':
                                            i += 1
                                            break
                                        elif s[i] == '\\':
                                            # escaped character
                                            i += 1
                                        if i < n:
                                            tt.append(s[i])
                                            i += 1
                                else:
                                    tt.append(s[i])
                                    i += 1
                            key = ''.join(tt).replace(sep, os.sep)
                            if isWindows():
                                key = key.upper()
                            projs[key] = None
                    elif s.startswith('  hash:'):
                        hash = True
                # check if the file belongs to one of the project directories
                while True:
                    newname = os.path.dirname(name)
                    if newname == name:
                        break
                    if projs.has_key(newname):
                        return True
                    name = newname
            except IOError:
                print 'Error parsing %s.' % (config, )
        return False

    class Svk:
        def getSingleFileSpecs(self, prefs, name):
            # FIXME: merge conflicts?
            return [ (name, prefs.getString('svk_default_revision')), (name, None) ]

        def getRevisionCommand(self, prefs, name, rev):
            return 'svk_bash', [ prefs.getString('svk_bin'), 'cat', '-r', rev, safeLocalPath(name, prefs, 'svk_cygwin') ]

    def __init__(self):
        # initialise the VCS objects
        if os.environ.has_key('SVKROOT'):
            svkroot = os.environ['SVKROOT']
        else:
            svkroot = os.path.expanduser('~/.svk')
        self.svkconfig = os.path.join(svkroot, 'config')
        self.leaf_dir_repos = [('.svn', VCSs.Svn), ('CVS', VCSs.Cvs)]
        self.common_dir_repos = [('.git', VCSs.Git), ('.hg', VCSs.Hg), ('.bzr', VCSs.Bzr), ('_darcs', VCSs.Darcs), ('_MTN', VCSs.Mtn)]

    # determines which VCS to use for the named file
    def findByFilename(self, name):
        for dir_name, repo in self.leaf_dir_repos:
            if os.path.isdir(os.path.join(os.path.dirname(name), dir_name)):
                return repo()
        name = os.path.abspath(name)
        if self.isSvkManaged(name):
            return VCSs.Svk()
        while True:
            newname = os.path.dirname(name)
            if newname == name:
                break
            name = newname
            for dir_name, repo in self.common_dir_repos:
                if os.path.isdir(os.path.join(name, dir_name)):
                    return repo(name)

theVCSs = VCSs()

# custom dialogue for picking files with widgets for specifying the encoding
# and revision
class FileChooserDialog(gtk.FileChooserDialog):
    def __init__(self, title, parent, prefs, action, accept):
        gtk.FileChooserDialog.__init__(self, title, parent, action, (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, accept, gtk.RESPONSE_OK))
        self.prefs = prefs
        hbox = gtk.HBox()
        label = gtk.Label(_('Encoding: '))
        hbox.pack_start(label, False, False, 0)
        label.show()
        self.combobox = combobox = gtk.combo_box_new_text()
        self.encodings = prefs.getEncodings()
        for e in self.encodings:
            combobox.append_text(e)
        if action == gtk.FILE_CHOOSER_ACTION_OPEN:
            self.encodings = self.encodings[:]
            self.encodings.insert(0, None)
            combobox.prepend_text('Auto Detect')
            combobox.set_active(0)
        hbox.pack_start(combobox, False, False, 5)
        combobox.show()
        if action == gtk.FILE_CHOOSER_ACTION_OPEN:
            self.revision = entry = gtk.Entry()
            hbox.pack_end(entry, False, False, 0)
            entry.show()
            label = gtk.Label(_('Revision: '))
            hbox.pack_end(label, False, False, 0)
            label.show()

        self.vbox.pack_start(hbox, False, False, 0)
        hbox.show()
        self.set_current_folder(os.path.realpath(os.path.curdir))

    def set_encoding(self, encoding):
        if encoding not in self.encodings:
            encoding = self.prefs.getDefaultEncoding()
        if encoding in self.encodings:
            self.combobox.set_active(self.encodings.index(encoding))

    def get_encoding(self):
        i = self.combobox.get_active()
        if i >= 0:
            return self.encodings[i]

    def get_revision(self):
        return self.revision.get_text()

# This is a replacement for gtk.ScrolledWindow as it forced expose events to be
# handled immediately after changing the viewport position.  This could cause
# the application to become unresponsive for a while as it processed a large
# queue of keypress and expose event pairs.
class ScrolledWindow(gtk.Table):
    def __init__(self):
        gtk.Table.__init__(self, 2, 2)
        self.position = (0, 0)
        self.scroll_count = 0

        vport = gtk.Viewport()
        darea = gtk.DrawingArea()
        self.darea = darea
        vport.add(darea)
        darea.show()
        self.attach(vport, 0, 1, 0, 1)
        vport.show()

        self.vbar = bar = gtk.VScrollbar()
        self.attach(bar, 1, 2, 0, 1, gtk.FILL, gtk.EXPAND|gtk.FILL)
        bar.show()
        self.vhandle = bar.get_adjustment().connect('value_changed', self.value_changed_cb)

        self.hbar = bar = gtk.HScrollbar()
        self.attach(bar, 0, 1, 1, 2, gtk.EXPAND|gtk.FILL, gtk.FILL)
        bar.show()
        self.hhandle = bar.get_adjustment().connect('value_changed', self.value_changed_cb)

        darea.connect('configure_event', self.configure_cb)
        darea.connect('scroll_event', self.scroll_cb)
        darea.connect('expose_event', self.expose_cb)

    def set_vadjustment(self, adj):
        self.get_vadjustment().disconnect(self.vhandle)
        self.vhandle = adj.connect('value_changed', self.value_changed_cb)
        self.vbar.set_adjustment(adj)

    def get_vadjustment(self):
        return self.vbar.get_adjustment()

    def set_hadjustment(self, adj):
        self.get_hadjustment().disconnect(self.hhandle)
        self.hbar.set_adjustment(adj)
        self.hhandle = adj.connect('value_changed', self.value_changed_cb)

    def get_hadjustment(self):
        return self.hbar.get_adjustment()

    # updates the adjustments to match the new widget size
    def configure_cb(self, widget, event):
        w, h = event.width, event.height

        adj = self.get_hadjustment()
        v = adj.get_value()
        if v + w > adj.upper:
            adj.set_value(max(0, adj.upper - w))
        adj.page_size = w
        adj.page_increment = w

        adj = self.get_vadjustment()
        v = adj.get_value()
        if v + h > adj.upper:
            adj.set_value(max(0, adj.upper - h))
        adj.page_size = h
        adj.page_increment = h

    # update the vertical adjustment when the mouse's scroll wheel is used
    def scroll_cb(self, widget, event):
        vadj = self.get_vadjustment()
        v = vadj.get_value()
        delta = 100
        if event.direction == gtk.gdk.SCROLL_UP:
            delta = -delta
        v = max(v + delta, int(vadj.lower))
        v = min(v, int(vadj.upper - vadj.page_size))
        vadj.set_value(v)

    def value_changed_cb(self, widget):
        old_x, old_y = self.position
        pos_x = int(self.get_hadjustment().get_value())
        pos_y = int(self.get_vadjustment().get_value())
        self.position = (pos_x, pos_y)
        if self.darea.window is not None:
            # window.scroll() although visually nice, is slow, revert to
            # queue_draw() if scroll a lot without seeing an expose event
            if self.scroll_count < 2:
                self.scroll_count += 1
                self.darea.window.scroll(old_x - pos_x, old_y - pos_y)
            else:
                self.darea.queue_draw()

    def expose_cb(self, widget, event):
        self.scroll_count = 0

# dialogue used to search for text
class GoToLineDialog(gtk.Dialog):
    def __init__(self, parent):
        gtk.Dialog.__init__(self, _('Go To Line...'), parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))

        vbox = gtk.VBox()
        vbox.set_border_width(10)

        hbox = gtk.HBox()
        label = gtk.Label(_('Line Number: '))
        hbox.pack_start(label, False, False, 0)
        label.show()
        self.entry = entry = gtk.Entry()
        entry.connect('activate', self.entry_cb)
        hbox.pack_start(entry, True, True, 0)
        entry.show()

        vbox.pack_start(hbox, True, True, 0)
        hbox.show()

        self.vbox.pack_start(vbox, False, False, 0)
        vbox.show()

    def entry_cb(self, widget):
        self.response(gtk.RESPONSE_ACCEPT)

# establish callback for the about dialog's link to Diffuse's web site
def url_hook(dialog, link, userdata):
    webbrowser.open(link)

gtk.about_dialog_set_url_hook(url_hook, None)

# the about dialogue
class AboutDialog(gtk.AboutDialog):
    def __init__(self):
        gtk.AboutDialog.__init__(self)
        if hasattr(self, 'set_program_name'):
            # only available in pygtk >= 2.12
            self.set_program_name(APP_NAME)
        self.set_version(VERSION)
        self.set_comments(_('a file comparison and merge tool'))
        self.set_copyright(COPYRIGHT)
        self.set_website(WEBSITE)
        self.set_authors([ 'Derrick Moser <derrick_moser@yahoo.com>' ])
        ss = [ APP_NAME + ' ' + VERSION + '\n',
               self.get_comments() + '\n',
               COPYRIGHT + '\n\n',
               _("""This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the licence, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program.  You may also obtain a copy of the GNU General Public License from the Free Software Foundation by visiting their web site (http://www.fsf.org/) or by writing to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
""") ]
        self.set_license(''.join(ss))
        self.set_wrap_license(True)

# Enforcing manual alignment is accomplished by dividing the lines of text into
# sections that are matched independently.  'blocks' is an array of integers
# describing how many lines (including null lines for spacing) that are in each
# section starting from the begining of the files.  When no manual alignment
# has been specified, all lines are in the same section so 'blocks' should
# contain a single entry equal to the number of lines.  Zeros are not allowed
# in this array so 'blocks' will be an empty array when there are no lines.  A
# 'cut' at location 'i' means a line 'i-1' and line 'i' belong to different
# sections

def createBlock(n):
    if n > 0:
        return [ n ]
    return []

# returns the two sets of blocks after cutting at 'i'
def cutBlocks(i, blocks):
    pre, post, nlines = [], [], 0
    for b in blocks:
        if nlines >= i:
            post.append(b)
        elif nlines + b <= i:
            pre.append(b)
        else:
            n = i - nlines
            pre.append(n)
            post.append(b - n)
        nlines += b
    return pre, post

# returns a set of blocks containing all of the cuts in the inputs
def mergeBlocks(leftblocks, rightblocks):
    leftblocks, rightblocks, b = leftblocks[:], rightblocks[:], []
    while len(leftblocks) > 0:
        nleft, nright = leftblocks[0], rightblocks[0]
        n = min(nleft, nright)
        if n < nleft:
            leftblocks[0] -= n
        else:
            del leftblocks[0]
        if n < nright:
            rightblocks[0] -= n
        else:
            del rightblocks[0]
        b.append(n)
    return b

# utility method to simplify working with structures used to describe character
# differences of a line
#
# ranges of character differences are indicated by (start, end, flags) tuples
# where 'flags' is a mask used to indicate if the characters are different from
# the line to the left, right, or both
#
# this method will return the union of two sorted lists of ranges
def mergeRanges(r1, r2):
    r1, r2, result, start = r1[:], r2[:], [], 0
    rs = [ r1, r2 ]
    while len(r1) > 0 and len(r2) > 0:
        flags, start = 0, min(r1[0][0], r2[0][0])
        if start == r1[0][0]:
            r1end = r1[0][1]
            flags |= r1[0][2]
        else:
            r1end = r1[0][0]
        if start == r2[0][0]:
            r2end = r2[0][1]
            flags |= r2[0][2]
        else:
            r2end = r2[0][0]
        end = min(r1end, r2end)
        result.append((start, end, flags))
        for r in rs:
            if start == r[0][0]:
                if end == r[0][1]:
                    del r[0]
                else:
                    r[0] = (end, r[0][1], r[0][2])
    result.extend(r1)
    result.extend(r2)
    return result

# eliminates lines that are spacing lines in all panes
def removeNullLines(blocks, lines_set):
    bi, bn, i = 0, 0, 0
    while bi < len(blocks):
        while i < bn + blocks[bi]:
            for lines in lines_set:
                if lines[i] is not None:
                    i += 1
                    break
            else:
                for lines in lines_set:
                    del lines[i]
                blocks[bi] -= 1
        if blocks[bi] == 0:
            del blocks[bi]
        else:
            bn += blocks[bi]
            bi += 1

# convenience method to request confirmation before doing an operation that
# will cause edits to be lost
def confirmDiscardEdits(parent):
    dialog = MessageDialog(parent, gtk.MESSAGE_QUESTION, _('Discard unsaved changes?'))
    end = (dialog.run() == gtk.RESPONSE_OK)
    dialog.destroy()
    return end

def nullToEmpty(s):
    if s is None:
        s = ''
    return s

# returns true if the string only contains whitespace characters
def isBlank(s):
    for c in whitespace:
        s = s.replace(c, '')
    return len(s) == 0

# use pango.SCALE instead of pango.PIXELS to avoid overflow exception
def pixels(size):
    return int(size / pango.SCALE + 0.5)

# constructs a full URL for the name file
def path2url(path, proto='file'):
    s = urllib.pathname2url(os.path.abspath(path))
    i = 0
    while i < len(s) and s[i] == '/':
        i += 1
    return ''.join([ proto, ':///', s[i:] ])

# the file diff viewer is always in one of these modes defining the cursor,
# and hotkey behaviour
LINE_MODE = 0
CHAR_MODE = 1
ALIGN_MODE = 2

# widget used to compare and merge text files
class FileDiffViewer(gtk.Table):
    # class describing a text pane
    class Pane:
        def __init__(self):
            # list of lines displayed in this pane (including spacing lines)
            self.lines = []
            # file's name and information about how to retrieve it from a VCS
            self.spec = FileSpec()
            # label for the pane (filename and revision information)
            self.label = ''
            # high water mark for line length in pango units (used to determine
            # the required horizontal scroll range)
            self.line_lengths = 0
            # highest line number
            self.max_line_number = 0
            # cache of syntax highlighting information for each line
            # self.syntax_cache[i] corresponds to self.lines[i]
            # the list is truncated when a change to a line invalidates a
            # portion of the cache
            self.syntax_cache = []
            # cache of character differences for each line
            # self.diff_cache[i] corresponds to self.lines[i]
            # portion of the cache are cleared by setting entries to None
            self.diff_cache = []
            # mask indicating the type of line endings present
            self.format = 0
            # 'stat' for files read from disk -- used to warn about changes to
            # the file on disk before saving
            self.stat = None
            # most recent 'stat' for files read from disk -- used on focus
            # change to warn about changes to file on disk
            self.last_stat = None

        # returns True if any line contains modifications
        def hasEdits(self):
            for line in self.lines:
                if line is not None and line.is_modified:
                    return True
            return False

    # class describing a single line of a pane
    class Line:
        def __init__(self, line_number = None, text = None):
            # line number
            self.line_number = line_number
            # original text for the line
            self.text = text
            # flag indicating modifications are present
            self.is_modified = False
            # actual modified text
            self.modified_text = None
            # cache used to speed up comparison of strings
            # this should be cleared whenever the comparison preferences change
            self.compare_string = None

        # returns the current text for this line
        def getText(self):
            if self.is_modified:
                return self.modified_text
            return self.text

    def __init__(self, n, prefs, title):
        # figure out how many pane panes we should have
        if n < 2:
            n = 2

        gtk.Table.__init__(self, 3, n + 1)
        self.set_flags(gtk.CAN_FOCUS)
        self.prefs = prefs

        # diff blocks
        self.blocks = []
        self.labels = []

        # undos
        self.undos = []
        self.redos = []
        self.undoblock = None

        # cached data
        self.syntax = None
        self.map_cache = None

        # editing mode
        self.mode = LINE_MODE
        self.current_pane = 1
        self.current_line = 0
        self.current_char = 0
        self.selection_line = 0
        self.selection_char = 0
        self.align_pane = 0
        self.align_line = 0
        self.cursor_column = -1

        # keybindings
        self._line_mode_actions = {
                'enter_align_mode': self._line_mode_enter_align_mode,
                'enter_character_mode': self.setCharMode,
                'first_line': self._first_line,
                'extend_first_line': self._extend_first_line,
                'last_line': self._last_line,
                'extend_last_line': self._extend_last_line,
                'up': self._line_mode_up,
                'extend_up': self._line_mode_extend_up,
                'down': self._line_mode_down,
                'extend_down': self._line_mode_extend_down,
                'left': self._line_mode_left,
                'extend_left': self._line_mode_extend_left,
                'right': self._line_mode_right,
                'extend_right': self._line_mode_extend_right,
                'page_up': self._line_mode_page_up,
                'extend_page_up': self._line_mode_extend_page_up,
                'page_down': self._line_mode_page_down,
                'extend_page_down': self._line_mode_extend_page_down,
                'delete_text': self._delete_text,
                'merge_from_left': self._merge_from_left,
                'merge_from_right': self._merge_from_right,
                'first_difference': self._first_difference,
                'previous_difference': self._previous_difference,
                'next_difference': self._next_difference,
                'last_difference': self._last_difference,
                'isolate': self._isolate }
        self._align_mode_actions = {
                'enter_line_mode': self._align_mode_enter_line_mode,
                'enter_character_mode': self.setCharMode,
                'first_line': self._first_line,
                'last_line': self._last_line,
                'up': self._line_mode_up,
                'down': self._line_mode_down,
                'left': self._line_mode_left,
                'right': self._line_mode_right,
                'page_up': self._line_mode_page_up,
                'page_down': self._line_mode_page_down,
                'align': self._align_text }
        self._character_mode_actions = {
                'enter_line_mode': self.setLineMode }

        # create pane panes
        self.dareas = []
        self.panes = []
        self.hadj = None
        self.vadj = None
        for i in range(n):
            pane = FileDiffViewer.Pane()
            self.panes.append(pane)

            # pane header
            hbox = gtk.HBox()
            appendButtons(hbox, gtk.ICON_SIZE_MENU, [
               [ gtk.STOCK_OPEN, self.open_file_button_cb, i, _('Open File...') ],
               [ gtk.STOCK_REFRESH, self.reload_file_button_cb, i, _('Reload File') ],
               [ gtk.STOCK_SAVE, self.save_file_button_cb, i, _('Save File') ],
               [ gtk.STOCK_SAVE_AS, self.save_file_as_button_cb, i, _('Save File As...') ] ])
            label = gtk.Label()
            self.labels.append(label)
            label.set_size_request(0, label.get_size_request()[1])
            hbox.pack_start(label, True, True, 0)
            label.show()
            self.attach(hbox, i, i + 1, 0, 1, gtk.FILL, gtk.FILL)
            hbox.show()

            # pane contents
            sw = ScrolledWindow()
            darea = sw.darea
            darea.add_events(gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON1_MOTION_MASK)
            darea.connect('button_press_event', self.darea_button_press_cb, i)
            darea.connect('motion_notify_event', self.darea_motion_notify_cb, i)
            darea.connect('expose_event', self.darea_expose_cb, i)
            self.dareas.append(darea)
            if self.hadj is None:
                self.hadj = sw.get_hadjustment()
                self.vadj = sw.get_vadjustment()
            else:
                sw.set_hadjustment(self.hadj)
                sw.set_vadjustment(self.vadj)
            self.attach(sw, i, i + 1, 1, 2)
            sw.show()
        self.vadj.connect('value_changed', self.map_vadj_changed_cb)

        # add diff map
        self.map = map = gtk.DrawingArea()
        map.add_events(gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.BUTTON1_MOTION_MASK)
        map.connect('button_press_event', self.map_button_press_cb)
        map.connect('motion_notify_event', self.map_button_press_cb)
        map.connect('scroll_event', self.map_scroll_cb)
        map.connect('expose_event', self.map_expose_cb)
        self.attach(map, n, n + 1, 1, 2, gtk.FILL, gtk.FILL)
        map.show()
        map.set_size_request(16 * n, 0)
        self.add_events(gtk.gdk.KEY_PRESS_MASK | gtk.gdk.FOCUS_CHANGE)
        self.connect('key_press_event', self.key_press_cb)
        self.connect('focus_in_event', self.focus_in_cb)

        # Add a status bar to the botton
        self.statusbar = statusbar = gtk.Statusbar()
        self.status_context = statusbar.get_context_id('Message')
        self.attach(statusbar, 0, n + 1, 2, 3, gtk.FILL, gtk.FILL)
        statusbar.show()

        # font
        self.setFont(pango.FontDescription(prefs.getString('display_font')))

        # scroll to first difference when realised
        darea.connect_after('realize', self._realise_cb)

        # title
        self.setTitle(title)

    # updates the display font and resizes viewports as necessary
    def setFont(self, font):
        self.font = font
        metrics = self.get_pango_context().get_metrics(self.font)
        self.font_height = max(pixels(metrics.get_ascent() + metrics.get_descent()), 1)
        self.digit_width = metrics.get_approximate_digit_width()
        self.updateSize(True)
        self.map.queue_draw()

    # returns the 'column width' for a string -- used to help position
    # characters when tabs and other special characters are present
    def getStringColumnWidth(self, f, s):
        if self.prefs.getBool('display_hide_endofline'):
            s = strip_line_ending(self.panes[f].format, s)
        col = 0
        tab_width = self.prefs.getInt('display_tab_width')
        for c in s:
            v = ord(c)
            if v < 32:
                if c == '\t':
                    col += tab_width - col % tab_width
                else:
                    col += 2
            elif v >= 0x1100 and (v <= 0x115f
                or v == 0x2329
                or v == 0x232a
                or (v >= 0x2e80 and v <= 0xa4cf and v != 0x303f)
                or (v >= 0xac00 and v <= 0xd7a3)
                or (v >= 0xf900 and v <= 0xfaff)
                or (v >= 0xfe30 and v <= 0xfe6f)
                or (v >= 0xff00 and v <= 0xff60)
                or (v >= 0xffe0 and v <= 0xffe6)
                or (v >= 0x20000 and v <= 0x2ffff)):
                col += 2
            else:
                col += 1
        return col

    # returns the 'column width' for a single character created at column 'i'
    def characterWidth(self, i, c):
        v = ord(c)
        if v < 32:
            if c == '\t':
                tab_width = self.prefs.getInt('display_tab_width')
                return tab_width - i % tab_width
            return 2
        if v >= 0x1100 and (v <= 0x115f
            or v == 0x2329 or v == 0x232a
            or (v >= 0x2e80 and v <= 0xa4cf and v != 0x303f)
            or (v >= 0xac00 and v <= 0xd7a3)
            or (v >= 0xf900 and v <= 0xfaff)
            or (v >= 0xfe30 and v <= 0xfe6f)
            or (v >= 0xff00 and v <= 0xff60)
            or (v >= 0xffe0 and v <= 0xffe6)
            or (v >= 0x20000 and v <= 0x2ffff)):
            return 2
        return 1

    # returns the 'column width' of a string taking into account the optional
    # display of EOL characters 
    def stringWidth(self, f, s):
        if self.prefs.getBool('display_hide_endofline'):
            s = strip_line_ending(self.panes[f].format, s)
        w, width = 0, self.characterWidth
        for c in s:
            w += width(w, c)
        return w

    # translates a string into an array of the printable representation for
    # each character
    def expand(self, f, s):
        if self.prefs.getBool('display_hide_endofline'):
            s = strip_line_ending(self.panes[f].format, s)
        tab_width = self.prefs.getInt('display_tab_width')
        col = 0
        result = []
        for c in s:
            v = ord(c)
            if v < 32:
                if c == '\t':
                    width = tab_width - col % tab_width
                    result.append(width * ' ')
                else:
                    result.append('^' + chr(v + 64))
            else:
                result.append(c)
            col += self.characterWidth(col, c)
        return result

    # changes the viewer's mode to LINE_MODE
    def setLineMode(self):
        if self.mode == CHAR_MODE:
            self.dareas[self.current_pane].queue_draw()
            self.current_char = 0
            self.selection_char = 0
        elif self.mode == ALIGN_MODE:
            self.dareas[self.align_pane].queue_draw()
            self.dareas[self.current_pane].queue_draw()
            self.align_pane = 0
            self.align_line = 0
        self.mode = LINE_MODE
        self.updatePrompt()

    # changes the viewer's mode to CHAR_MODE
    def setCharMode(self):
        if self.mode == LINE_MODE:
            self.cursor_column = -1
            self.setCurrentChar(self.current_line, 0)
        elif self.mode == ALIGN_MODE:
            self.dareas[self.align_pane].queue_draw()
            self.cursor_column = -1
            self.align_pane = 0
            self.align_line = 0
            self.setCurrentChar(self.current_line, 0)
        self.mode = CHAR_MODE
        self.updatePrompt()

    # sets the syntax hightlighting rules
    def setSyntax(self, syntax):
        if self.syntax is not syntax:
            self.syntax = syntax
            # invalidate the syntax caches
            for pane in self.panes:
                pane.syntax_cache = []
            # force all panes to redraw
            for darea in self.dareas:
                darea.queue_draw()

    # returns True if any pane contains edits
    def hasEdits(self):
        for pane in self.panes:
            if pane.hasEdits():
                return True
        return False

    # sets the status bar text
    def setStatus(self, text):
        self.statusbar.pop(self.status_context)
        self.statusbar.push(self.status_context, text)

    # Changes to the diff viewer's state is recorded so they can be later
    # undone.  The recorded changes are organised into blocks that correspond
    # to high level action initiated by the user.  For example, pasting some
    # text may modify some lines and cause insertion of spacing lines to keep
    # proper alignment with the rest of the panes.  An undo operation initiated
    # by the user should undo all of these changes in a single step.
    # openUndoBlock() should be called when the action from a user, like a
    # mouse button press, menu item, etc. may cause change to the diff viewer's
    # state
    def openUndoBlock(self):
        self.undoblock = []

    # all changes to the diff viewer's state should create an Undo object and
    # add it to the current undo block using this method
    # this method does not need to be called when the state change is a result
    # of an undo/redo operation (self.undoblock is None in these cases)
    def addUndo(self, u):
        self.undoblock.append(u)

    # all openUndoBlock() calls should also have a matching closeUndoBlock()
    # this method collects all Undos created since the openUndoBlock() call
    # and pushes them onto the undo stack as a single unit
    def closeUndoBlock(self):
        if len(self.undoblock) > 0:
            self.redos = []
            self.undos.append(self.undoblock)
        self.undoblock = None

    # undo the last block of changes on the undo stack
    def undo(self):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            if len(self.undos) > 0:
                # move the block to the redo stack
                block = self.undos.pop()
                self.redos.append(block)
                # undo all changes in the block in reverse order
                for u in block[::-1]:
                    u.undo(self)

    # re-apply the block of changes on the redo stack
    def redo(self):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            if len(self.redos) > 0:
                # move the block to the undo stack
                block = self.redos.pop()
                self.undos.append(block)
                # re-apply all changes in the block
                for u in block:
                    u.redo(self)

    # returns the width of the viewport's line number column in pango units
    def getLineNumberWidth(self):
        # find the maximum number of digits for a line number from all panes
        n = 0
        for pane in self.panes:
            n = max(n, len(str(pane.max_line_number)))
        # pad the total width by the width of a digit on either side
        return (n + 2) * self.digit_width

    # returns the width of a string in pango units
    def getTextWidth(self, text):
        layout = self.create_pango_layout(text)
        layout.set_font_description(self.font)
        return layout.get_size()[0]

    # updates the size of the viewport
    # set 'compute_width' to False if the high water mark for line length can
    # be used to determine the required width for the viewport, use True for
    # this value otherwise
    def updateSize(self, compute_width, f=None):
        if compute_width:
            if f is None:
                panes = self.panes
            else:
                panes = [ self.panes[f] ]
            for f, pane in enumerate(panes):
                del pane.syntax_cache[:]
                del pane.diff_cache[:]
                # re-compute the high water mark
                pane.line_lengths = 0
                for line in pane.lines:
                    if line is not None:
                        line.compare_string = None
                        text = [ line.text ]
                        if line.is_modified:
                            text.append(line.modified_text)
                        for s in text:
                            if s is not None:
                                pane.line_lengths = max(pane.line_lengths, self.digit_width * self.getStringColumnWidth(f, s))
        # compute the maximum extents
        num_lines, line_lengths = 0, 0
        for pane in self.panes:
            num_lines = max(num_lines, len(pane.lines))
            line_lengths = max(line_lengths, pane.line_lengths)
        # the cursor can move one line past the last line of text, add it so we
        # can scroll to see this line
        num_lines += 1
        width = self.getLineNumberWidth() + self.digit_width + line_lengths
        width = pixels(width)
        height = self.font_height * num_lines
        # update the adjustments
        self.hadj.upper = width
        self.hadj.step_increment = self.font_height
        self.vadj.upper = height
        self.vadj.step_increment = self.font_height

    # returns a line from the specified pane and offset
    def getLine(self, f, i):
        lines = self.panes[f].lines
        if i < len(lines):
            return lines[i]

    # returns the text for the specified line
    def getLineText(self, f, i):
        line = self.getLine(f, i)
        if line is not None:
            return line.getText()

    # Undo for changes to the cached line ending style
    class SetFormatUndo:
        def __init__(self, f, format, old_format):
            self.data = (f, format, old_format)

        def undo(self, viewer):
            f, format, old_format = self.data
            viewer.setFormat(f, old_format)

        def redo(self, viewer):
            f, format, old_format = self.data
            viewer.setFormat(f, format)

    # sets the cached line ending style
    def setFormat(self, f, format):
        pane = self.panes[f]
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.SetFormatUndo(f, format, pane.format))
        pane.format = format

    # Undo for the creation of Line objects
    class InstanceLineUndo:
        def __init__(self, f, i, reverse):
            self.data = (f, i, reverse)

        def undo(self, viewer):
            f, i, reverse = self.data
            viewer.instanceLine(f, i, not reverse)

        def redo(self, viewer):
            f, i, reverse = self.data
            viewer.instanceLine(f, i, reverse)

    # creates an instance of a Line object for the specified pane and offset
    # deletes an instance when 'reverse' is set to True
    def instanceLine(self, f, i, reverse=False):
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.InstanceLineUndo(f, i, reverse))
        pane = self.panes[f]
        if reverse:
            pane.lines[i] = None
        else:
            line = FileDiffViewer.Line()
            pane.lines[i] = line

    # Undo for changing the text for a Line object
    class UpdateLineTextUndo:
        def __init__(self, f, i, old_is_modified, old_text, is_modified, text):
            self.data = (f, i, old_is_modified, old_text, is_modified, text)

        def undo(self, viewer):
            f, i, old_is_modified, old_text, is_modified, text = self.data
            viewer.updateLineText(f, i, old_is_modified, old_text)

        def redo(self, viewer):
            f, i, old_is_modified, old_text, is_modified, text = self.data
            viewer.updateLineText(f, i, is_modified, text)

    def getMapFlags(self, f, i):
        flags = 0
        compare_text = self.getCompareString(f, i)
        if f > 0 and self.getCompareString(f - 1, i) != compare_text:
            flags |= 1
        if f + 1 < len(self.panes) and self.getCompareString(f + 1, i) != compare_text:
            flags |= 2
        line = self.getLine(f, i)
        if line is not None and line.is_modified:
            flags |= 4
        return flags

    # update line 'i' in pane 'f' to contain 'text'
    def updateLineText(self, f, i, is_modified, text):
        pane = self.panes[f]
        line = pane.lines[i]
        flags = self.getMapFlags(f, i)
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.UpdateLineTextUndo(f, i, line.is_modified, line.modified_text, is_modified, text))
        line.is_modified = is_modified
        line.modified_text = text
        line.compare_string = None

        # update/invalidate all relevent caches and queue widgets for redraw
        if text is not None:
            pane.line_lengths = max(pane.line_lengths, self.digit_width * self.getStringColumnWidth(f, text))
        self.updateSize(False)

        y = int(self.vadj.get_value())
        h = self.font_height
        fs = []
        if f > 0:
            fs.append(f - 1)
        if f + 1 < len(self.panes):
            fs.append(f + 1)
        for fn in fs:
            otherpane = self.panes[fn]
            if i < len(otherpane.diff_cache):
                otherpane.diff_cache[i] = None
            darea = self.dareas[fn]
            darea.queue_draw_area(0, i * h - y, darea.get_allocation().width, h)
        if i < len(pane.syntax_cache):
            del pane.syntax_cache[i:]
        if i < len(pane.diff_cache):
            pane.diff_cache[i] = None
        self.dareas[f].queue_draw()
        if self.getMapFlags(f, i) != flags:
            self.map_cache = None
            self.map.queue_draw()

    # Undo for inserting a spacing line in a single pane
    class InsertNullUndo:
        def __init__(self, f, i, reverse):
            self.data = (f, i, reverse)

        def undo(self, viewer):
            f, i, reverse = self.data
            viewer.insertNull(f, i, not reverse)

        def redo(self, viewer):
            f, i, reverse = self.data
            viewer.insertNull(f, i, reverse)

    # insert a spacing line at line 'i' in pane 'f'
    # this caller must ensure the blocks and number of lines in each pane
    # are valid again
    def insertNull(self, f, i, reverse):
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.InsertNullUndo(f, i, reverse))
        pane = self.panes[f]
        lines = pane.lines
        # update/invalidate all relevent caches
        if reverse:
            del lines[i]
            if i < len(pane.syntax_cache):
                del pane.syntax_cache[i]
        else:
            lines.insert(i, None)
            if i < len(pane.syntax_cache):
                state = pane.syntax_cache[i][0]
                pane.syntax_cache.insert(i, [state, state, None, None])

    # Undo for manipulating a section of the line matching data
    class InvalidateLineMatchingUndo:
        def __init__(self, i, n, new_n):
            self.data = (i, n, new_n)

        def undo(self, viewer):
            i, n, new_n = self.data
            viewer.invalidateLineMatching(i, new_n, n)

        def redo(self, viewer):
            i, n, new_n = self.data
            viewer.invalidateLineMatching(i, n, new_n)

    # manipulate a section of the line matching data
    def invalidateLineMatching(self, i, n, new_n):
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.InvalidateLineMatchingUndo(i, n, new_n))
        # update/invalidate all relevent caches and queue widgets for redraw
        i2 = i + n
        for f, pane in enumerate(self.panes):
            if i < len(pane.diff_cache):
                if i2 + 1 < len(pane.diff_cache):
                    pane.diff_cache[i:i2] = new_n * [ None ]
                else:
                    del pane.diff_cache[i:]
            self.dareas[f].queue_draw()
        self.updateSize(False)
        self.map_cache = None
        self.map.queue_draw()

    # updates the alignment of 'n' lines starting from 'i'
    def updateAlignment(self, i, n, lines):
        new_n = len(lines[0])
        i2 = i + n
        # insert spacing lines
        for f in range(len(self.panes)):
            for j in range(i2-1, i-1, -1):
                if self.getLine(f, j) is None:
                    self.insertNull(f, j, True)
            temp = lines[f]
            for j in range(new_n):
                if temp[j] is None:
                    self.insertNull(f, i + j, False)
        # update line matching for this block

        # FIXME: we should be able to do something more intelligent here...
        # the syntax cache will become invalidated.... we don't really need to
        # do that...
        self.invalidateLineMatching(i, n, new_n)

    # Undo for changing how lines are cut into blocks for alignment
    class UpdateBlocksUndo:
        def __init__(self, old_blocks, blocks):
            self.data = (old_blocks, blocks)

        def undo(self, viewer):
            old_blocks, blocks = self.data
            viewer.updateBlocks(old_blocks)

        def redo(self, viewer):
            old_blocks, blocks = self.data
            viewer.updateBlocks(blocks)

    # change how lines are cut into blocks for alignment
    def updateBlocks(self, blocks):
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.UpdateBlocksUndo(self.blocks, blocks))
        self.blocks = blocks

    # insert 'n' blank lines in all panes
    def insertLines(self, i, n):
        # insert lines
        self.updateAlignment(i, 0, [ n * [ None ] for pane in self.panes ])
        pre, post = cutBlocks(i, self.blocks)
        pre.append(n)
        pre.extend(post)
        self.updateBlocks(pre)

        # update selection
        if self.current_line >= i:
            self.current_line += n
        if self.selection_line >= i:
            self.selection_line += n
        # queue redraws
        self.updateSize(False)
        self.map_cache = None
        self.map.queue_draw()

    # Undo for replacing the lines for a single pane with a new set
    class ReplaceLinesUndo:
        def __init__(self, f, lines, new_lines, max_num, new_max_num):
            self.data = (f, lines, new_lines, max_num, new_max_num)

        def undo(self, viewer):
            f, lines, new_lines, max_num, new_max_num = self.data
            viewer.replaceLines(f, new_lines, lines, new_max_num, max_num)

        def redo(self, viewer):
            f, lines, new_lines, max_num, new_max_num = self.data
            viewer.replaceLines(f, lines, new_lines, max_num, new_max_num)

    # replace the lines for a single pane with a new set
    def replaceLines(self, f, lines, new_lines, max_num, new_max_num):
        if self.undoblock is not None:
            # create an Undo object for the action
            self.addUndo(FileDiffViewer.ReplaceLinesUndo(f, lines, new_lines, max_num, new_max_num))
        pane = self.panes[f]
        pane.lines = new_lines
        # update/invalidate all relevent caches and queue widgets for redraw
        del pane.syntax_cache[:]
        pane.max_line_number = new_max_num
        self.dareas[f].queue_draw()
        self.updateSize(True, f)
        self.map_cache = None
        self.map.queue_draw()

    # create a hash for a line to use for line matching
    def _alignmentHash(self, line):
        text = line.getText()
        if text is None:
            return ''
        # hashes for non-null lines should start with '+' to distinguish them
        # from blank lines
        if self.prefs.getBool('align_ignore_blanklines') and isBlank(text):
            # consider all lines containing only white space as the same
            return '+'
        if self.prefs.getBool('align_ignore_whitespace'):
            # strip all white space from the string
            for c in whitespace:
                text = text.replace(c, '')
        elif self.prefs.getBool('align_ignore_whitespace_changes'):
            # replace all blocks of white space with a single space
            pc = True
            r = []
            append = r.append
            for c in text:
                if c in whitespace:
                    if pc:
                       append(' ')
                       pc = False
                else:
                    append(c)
                    pc = True
            text = ''.join(r)
        if self.prefs.getBool('align_ignore_case'):
            # convert everything to upper case
            text = text.upper()
        return '+' + text

    # align sets of lines by insertting null spacers and updating the size
    # of blocks to which they belong
    #
    # Leftlines and rightlines are list of list of lines.  Only the inner list
    # of lines are aligned (leftlines[-1] and rightlines[0]). Any spacers
    # needed for alignment are inserted in all lists of lines for a particular
    # side to keep them all in sync.
    def alignBlocks(self, leftblocks, leftlines, rightblocks, rightlines):
        blocks = ( leftblocks, rightblocks )
        lines = ( leftlines, rightlines )
        # get the inner lines we are to match
        middle = ( leftlines[-1], rightlines[0] )
        # eliminate any existing spacer lines
        mlines = ( [ line for line in middle[0] if line is not None ],
                   [ line for line in middle[1] if line is not None ] )
        s1, s2 = mlines
        n1, n2 = 0, 0
        # hash lines according to the alignment preferences
        t1 = [ self._alignmentHash(s) for s in s1 ]
        t2 = [ self._alignmentHash(s) for s in s2 ]
        # align s1 and s2 by inserting spacer lines
        # this will be used to determine which lines from the inner lists of
        # lines should be neighbours
        for block in difflib.SequenceMatcher(None, t1, t2).get_matching_blocks():
            delta = (n1 + block[0]) - (n2 + block[1])
            if delta < 0:
                # insert spacer lines in s1
                i = n1 + block[0]
                s1[i:i] = -delta * [ None ]
                n1 -= delta
            elif delta > 0:
                # insert spacer lines in s2
                i = n2 + block[1]
                s2[i:i] = delta * [ None ]
                n2 += delta
        nmatch = len(s1)

        # insert spacer lines in leftlines and rightlines and increase the
        # size of blocks in leftblocks and rightblocks as spacer lines are
        # inserted
        #
        # advance one row at a time inserting spacer lines as we go
        # 'i' indicates which row we are processing
        # 'k' indicates which pair of neighbours we are processing
        i, k = 0, 0
        bi = [ 0, 0 ]
        bn = [ 0, 0 ]
        while True:
            # if we have reached the end of the list for any side, it needs
            # spacer lines to align with the other side
            insert = [ i >= len(m) for m in middle  ]
            if insert == [ True, True ]:
                # we have reached the end of both inner lists of lines
                # we are done
                break
            if insert == [ False, False ] and k < nmatch:
                # determine if either side needs spacer lines to make the
                # inner list of lines match up
                accept = True
                for j in range(2):
                    m = mlines[j][k]
                    if middle[j][i] is not m:
                        # this line does not correspond to the pair of
                        # neighbours we expected
                        if m is None:
                            # we expected to find a null here so insert one
                            insert[j] = True
                        else:
                            # we have a null but didn't expect one we will not
                            # obtain the pairing we expected by iserting nulls
                            accept = False
                if accept:
                    # our lines will be correctly paired up
                    # move on to the next pair
                    k += 1
                else:
                    # insert spacer lines as needed
                    insert = [ m[i] is not None for m in middle ]
            for j in range(2):
                if insert[j]:
                    # insert spacers lines for side 'j'
                    for temp in lines[j]:
                        temp.insert(i, None)
                    blocksj = blocks[j]
                    bij = bi[j]
                    bnj = bn[j]
                    # append a new block if needed
                    if len(blocksj) == 0:
                        blocksj.append(0)
                    # advance to the current block
                    while bnj + blocksj[bij] < i:
                        bnj += blocksj[bij]
                        bij += 1
                    # increase the current block size
                    blocksj[bij] += 1
            # advance to the next row
            i += 1

    # replace the contents of pane 'f' with the strings list of strings 'ss'
    def updatePane(self, f, ss):
        # determine the format for the text
        self.setFormat(f, getFormat(ss))

        # create an initial set of blocks for the lines
        blocks = []
        n = len(ss)
        if n > 0:
            blocks.append(n)
        # create line objects for the text
        mid = [ [ FileDiffViewer.Line(j + 1, ss[j]) for j in range(n) ] ]

        if f > 0:
            # align with panes to the left
            # use copies so the originals can be used by the Undo object
            leftblocks = self.blocks[:]
            leftlines = [ pane.lines[:] for pane in self.panes[:f] ]
            removeNullLines(leftblocks, leftlines)
            self.alignBlocks(leftblocks, leftlines, blocks, mid)
            mid[:0] = leftlines
            blocks = mergeBlocks(leftblocks, blocks)
        if f + 1 < len(self.panes):
            # align with panes to the right
            # use copies so the originals can be used by the Undo object
            rightblocks = self.blocks[:]
            rightlines = [ pane.lines[:] for pane in self.panes[f + 1:] ]
            removeNullLines(rightblocks, rightlines)
            self.alignBlocks(blocks, mid, rightblocks, rightlines)
            mid.extend(rightlines)
            blocks = mergeBlocks(blocks, rightblocks)

        # update the lines for this pane
        pane = self.panes[f]
        old_n = len(pane.lines)
        new_n = len(mid[f])
        self.replaceLines(f, pane.lines, mid[f], pane.max_line_number, n)

        # insert or remove spacer lines from the other panes
        for f_idx in range(len(self.panes)):
            if f_idx != f:
                for j in range(old_n-1, -1, -1):
                    if self.getLine(f_idx, j) is None:
                        self.insertNull(f_idx, j, True)
                temp = mid[f_idx]
                for j in range(new_n):
                    if temp[j] is None:
                        self.insertNull(f_idx, j, False)

        # update the blocks
        self.invalidateLineMatching(0, old_n, new_n)
        self.updateBlocks(blocks)

    # change the label for pane 'f' to 's'
    def setLabel(self, f, s):
        self.panes[f].label = s
        self.labels[f].set_text(s)

        # choose a short but descriptive title for the viewer
        names = []
        unique_names = {}
        for pane in self.panes:
            s = pane.spec.name
            if s is not None:
                s = os.path.basename(s)
                names.append(s)
                unique_names[s] = None

        if len(unique_names) == 1:
            self.setTitle(names[0])
        else:
            self.setTitle(' : '.join(names))

    # set the viewer's title
    def setTitle(self, s):
        self.title = s
        self.emit('title_changed', s)

    # load a new file into pane 'f'
    # 'spec' indicates the name of the file and how to retrieve it from the
    # version control system if applicable
    def load(self, f, spec):
        name = spec.name
        label = spec.get_label()
        stat = None
        if name is None:
            # reset to an empty pane
            ss = []
        else:
            rev = spec.revision
            try:
                if rev is None:
                    # we are loading a plain file
                    fd = open(name, 'rb')
                    # get the file's modification times so we can detect changes
                    stat = os.stat(name)
                else:
                    if spec.vcs is None:
                        raise IOError('Not under revision control.')
                    fullname = os.path.abspath(name)
                    # find command to retrieve the file from the version
                    # control system
                    bash_pref, cmd = spec.vcs.getRevisionCommand(self.prefs, fullname, rev)
                    dn = os.path.dirname(fullname)
                    if isWindows() and self.prefs.getBool(bash_pref):
                        # launch the command from a bash shell is requested
                        cmd = [ self.prefs.convertToNativePath('/bin/bash.exe'), '-l', '-c', 'cd %s; %s' % (bashEscape(dn), ' '.join([ bashEscape(arg) for arg in cmd ])) ]
                        dn = None
                    # use subprocess.Popen to retrieve the file contents
                    if isWindows():
                        info = subprocess.STARTUPINFO()
                        info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
                        info.wShowWindow = subprocess.SW_HIDE
                    else:
                        info = None
                    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=dn, startupinfo=info)
                    proc.stdin.close()
                    proc.stderr.close()
                    fd = proc.stdout
                # read the file's contents
                ss = fd.readlines()
                fd.close()
                # report any errors
                if rev is not None and proc.wait() != 0:
                    raise IOError('Command failed.')
                # convert file contents to unicode
                if spec.encoding is None:
                    ss, spec.encoding = self.prefs.convertToUnicode(ss)
                else:
                    ss = [ unicode(s, spec.encoding) for s in ss ]
            except (IOError, OSError, UnicodeDecodeError, WindowsError):
                # FIXME: this can occur before the toplevel window is drawn
                if rev is not None:
                    msg = 'Error reading revision %(rev)s of "%(file)s".' % { 'rev': rev, 'file': name }
                else:
                    msg = 'Error reading %s.' % (repr(name), )
                dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_ERROR, msg)
                dialog.run()
                dialog.destroy()
                return
        # update the panes contents, last modified time, and label
        self.updatePane(f, ss)
        pane = self.panes[f]
        pane.spec = spec
        pane.last_stat = pane.stat = stat
        self.setLabel(f, label)
        # use the file name to choose appropriate syntax highlighting rules
        if name is not None:
            syntax = theResources.getSyntaxByFilename(name)
            if syntax is not None:
                self.setSyntax(syntax)

    # update the contents for a line, creating the line if necessary
    def updateText(self, f, i, text, is_modified=True):
        if self.panes[f].lines[i] is None:
            self.instanceLine(f, i)
        self.updateLineText(f, i, is_modified, text)

    # replace the current selection with 'text'
    def replaceText(self, text):
        # record the edit mode as we will be updating the selection too
        self.recordEditMode()

        # find the extents of the current selection
        f = self.current_pane
        pane = self.panes[f]
        line0, line1 = self.selection_line, self.current_line
        if self.mode == LINE_MODE:
            col0, col1 = 0, 0
            if line1 < line0:
                line0, line1 = line1, line0
            if line1 < len(pane.lines):
                line1 += 1
        else:
            col0, col1 = self.selection_char, self.current_char
            if line1 < line0 or (line1 == line0 and col1 < col0):
                line0, col0, line1, col1 = line1, col1, line0, col0

        # update text
        if text is None:
            text = ''
        # split the replacement text into lines
        ss = text.split('\n')
        last = ss.pop()
        ss = [ s + '\n' for s in ss ]
        ss.append(last)
        # change the format to that of the target pane
        if pane.format == 0:
            self.setFormat(f, getFormat(ss))
        ss = [ convert_to_format(s, pane.format) for s in ss ]
        # prepend original text that was before the selection
        if col0 > 0:
            pre = self.getLineText(f, line0)[:col0]
            ss[0] = pre + ss[0]
        # remove the last line as it needs special casing
        last = ss.pop()
        cur_line = line0 + len(ss)
        lastcol = len(last)
        if lastcol > 0:
            # the replacement text does not end with a new line character
            # we need more text to finish the line, search forward for some
            # more text
            while line1 < len(pane.lines):
                s = self.getLineText(f, line1)
                line1 += 1
                if s is not None:
                    last = last + s[col1:]
                    break
                col1 = 0
            ss.append(last)
        elif col1 > 0:
            # append original text that was after the selection
            s = self.getLineText(f, line1)
            ss.append(s[col1:])
            line1 += 1
        # insert blank lines for more space if needed
        n_have = line1 - line0
        n_need = len(ss)
        if n_need > n_have:
            self.insertLines(line1, n_need - n_have)
        # update the text
        for i, s in enumerate(ss):
            self.updateText(f, line0 + i, s)
        # clear all unused lines
        if n_have > n_need:
            for i in range(n_need, n_have):
                self.updateText(f, line0 + i, None)
        # update selection
        if self.mode == LINE_MODE:
            self.selection_line = line0
            self.setCurrentLine(f, line0 + max(n_need, n_have) - 1, True)
        else:
            self.setCurrentChar(cur_line, lastcol)
        self.recordEditMode()

    # manually adjust line matching so 'line1' of pane 'f' is a neighbour of
    # 'line2' from pane 'f+1'
    def align(self, f, line1, line2):
        # record the edit mode as we will be updating the selection too
        self.recordEditMode()

        # find the smallest span of blocks that inclues line1 and line2
        start = line1
        end = line2
        if end < start:
            start, end = end, start
        pre_blocks = []
        mid = []
        post_blocks = []
        n = 0
        for b in self.blocks:
            if n + b <= start:
                dst = pre_blocks
            elif n <= end:
                dst = mid
            else:
                dst = post_blocks
            dst.append(b)
            n += b
        start = sum(pre_blocks)
        end = start + sum(mid)

        # cut the span of blocks into three sections:
        # 1. lines before the matched pair
        # 2. the matched pair
        # 3. lines after the matched pair
        # each section has lines and blocks for left and right sides
        lines_s = [ [], [], [] ]
        cutblocks = [ [], [], [] ]
        lines = [ pane.lines for pane in self.panes ]
        nlines = len(lines[0])
        for temp, m in zip([ lines[:f + 1], lines[f + 1:] ], [ line1, line2 ]):
            # cut the blocks just before the line being matched
            pre, post = cutBlocks(m - start, mid)
            if len(temp) == 1:
                # if we only have one pane on this side, we don't need to
                # preserve other cuts
                pre = createBlock(sum(pre))
            # the first section of lines to match
            lines_s[0].append([ s[start:m] for s in temp ])
            cutblocks[0].append(pre)
            # the line to match may be after the actual lines
            if m < nlines:
                m1 = [ [ s[m] ] for s in temp ]
                m2 = [ s[m + 1:end] for s in temp ]
                # cut the blocks just after the line being matched
                b1, b2 = cutBlocks(1, post)
                if len(temp) == 1:
                    # if we only have one pane on this side, we don't need to
                    # preserve other cuts
                    b2 = createBlock(sum(b2))
            else:
                m1 = [ [] for s in temp ]
                m2 = [ [] for s in temp ]
                b1, b2 = [], []
            # the second section of lines to match
            lines_s[1].append(m1)
            cutblocks[1].append(b1)
            # the third section of lines to match
            lines_s[2].append(m2)
            cutblocks[2].append(b2)

        # align each section and concatenate the results
        finallines = [ [] for s in lines ]
        for b, lines_t in zip(cutblocks, lines_s):
            removeNullLines(b[0], lines_t[0])
            removeNullLines(b[1], lines_t[1])
            self.alignBlocks(b[0], lines_t[0], b[1], lines_t[1])
            temp = lines_t[0]
            temp.extend(lines_t[1])
            for dst, s in zip(finallines, temp):
                dst.extend(s)
            pre_blocks.extend(mergeBlocks(b[0], b[1]))
        pre_blocks.extend(post_blocks)

        # update the actual lines and blocks
        self.updateAlignment(start, end - start, finallines)
        self.updateBlocks(pre_blocks)

        # update selection
        self.setCurrentLine(self.current_pane, start + len(lines_s[0][0][0]))
        self.recordEditMode()

    # Undo for changing the selection mode and range
    class EditModeUndo:
        def __init__(self, mode, current_pane, current_line, current_char, selection_line, selection_char, cursor_column):
            self.data = (mode, current_pane, current_line, current_char, selection_line, selection_char, cursor_column)

        def undo(self, viewer):
            mode, current_pane, current_line, current_char, selection_line, selection_char, cursor_column = self.data
            viewer.setEditMode(mode, current_pane, current_line, current_char, selection_line, selection_char, cursor_column)

        def redo(self, viewer):
            self.undo(viewer)

    # appends an undo to reset to the specified selection mode and range
    # this should be called before and after actions that also change the
    # selection
    def recordEditMode(self):
        self.addUndo(FileDiffViewer.EditModeUndo(self.mode, self.current_pane, self.current_line, self.current_char, self.selection_line, self.selection_char, self.cursor_column))

    # change the selection mode
    def setEditMode(self, mode, f, current_line, current_char, selection_line, selection_char, cursor_column):
        # FIXME: this should also record the scroll spot
        old_f = self.current_pane
        self.mode = mode
        self.current_pane = f
        self.current_line = current_line
        self.current_char = current_char
        self.selection_line = selection_line
        self.selection_char = selection_char
        self.cursor_column = cursor_column
        if mode == CHAR_MODE:
            self.setCurrentChar(self.current_line, self.current_char, True)
        else:
            self.setCurrentLine(self.current_pane, self.current_line, True)
        # some selections display information in the status bar
        self.updatePrompt()
        # queue a redraw to show the updated selection
        self.dareas[old_f].queue_draw()

    # change the current selection in LINE_MODE
    # use extend=True to extend the selection
    def setCurrentLine(self, f, i, extend=False):
        # update selection
        old_f = self.current_pane
        old_line = self.current_line
        f = max(min(f, len(self.panes) - 1), 0)
        i = max(min(i, len(self.panes[f].lines)), 0)
        self.current_pane = f
        self.current_line = i
        if not extend:
            self.selection_line = i
        # update size
        h = self.font_height
        vadj = self.vadj
        v = vadj.get_value()
        ps = vadj.page_size
        lower = i * h
        upper = lower + h
        if lower < v:
            vadj.set_value(lower)
        elif upper > v + ps:
            vadj.set_value(upper - ps)
        # queue redraw
        self.dareas[old_f].queue_draw()
        self.dareas[f].queue_draw()

    # display the cursor's column position
    def updatePrompt(self):
        if self.mode == CHAR_MODE:
            j = self.current_char
            if j > 0:
                text = self.getLineText(self.current_pane, self.current_line)[:j]
                j = self.stringWidth(self.current_pane, text)
            self.setStatus(_('Column %d') % j)
        else:
            self.setStatus('')

    # change the current selection in CHAR_MODE
    # use extend=True to extend the selection
    def setCurrentChar(self, i, j, extend=False):
        f = self.current_pane
        self.cursor_column = -1
        self.current_line = i
        self.current_char = j
        if extend:
            gtk.clipboard_get(gtk.gdk.SELECTION_PRIMARY).set_text(self.getSelectedText())
        else:
            self.selection_line = i
            self.selection_char = j
        # scroll vertically to current position
        h = self.font_height
        lower = i * h
        upper = lower + h
        h = self.font_height
        vadj = self.vadj
        v = vadj.get_value()
        ps = vadj.page_size
        if lower < v:
            vadj.set_value(lower)
        elif upper > v + ps:
            vadj.set_value(upper - ps)
        # scroll horizontally to current position
        # but try to keep the line numbers visible
        if j > 0:
            text = ''.join(self.expand(f, self.getLineText(f, i)[:j]))
            lower = self.getTextWidth(text)
        else:
            lower = 0
        upper = lower + self.getLineNumberWidth() + self.digit_width
        lower = pixels(lower)
        upper = pixels(upper)
        hadj = self.hadj
        v = hadj.get_value()
        ps = hadj.page_size
        if lower < v:
            hadj.set_value(lower)
        elif upper > v + ps:
            hadj.set_value(upper - ps)
        # queue redraw
        self.dareas[f].queue_draw()
        self.updatePrompt()

    # returns the currently selected text
    def getSelectedText(self):
        f = self.current_pane
        start, end = self.selection_line, self.current_line
        # find extents of selection
        if self.mode == LINE_MODE:
            if end < start:
                start, end = end, start
            end += 1
            col0, col1 = 0, 0
        else:
            col0, col1 = self.selection_char, self.current_char
            if end < start or (end == start and col1 < col0):
                start, col0, end, col1 = end, col1, start, col0
            if col1 > 0:
               end += 1
        # get the text for the selected lines
        end = min(end, len(self.panes[f].lines))
        ss = [ self.getLineText(f, i) for i in range(start, end) ]
        # trim out the unselected parts of the lines
        # check for col > 0 as some lines may be null
        if col1 > 0:
            ss[-1] = ss[-1][:col1]
        if col0 > 0:
            ss[0] = ss[0][col0:]
        return ''.join([ s for s in ss if s is not None ])

    # expands the selection to include everything
    def selectAll(self):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            f = self.current_pane
            self.selection_line = 0
            self.current_line = len(self.panes[f].lines)
            if self.mode == CHAR_MODE:
                self.selection_char = 0
                self.current_char = 0
            self.dareas[f].queue_draw()

    # returns the index of the last character in text that should be left of
    # 'x' pixels from the edge of the darea widget
    # if partial=True, include characters only partially to the left of 'x'
    def _getPickedCharacter(self, f, text, x, partial):
        if text is None:
            return 0
        n = len(text)
        w = self.getLineNumberWidth()
        for i, s in enumerate(self.expand(f, text)):
            width = self.getTextWidth(s)
            tmp = w
            if partial:
                tmp += width // 2
            else:
                tmp += width
            if x < pixels(tmp):
                return i
            w += width
        return n

    # update the selection in response to a mouse button press
    def button_press(self, f, x, y, extend):
        i = min(y // self.font_height, len(self.panes[f].lines))
        if self.mode == CHAR_MODE and f == self.current_pane:
            text = strip_line_ending(self.panes[f].format, self.getLineText(f, i))
            j = self._getPickedCharacter(f, text, x, True)
            self.setCurrentChar(i, j, extend)
        else:
            self.setLineMode()
            self.setCurrentLine(f, i, extend and f == self.current_pane)

    # callback for mouse button presses in the text window
    def darea_button_press_cb(self, widget, event, f):
        self.get_toplevel().set_focus(self)
        x = int(event.x + self.hadj.get_value())
        y = int(event.y + self.vadj.get_value())
        nlines = len(self.panes[f].lines)
        i = min(y // self.font_height, nlines)
        if event.button == 1:
            # left mouse button
            if event.type == gtk.gdk._2BUTTON_PRESS:
                # double click
                if self.mode == LINE_MODE:
                    # change to CHAR_MODE
                    self.setCurrentLine(f, i)
                    # silently switch mode so the viewer does not scroll yet.
                    self.mode = CHAR_MODE
                    self.button_press(f, x, y, False)
                elif self.mode == CHAR_MODE and self.current_pane == f:
                    # select word
                    text = strip_line_ending(self.panes[f].format, self.getLineText(f, i))
                    if text is not None:
                        n = len(text)
                        j = self._getPickedCharacter(f, text, x, False)
                        if j < n:
                            ss = self.expand(f, text[:n])
                            c = theResources.getCharacterClass(text[j])
                            k = j
                            while k > 0 and theResources.getCharacterClass(text[k - 1]) == c:
                                k -= 1
                            while j < n and theResources.getCharacterClass(text[j]) == c:
                                j += 1
                            self.setCurrentChar(i, k)
                            self.setCurrentChar(i, j, True)
            elif event.type == gtk.gdk._3BUTTON_PRESS:
                # triple click, select a whole line
                if self.mode == CHAR_MODE and self.current_pane == f:
                    i2 = min(i + 1, nlines)
                    self.setCurrentChar(i, 0)
                    self.setCurrentChar(i2, 0, True)
            else:
                # update the selection
                is_shifted = event.state & gtk.gdk.SHIFT_MASK
                extend = (is_shifted and f == self.current_pane)
                self.button_press(f, x, y, extend)
        elif event.button == 2:
            # middle mouse button, paste primary selection
            if self.mode == CHAR_MODE and f == self.current_pane:
                self.button_press(f, x, y, False)
                gtk.clipboard_get(gtk.gdk.SELECTION_PRIMARY).request_text(self.receive_clipboard_text_cb)
        elif event.button == 3:
            # right mouse button, raise context sensitive menu
            flag = (self.mode == LINE_MODE and (f == self.current_pane + 1 or f == self.current_pane - 1))
            can_align = (flag or self.mode == ALIGN_MODE)
            can_isolate = (self.mode == LINE_MODE and f == self.current_pane)
            can_merge = (self.mode == LINE_MODE and f != self.current_pane)
            can_swap = (f != self.current_pane)
            can_select = ((self.mode == LINE_MODE or self.mode == CHAR_MODE) and f == self.current_pane)

            menu = createMenu(
                      [ [_('Align to Selection'), self.align_to_selection_cb, [f, i], gtk.STOCK_EXECUTE, None, can_align],
                      [_('Isolate'), self.isolate_cb, None, None, None, can_isolate ],
                      [_('Clear Edits'), self.clear_edits_cb, None, gtk.STOCK_CLEAR, None, can_isolate],
                      [_('Merge'), self.merge_lines_cb, f, None, None, can_merge],
                      [],
                      [_('Swap with Selected Pane'), self.swap_panes_cb, f, None, None, can_swap],
                      [],
                      [_('Cut'), self.cut_cb, None, gtk.STOCK_CUT, None, can_select],
                      [_('Copy'), self.copy_cb, None, gtk.STOCK_COPY, None, can_select],
                      [_('Paste'), self.paste_cb, None, gtk.STOCK_PASTE, None, can_select],
                      [],
                      [_('Select All'), self.select_all_cb, None, None, None, can_select] ])
            menu.popup(None, None, None, event.button, event.time)

    # callback used to notify us about click and drag motion
    def darea_motion_notify_cb(self, widget, event, f):
        if event.state & gtk.gdk.BUTTON1_MASK:
            # left mouse button
            extend = (f == self.current_pane)
            x = int(event.x + self.hadj.get_value())
            y = int(event.y + self.vadj.get_value())
            self.button_press(f, x, y, extend)

    # return a list of (begin, end, flag) tuples marking characters that differ
    # from the text in line 'i' from panes 'f' and 'f+1'
    # return the results for pane 'f' if idx=0 and 'f+1' if idx=1
    def getDiffRanges(self, f, i, idx, flag):
        result = []
        s1 = nullToEmpty(self.getLineText(f, i))
        s2 = nullToEmpty(self.getLineText(f + 1, i))

        # ignore blank lines if specified
        if self.prefs.getBool('display_ignore_blanklines') and isBlank(s1) and isBlank(s2):
            return result

        # ignore white space preferences
        ignore_whitespace = self.prefs.getBool('display_ignore_whitespace')
        if ignore_whitespace or self.prefs.getBool('display_ignore_whitespace_changes'):
            if idx == 0:
                s = s1
            else:
                s = s2
            s1 = nullToEmpty(self.getCompareString(f, i))
            s2 = nullToEmpty(self.getCompareString(f + 1, i))

            # build a mapping from characters in compare string to those in the
            # original string
            v = 0
            lookup = []
            # we only need to consider white space here as those are the only
            # ones that can change the number of characters in the compare
            # string
            if ignore_whitespace:
                # all white space characters were removed
                for c in s:
                    if c not in whitespace:
                        lookup.append(v)
                    v += 1
            else:
                # all white space characters were replaced with a single space
                first = True
                for c in s:
                    if c in whitespace:
                        # only include the first white space character of a span
                        if first:
                            lookup.append(v)
                            first = False
                    else:
                        lookup.append(v)
                        first = True
                    v += 1
            lookup.append(v)
        else:
            lookup = None

        start = 0
        for block in difflib.SequenceMatcher(None, s1, s2).get_matching_blocks():
            end = block[idx]
            # skip zero length blocks
            if start < end:
                if lookup is None:
                    result.append((start, end, flag))
                else:
                    # map to indices for the original string
                    lookup_start = lookup[start]
                    lookup_end = lookup[end]
                    # scan for whitespace and skip those sections if specified
                    for j in range(lookup_start, lookup_end):
                        if ignore_whitespace and s[j] in whitespace:
                            if lookup_start != j:
                                result.append((lookup_start, j, flag))
                            lookup_start = j + 1
                    if lookup_start != lookup_end:
                        result.append((lookup_start, lookup_end, flag))
            start = end + block[2]
        return result

    # returns a hash of a string that can be used to quickly compare strings
    # according to the display preferences
    def getCompareString(self, f, i):
        line = self.getLine(f, i)
        if line is None:
            return None
        # if a cached value exists, use it
        s = line.compare_string
        if s is not None:
            return s
        # compute a new hash and cache it
        s = line.getText()
        if s is not None:
            if self.prefs.getBool('display_ignore_blanklines') and isBlank(s):
                return None
            if self.prefs.getBool('display_ignore_whitespace'):
                # strip all white space characters
                for c in whitespace:
                    s = s.replace(c, '')
            elif self.prefs.getBool('display_ignore_whitespace_changes'):
                # map all spans of white space characters to a single space
                first = True
                temp = []
                for c in s:
                    if c in whitespace:
                        if first:
                            temp.append(' ')
                            first = False
                    else:
                        temp.append(c)
                        first = True
                s = ''.join(temp)
            if self.prefs.getBool('display_ignore_case'):
                # force everything to be upper case
                s = s.upper()
            # cache the hash
            line.compare_string = s
        return s

    # draw the text viewport
    def darea_expose_cb(self, widget, event, f):
        pane = self.panes[f]
        syntax = self.syntax

        offset_x, offset_y, width, height = event.area
        x = offset_x + int(self.hadj.get_value())
        y = offset_y + int(self.vadj.get_value())

        # draw to a pixmap to avoid screen flicker
        pixmap = gtk.gdk.Pixmap(widget.window, width, height)

        cr = pixmap.cairo_create()
        cr.translate(-x, -y)

        maxx = x + width
        maxy = y + height
        line_number_width = pixels(self.getLineNumberWidth())
        h = self.font_height

        diffcolours = [ theResources.getDifferenceColour(f), theResources.getDifferenceColour(f + 1) ]
        diffcolours.append((diffcolours[0] + diffcolours[1]) * 0.5)

        # iterate over each exposed line
        i = y // h
        y_start = i * h
        while y_start < maxy:
            line = self.getLine(f, i)

            # line numbers
            if 0 < maxx and line_number_width > x:
                cr.save()
                cr.rectangle(0, y_start, line_number_width, h)
                cr.clip()
                colour = theResources.getColour('line_number_background')
                cr.set_source_rgb(colour.red, colour.green, colour.blue)
                cr.paint()

                ## draw the line number
                if line is not None and line.line_number > 0:
                    colour = theResources.getColour('line_number')
                    cr.set_source_rgb(colour.red, colour.green, colour.blue)
                    layout = self.create_pango_layout(str(line.line_number))
                    layout.set_font_description(self.font)
                    w = pixels(layout.get_size()[0] + self.digit_width)
                    cr.move_to(line_number_width - w, y_start)
                    cr.show_layout(layout)
                cr.restore()

            x_start = line_number_width
            if x_start < maxx:
                cr.save()
                cr.rectangle(x_start, y_start, maxx - x_start, h)
                cr.clip()

                text = self.getLineText(f, i)
                ss = None

                # enlarge cache to fit pan.diff_cache[i]
                if i >= len(pane.diff_cache):
                    pane.diff_cache.extend((i - len(pane.diff_cache) + 1) * [ None ])
                # construct a list of ranges for this lines character
                # differences if not already cached
                if pane.diff_cache[i] is None:
                    flags = 0
                    temp_diff = []
                    comptext = self.getCompareString(f, i)
                    if f > 0:
                        # compare with neighbour to the left
                        if self.getCompareString(f - 1, i) != comptext:
                            flags |= 1
                        if text is not None:
                            temp_diff = mergeRanges(temp_diff, self.getDiffRanges(f - 1, i, 1, 1))
                    if f + 1 < len(self.panes):
                        # compare with neighbour to the right
                        if self.getCompareString(f + 1, i) != comptext:
                            flags |= 2
                        if text is not None:
                            temp_diff = mergeRanges(temp_diff, self.getDiffRanges(f, i, 0, 2))

                    chardiff = []
                    if text is not None:
                        # expand text into a list of visual representations
                        ss = self.expand(f, text)

                        # find the size of each region in pango units
                        old_end = 0
                        x_temp = 0
                        for start, end, tflags in temp_diff:
                            layout = self.create_pango_layout(''.join(ss[old_end:start]))
                            layout.set_font_description(self.font)
                            x_temp += layout.get_size()[0]
                            layout = self.create_pango_layout(''.join(ss[start:end]))
                            layout.set_font_description(self.font)
                            w = layout.get_size()[0]
                            chardiff.append((x_temp, w, diffcolours[tflags - 1]))
                            old_end = end
                            x_temp += w
                    # cache flags and character diff ranges
                    pane.diff_cache[i] = (flags, chardiff)
                else:
                    flags, chardiff = pane.diff_cache[i]

                # draw background
                colour = theResources.getColour('text_background')
                if flags != 0:
                    colour = (diffcolours[flags - 1] * theResources.getFloat('line_difference_alpha')).over(colour)
                cr.set_source_rgb(colour.red, colour.green, colour.blue)
                cr.paint()

                if text is not None:
                    # draw char diffs
                    alpha = theResources.getFloat('char_difference_alpha')
                    for start, w, colour in chardiff:
                        cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                        cr.rectangle(x_start + pixels(start), y_start, pixels(w), h)
                        cr.fill()

                if line is not None and line.is_modified:
                    # draw modified
                    colour = theResources.getColour('modified')
                    alpha = theResources.getFloat('modified_alpha')
                    cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                    cr.paint()
                if self.mode == ALIGN_MODE:
                    # draw align
                    if self.align_pane == f and self.align_line == i:
                        colour = theResources.getColour('align')
                        alpha = theResources.getFloat('align_alpha')
                        cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                        cr.paint()
                elif self.mode == LINE_MODE:
                    # draw line selection
                    if self.current_pane == f:
                        start, end = self.selection_line, self.current_line
                        if end < start:
                            start, end = end, start
                        if i >= start and i <= end:
                            colour = theResources.getColour('line_selection')
                            alpha = theResources.getFloat('line_selection_alpha')
                            cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                            cr.paint()
                elif self.mode == CHAR_MODE:
                    # draw char selection
                    if self.current_pane == f and text is not None:
                        start, end = self.selection_line, self.current_line
                        start_char, end_char = self.selection_char, self.current_char
                        if end < start or (end == start and end_char < start_char):
                            start, start_char, end, end_char = end, end_char, start, start_char
                        if start <= i and end >= i:
                            if start < i:
                                start_char = 0
                            if end > i:
                                end_char = len(text)
                            if start_char < end_char:
                                if ss is None:
                                    ss = self.expand(f, text)
                                layout = self.create_pango_layout(''.join(ss[:start_char]))
                                layout.set_font_description(self.font)
                                x_temp = layout.get_size()[0]
                                layout = self.create_pango_layout(''.join(ss[start_char:end_char]))
                                layout.set_font_description(self.font)
                                w = layout.get_size()[0]
                                colour = theResources.getColour('char_selection')
                                alpha = theResources.getFloat('char_selection_alpha')
                                cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                                cr.rectangle(x_start + pixels(x_temp), y_start, pixels(w), h)
                                cr.fill()

                if text is None:
                    # draw hatching
                    colour = theResources.getColour('hatch')
                    cr.set_source_rgb(colour.red, colour.green, colour.blue)
                    cr.set_line_width(1)
                    h2 = 2 * h
                    temp = line_number_width
                    if temp < x:
                        temp += ((x - temp) // h) * h
                    h_half = 0.5 * h
                    phase = [ h_half, h_half, -h_half, -h_half ]
                    for j in range(4):
                        x_temp = temp
                        y_temp = y_start
                        for k in range(j):
                            y_temp += phase[k]
                        cr.move_to(x_temp, y_temp)
                        for k in range(j, 4):
                            cr.rel_line_to(h_half, phase[k])
                            x_temp += h_half
                        while x_temp < maxx:
                            cr.rel_line_to(h, h)
                            cr.rel_line_to(h, -h)
                            x_temp += h2
                        cr.stroke()
                else:
                    # continue populating the cache until line 'i' is included
                    # in the syntax highlighting cache
                    n = len(pane.syntax_cache)
                    while i >= n:
                        temp = self.getLineText(f, n)
                        if syntax is None:
                            initial_state, end_state = None, None
                            if temp is None:
                                blocks = None
                            else:
                                blocks = [ (0, len(temp), 'text') ]
                        else:
                            # apply the syntax highlighting rules to identify
                            # ranges of similarly coloured characters
                            if n == 0:
                                initial_state = syntax.initial_state
                            else:
                                initial_state = pane.syntax_cache[-1][1]
                            if temp is None:
                                end_state, blocks = initial_state, None
                            else:
                                end_state, blocks = syntax.parse(initial_state, temp)
                        pane.syntax_cache.append([initial_state, end_state, blocks, None])
                        n += 1

                    # use the cache the position, layout, and colour of each
                    # span of characters
                    blocks = pane.syntax_cache[i][3]
                    if blocks is None:
                        # populate the cache item if it didn't exist
                        if ss is None:
                            ss = self.expand(f, text)
                        x_temp = 0
                        blocks = []
                        for start, end, tag in pane.syntax_cache[i][2]:
                            layout = self.create_pango_layout(''.join(ss[start:end]))
                            layout.set_font_description(self.font)
                            colour = theResources.getColour(tag)
                            blocks.append((x_temp, layout, colour))
                            x_temp += layout.get_size()[0]
                        pane.syntax_cache[i][3] = blocks

                    # draw text
                    for start, layout, colour in blocks:
                        cr.set_source_rgb(colour.red, colour.green, colour.blue)
                        cr.move_to(x_start + pixels(start), y_start)
                        cr.show_layout(layout)

                if self.current_pane == f and self.current_line == i:
                    # draw the cursor
                    colour = theResources.getColour('cursor')
                    cr.set_source_rgb(colour.red, colour.green, colour.blue)
                    cr.set_line_width(1)

                    if self.mode == CHAR_MODE:
                        if text is not None:
                            if ss is None:
                                ss = self.expand(f, text)
                            layout = self.create_pango_layout(''.join(ss[:self.current_char]))
                            layout.set_font_description(self.font)
                            x_temp = layout.get_size()[0]
                        else:
                            x_temp = 0
                        cr.move_to(x_start + pixels(x_temp) + 0.5, y_start)
                        cr.rel_line_to(0, h)
                        cr.stroke()
                    elif self.mode == LINE_MODE or self.mode == ALIGN_MODE:
                        cr.move_to(maxx, y_start + 0.5)
                        cr.line_to(x_start + 0.5, y_start + 0.5)
                        cr.line_to(x_start + 0.5, y_start + h - 0.5)
                        cr.line_to(maxx, y_start + h - 0.5)
                        cr.stroke()
                cr.restore()
            # advance to the next exposed line
            i += 1
            y_start += h

        # draw the pixmap to window
        gc = pixmap.new_gc()
        widget.window.draw_drawable(gc, pixmap, 0, 0, offset_x, offset_y, width, height)

    # callback for update the overview map when the scroll position changes
    def map_vadj_changed_cb(self, vadj):
        self.map.queue_draw()

    # callback to handle button presses on the overview map
    def map_button_press_cb(self, widget, event):
        vadj = self.vadj

        h = widget.get_allocation().height
        hmax = max(int(vadj.upper), h)

        # centre view about picked location
        y = event.y * hmax // h
        v = y - int(vadj.page_size / 2)
        v = max(v, int(vadj.lower))
        v = min(v, int(vadj.upper - vadj.page_size))
        vadj.set_value(v)

    # callback to handle mouse scrollwheel events
    def map_scroll_cb(self, widget, event):
        vadj = self.vadj
        v = vadj.get_value()
        delta = 100
        if event.direction == gtk.gdk.SCROLL_UP:
            delta = -delta
        v = max(v + delta, int(vadj.lower))
        v = min(v, int(vadj.upper - vadj.page_size))
        vadj.set_value(v)

    # redraws the overview map when a portion is exposed
    def map_expose_cb(self, widget, event):
        n = len(self.panes)

        # compute map if it hasn't already been cached
        # the map is a list of (start, end, flags) tuples for each pane
        # flags & 1 indicates differences with the pane to the left
        # flags & 2 indicates differences with the pane to the right
        # flags & 4 indicates modified lines
        # flags & 8 indicates regular lines with text
        if self.map_cache is None:
            nlines = len(self.panes[0].lines)
            start = n * [ 0 ]
            flags = n * [ 0 ]
            self.map_cache = [ [] for f in range(n) ]
            # iterate over each row of lines
            for i in range(nlines):
                nextflag = 0
                # iterate over each pane
                for f in range(n):
                    flag = nextflag
                    nextflag = 0
                    s0 = self.getCompareString(f, i)
                    # compare with neighbour to the right
                    if f + 1 < n:
                        if s0 != self.getCompareString(f + 1, i):
                            flag |= 2
                            nextflag |= 1
                    line = self.getLine(f, i)
                    if line is not None and line.is_modified:
                        # modified line
                        flag = 4
                    elif s0 is None:
                        # empty line
                        flag = 0
                    elif flag == 0:
                        # regular line
                        flag = 8
                    if flags[f] != flag:
                        if flags[f] != 0:
                            self.map_cache[f].append([start[f], i, flags[f]])
                        start[f] = i
                        flags[f] = flag
            # finish any incomplete ranges
            for f in range(n):
                if flags[f] != 0:
                    self.map_cache[f].append([start[f], nlines, flags[f]])

        # draw to a pixmap to avoid screen flicker
        x, y, width, height = event.area
        pixmap = gtk.gdk.Pixmap(widget.window, width, height)
        cr = pixmap.cairo_create()
        cr.translate(-x, -y)

        # clear
        colour = theResources.getColour('map_background')
        cr.set_source_rgb(colour.red, colour.green, colour.blue)
        cr.paint()
        bg_colour = theResources.getColour('text_background')
        modified_colour = theResources.getColour('modified')

        # get scroll position and total size
        h = widget.get_allocation().height
        w = widget.get_allocation().width
        vadj = self.vadj
        hmax = max(vadj.upper, h)

        # draw diff blocks
        wn = w / n
        pad = 1
        for f in range(n):
            diffcolours = [ theResources.getDifferenceColour(f), theResources.getDifferenceColour(f + 1) ]
            diffcolours.append((diffcolours[0] + diffcolours[1]) * 0.5)
            wx = f * wn
            # draw in two passes, more important stuff in the second pass
            # this ensures less important stuff does not obscure more important
            # data
            for p in range(2):
                for start, end, flag in self.map_cache[f]:
                    if p == 0 and flag == 8:
                        colour = bg_colour
                    elif p == 1 and flag & 7:
                        if flag & 4:
                            colour = modified_colour
                        else:
                            colour = diffcolours[(flag & 3) - 1]
                    else:
                        continue

                    # ensure the line is visible in the map
                    ymin = h * self.font_height * start // hmax
                    if ymin >= y + height:
                        break
                    yh = max(h * self.font_height * end // hmax - ymin, 1)
                    if ymin + yh <= y:
                        continue

                    cr.set_source_rgb(colour.red, colour.green, colour.blue)
                    cr.rectangle(wx + pad, ymin, wn - 2 * pad, yh)
                    cr.fill()

        # draw cursor
        vmin = int(vadj.get_value())
        vmax = vmin + vadj.page_size
        ymin = h * vmin // hmax
        if ymin < y + height:
            yh = h * vmax // hmax - ymin
            if yh > 1:
                yh -= 1
            if ymin + yh > y:
                colour = theResources.getColour('line_selection')
                alpha = theResources.getFloat('line_selection_alpha')
                cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
                cr.rectangle(0.5, ymin + 0.5, w - 1, yh - 1)
                cr.fill()

                colour = theResources.getColour('cursor')
                cr.set_source_rgb(colour.red, colour.green, colour.blue)
                cr.set_line_width(1)
                cr.rectangle(0.5, ymin + 0.5, w - 1, yh - 1)
                cr.stroke()

        # draw the pixmap to the window
        gc = pixmap.new_gc()
        widget.window.draw_drawable(gc, pixmap, 0, 0, x, y, width, height)

    # returns the maximum valid offset for a cursor position
    # cursors cannot be moved to the right of line ending characters
    def getMaxCharPosition(self, i):
        f = self.current_pane
        text = self.getLineText(f, i)
        if text is None:
           return 0
        return len_minus_line_ending(self.panes[f].format, text)

    # 'enter_align_mode' keybinding action
    def _line_mode_enter_align_mode(self):
        self.mode = ALIGN_MODE
        self.selection_line = self.current_line
        self.align_pane = self.current_pane
        self.align_line = self.current_line
        self.dareas[self.align_pane].queue_draw()

    # 'first_line' keybinding action
    def _first_line(self):
        self.setCurrentLine(self.current_pane, 0)

    # 'extend_first_line' keybinding action
    def _extend_first_line(self):
        self.setCurrentLine(self.current_pane, 0, True)

    # 'last_line' keybinding action
    def _last_line(self):
        f = self.current_pane
        self.setCurrentLine(f, len(self.panes[f].lines))

    # 'extend_last_line' keybinding action
    def _extend_last_line(self):
        f = self.current_pane
        self.setCurrentLine(f, len(self.panes[f].lines), True)

    # 'up' keybinding action
    def _line_mode_up(self, extend=False):
        self.setCurrentLine(self.current_pane, self.current_line - 1, extend)

    # 'extend_up' keybinding action
    def _line_mode_extend_up(self):
        self._line_mode_up(True)

    # 'down' keybinding action
    def _line_mode_down(self, extend=False):
        self.setCurrentLine(self.current_pane, self.current_line + 1, extend)

    # 'extend_down' keybinding action
    def _line_mode_extend_down(self):
        self._line_mode_down(True)

    # 'left' keybinding action
    def _line_mode_left(self, extend=False):
        self.setCurrentLine(self.current_pane - 1, self.current_line, extend)

    # 'extend_left' keybinding action
    def _line_mode_extend_left(self):
        self._line_mode_left(True)

    # 'right' keybinding action
    def _line_mode_right(self, extend=False):
        self.setCurrentLine(self.current_pane + 1, self.current_line, extend)

    # 'extend_right' keybinding action
    def _line_mode_extend_right(self):
        self._line_mode_right(True)

    # 'page_up' keybinding action
    def _line_mode_page_up(self, extend=False):
        delta = int(self.vadj.page_size // self.font_height)
        self.setCurrentLine(self.current_pane, self.current_line - delta, extend)

    # 'extend_page_up' keybinding action
    def _line_mode_extend_page_up(self):
        self._line_mode_page_up(True)

    # 'page_down' keybinding action
    def _line_mode_page_down(self, extend=False):
        delta = int(self.vadj.page_size // self.font_height)
        self.setCurrentLine(self.current_pane, self.current_line + delta, extend)

    # 'extend_page_down' keybinding action
    def _line_mode_extend_page_down(self):
        self._line_mode_page_down(True)

    # 'delete_text' keybinding action
    def _delete_text(self):
        self.replaceText('')

    # 'enter_line_mode' keybinding action
    def _align_mode_enter_line_mode(self):
        self.selection_line = self.current_line
        self.setLineMode()

    # 'align' keybinding action
    def _align_text(self):
        f1 = self.align_pane
        line1 = self.align_line
        line2 = self.current_line
        self.selection_line = line2
        self.setLineMode()
        if self.current_pane == f1 + 1:
            self.align(f1, line1, line2)
        elif self.current_pane + 1 == f1:
            self.align(self.current_pane, line2, line1)

    # callback for keyboard events
    # only keypresses that are not handled by menu item accelerators reach here
    def key_press_cb(self, widget, event):
        retval = False
        # determine the modified keys used
        mask = event.state & (gtk.gdk.SHIFT_MASK | gtk.gdk.CONTROL_MASK)
        if event.state & gtk.gdk.LOCK_MASK:
            mask ^= gtk.gdk.SHIFT_MASK
        self.openUndoBlock()
        if self.mode == LINE_MODE:
            # check if the keyval matches a line mode action
            action = theResources.getActionForKey('line_mode', event.keyval, mask)
            if self._line_mode_actions.has_key(action):
                self._line_mode_actions[action]()
                retval = True
        elif self.mode == CHAR_MODE:
            f = self.current_pane
            is_shifted = event.state & gtk.gdk.SHIFT_MASK
            is_ctrl = event.state & gtk.gdk.CONTROL_MASK
            retval = True
            # check if the keyval matches a character mode action
            action = theResources.getActionForKey('character_mode', event.keyval, mask)
            if self._character_mode_actions.has_key(action):
                self._character_mode_actions[action]()
            # allow CTRL-Tab for widget navigation
            elif event.keyval == gtk.keysyms.Tab and event.state & gtk.gdk.CONTROL_MASK:
                retval = False
            # up/down cursor navigation
            elif event.keyval in [ gtk.keysyms.Up, gtk.keysyms.Down, gtk.keysyms.Page_Up, gtk.keysyms.Page_Down ]:
                i = self.current_line
                # move back to the remembered cursor column if possible
                col = self.cursor_column
                if col < 0:
                    # find the current cursor column
                    s = nullToEmpty(self.getLineText(f, i))[:self.current_char]
                    col = self.getStringColumnWidth(f, s)
                if event.keyval in [ gtk.keysyms.Up, gtk.keysyms.Down ]:
                    delta = 1
                else:
                    delta = int(self.vadj.page_size // self.font_height)
                if event.keyval in [ gtk.keysyms.Up, gtk.keysyms.Page_Up ]:
                    delta = -delta
                i += delta
                j = 0
                nlines = len(self.panes[f].lines)
                if i < 0:
                    i = 0
                elif i > nlines:
                    i = nlines
                else:
                    # move the cursor to column 'col' if possible
                    s = self.getLineText(f, i)
                    if s is not None:
                        s = strip_line_ending(self.panes[f].format, s)
                        idx = 0
                        for c in s:
                            w = self.characterWidth(idx, c)
                            if idx + w > col:
                                break
                            idx += w
                            j += 1
                self.setCurrentChar(i, j, is_shifted)
                self.cursor_column = col
            # home key
            elif event.keyval == gtk.keysyms.Home:
                if is_ctrl:
                    i = 0
                else:
                    i = self.current_line
                self.setCurrentChar(i, 0, is_shifted)
            # end key
            elif event.keyval == gtk.keysyms.End:
                if is_ctrl:
                    i = len(self.panes[f].lines)
                    j = 0
                else:
                    i = self.current_line
                    j = self.getMaxCharPosition(i)
                self.setCurrentChar(i, j, is_shifted)
            # cursor left navigation
            elif event.keyval == gtk.keysyms.Left:
                i = self.current_line
                j = self.current_char
                if j > 0:
                    j -= 1
                elif i > 0:
                    i -= 1
                    j = self.getMaxCharPosition(i)
                self.setCurrentChar(i, j, is_shifted)
            # cursor right navigation
            elif event.keyval == gtk.keysyms.Right:
                i = self.current_line
                j = self.current_char
                if j < self.getMaxCharPosition(i):
                    j += 1
                elif i < len(self.panes[f].lines):
                    i += 1
                    j = 0
                self.setCurrentChar(i, j, is_shifted)
            # backspace
            elif event.keyval == gtk.keysyms.BackSpace:
                s = ''
                i = self.current_line
                j = self.current_char
                if self.selection_line == i and self.selection_char == j:
                    if j > 0:
                        # delete back to the last soft-tab location if there
                        # are only spaces and tabs from the beginning of the
                        # line to the current cursor position
                        text = self.getLineText(f, i)[:j]
                        for c in text:
                            if c not in ' \t':
                                j -= 1
                                break
                        else:
                            w = self.stringWidth(f, text)
                            width = self.prefs.getInt('editor_soft_tab_width')
                            w = (w - 1) // width * width
                            if self.prefs.getBool('editor_expand_tabs'):
                                s = u' ' * w
                            else:
                                width = self.prefs.getInt('display_tab_width')
                                s = u'\t' * (w // width) + u' ' * (w % width)
                            j = 0
                    else:
                        # delete back to an end of line character from the
                        # previous line
                        while i > 0:
                            i -= 1
                            text = self.getLineText(f, i)
                            if text is not None:
                                j = self.getMaxCharPosition(i)
                                break
                    self.current_line = i
                    self.current_char = j
                self.replaceText(s)
            # delete key
            elif event.keyval == gtk.keysyms.Delete:
                i = self.current_line
                j = self.current_char
                if self.selection_line == i and self.selection_char == j:
                    # advance the selection to the next character so we can
                    # delete it
                    text = self.getLineText(f, i)
                    while text is None and i < len(self.panes[f].lines):
                        i += 1
                        j = 0
                        text = self.getLineText(f, i)
                    if text is not None:
                        if j < self.getMaxCharPosition(i):
                            j += 1
                        else:
                            i += 1
                            j = 0
                    self.current_line = i
                    self.current_char = j
                self.replaceText('')
            # return key, add the platform specific end of line characters
            elif event.keyval == gtk.keysyms.Return:
                self.replaceText(unicode(os.linesep))
            # insert key
            elif event.keyval == gtk.keysyms.Tab:
                # insert soft-tabs if there are only spaces and tabs from the
                # beginning of the line to the cursor location
                start_i, start_j = self.selection_line, self.selection_char
                end_i, end_j = self.current_line, self.current_char
                if end_i < start_i or (end_i == start_i and end_j < start_j):
                    start_i, start_j, end_i, end_j = end_i, end_j, start_i, start_j
                temp = start_j
                if temp > 0:
                    text = self.getLineText(f, start_i)[:start_j]
                    w = self.stringWidth(f, text)
                    while temp > 0 and text[temp - 1] in ' \t':
                        temp -= 1
                else:
                    w = 0
                tab_width = self.prefs.getInt('display_tab_width')
                if temp > 0:
                    # insert a regular tab
                    ws = tab_width - w % tab_width
                else:
                    # insert a soft tab
                    self.selection_line = start_i
                    self.selection_char = 0
                    self.current_line = end_i
                    self.current_char = end_j
                    width = self.prefs.getInt('editor_soft_tab_width')
                    ws = w + width - w % width
                    w = 0
                if self.prefs.getBool('editor_expand_tabs'):
                    # convert to spaces
                    s = u' ' * ws
                else:
                    # replace with tab characters where possible
                    s = u'\t' * ((w + ws) // tab_width - w // tab_width)
                    s += u' ' * ((w + ws) % tab_width)
                self.replaceText(s)
            # handle all other printable characters
            elif len(event.string) > 0:
                self.replaceText(event.string)
        elif self.mode == ALIGN_MODE:
            # check if the keyval matches an align mode action
            action = theResources.getActionForKey('align_mode', event.keyval, mask)
            if self._align_mode_actions.has_key(action):
                self._align_mode_actions[action]()
                retval = True
        self.closeUndoBlock()
        return retval

    # check changes to files on disk when receiving keyboard focus
    def focus_in_cb(self, widget, event):
        for f, pane in enumerate(self.panes):
            if pane.last_stat is not None:
                spec = pane.spec
                try:
                    new_stat = os.stat(spec.name)
                    if pane.last_stat[stat.ST_MTIME] < new_stat[stat.ST_MTIME]:
                        # update our notion of the most recent modification
                        pane.last_stat = new_stat
                        msg = _('The file "%s" changed on disk.  Do you want to reload the file?') % (spec.name, )
                        dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_QUESTION, msg)
                        ok = (dialog.run() == gtk.RESPONSE_OK)
                        dialog.destroy()
                        if ok:
                            self.open_file(f, True)
                except IOError:
                    pass

    # load a new file into pane 'f'
    def open_file(self, f, reload=False):
        pane = self.panes[f]
        spec = pane.spec
        # warn users of any unsaved changes they might lose
        if pane.hasEdits() and not confirmDiscardEdits(self.get_toplevel()):
            return
        if not reload:
            # we need to ask for a file name if we are not reloading the
            # existing file
            dialog = FileChooserDialog(_('Open File'), self.get_toplevel(), self.prefs, gtk.FILE_CHOOSER_ACTION_OPEN, gtk.STOCK_OPEN)
            if spec.name is not None:
                dialog.set_filename(os.path.realpath(spec.name))
            dialog.set_encoding(spec.encoding)
            dialog.set_default_response(gtk.RESPONSE_OK)
            end = (dialog.run() != gtk.RESPONSE_OK)
            name = dialog.get_filename()
            rev = None
            vcs = None
            revision = dialog.get_revision().strip()
            if revision != '':
                rev = revision
                vcs = theVCSs.findByFilename(name)
            spec = FileSpec(name, rev, vcs, dialog.get_encoding())
            dialog.destroy()
            if end:
                return
        self.setLineMode()
        self.openUndoBlock()
        self.recordEditMode()
        self.load(f, spec)
        self.setCurrentLine(f, min(self.current_line, len(self.panes[f].lines)))
        self.recordEditMode()
        self.closeUndoBlock()

    # callback for reload file menu item
    def reload_file_cb(self, widget, data):
        self.open_file(self.current_pane, True)

    # callback for reload file button
    def reload_file_button_cb(self, widget, data):
        self.open_file(data, True)

    # callback for open file menu item
    def open_file_cb(self, widget, data):
        self.open_file(self.current_pane)

    # callback for open file button
    def open_file_button_cb(self, widget, data):
        self.open_file(data)

    # save contents of pane 'f' to file
    def save_file(self, f, save_as=False, name=None):
        pane = self.panes[f]
        spec = pane.spec
        if spec.name is None or spec.revision is not None:
            # we need to prompt for a file name the current contents were not
            # loaded from a regular file
            save_as = True
        spec = FileSpec(spec.name, None, None, spec.encoding)
        if name is not None:
            spec.name = name
        if save_as:
            # prompt for a file name
            dialog = FileChooserDialog(_('Save File'), self.get_toplevel(), self.prefs, gtk.FILE_CHOOSER_ACTION_SAVE, gtk.STOCK_SAVE)
            if spec.name is not None:
                dialog.set_filename(os.path.abspath(spec.name))
            dialog.set_encoding(spec.encoding)
            spec.name = None
            dialog.set_default_response(gtk.RESPONSE_OK)
            if dialog.run() == gtk.RESPONSE_OK:
                spec.name = dialog.get_filename()
                spec.encoding = dialog.get_encoding()
            dialog.destroy()
        if spec.name is None:
            return
        try:
            msg = None
            # warn if we are about to overwrite an existing file
            if save_as:
                if os.path.exists(spec.name):
                    msg = _('A file named "%s" already exists.  Do you want to overwrite it?') % (spec.name, )
            # warn if we are about to overwrite a file that has changed since
            # we last read it
            elif pane.stat is not None:
                if pane.stat[stat.ST_MTIME] < os.stat(spec.name)[stat.ST_MTIME]:
                    msg = _('The file "%s" has been modified by another process since reading it.  If you save, all the external changes could be lost.  Save anyways?') % (spec.name, )
            if msg is not None:
                dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_QUESTION, msg)
                end = (dialog.run() != gtk.RESPONSE_OK)
                dialog.destroy()
                if end:
                    return
        except IOError:
            pass
        try:
            # convert the text to the output encoding
            # refresh the lines to contain new objects with updated line
            # numbers and no local edits
            ss = [ self.getLineText(f, i) for i in range(len(pane.lines)) ]
            encoded = []
            lines = []
            i = 0
            for s in ss:
                if s is None:
                    lines.append(None)
                else:
                    i += 1
                    lines.append(FileDiffViewer.Line(i, s))
                    encoded.append(codecs.encode(s, spec.encoding))

            # write file
            fd = open(spec.name, 'wb')
            for s in encoded:
                fd.write(s)
            fd.close()
            pane.last_stat = pane.stat = os.stat(spec.name)

            # update loaded pane
            self.openUndoBlock()
            self.replaceLines(f, pane.lines, lines, pane.max_line_number, len(encoded))
            self.closeUndoBlock()
            pane.spec = spec
            # update the pane's label
            self.setLabel(f, spec.name)
            # update the syntax highlighting incase we changed the file
            # extension
            syntax = theResources.getSyntaxByFilename(spec.name)
            if syntax is not None:
                self.setSyntax(syntax)
        except UnicodeEncodeError:
            dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_ERROR, _('Error ecoding to %s.') % (repr(spec.encoding), ))
            dialog.run()
            dialog.destroy()
        except IOError:
            dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_ERROR, _('Error writing %s.') % (repr(spec.name), ))
            dialog.run()
            dialog.destroy()

    # callback for save file menu item
    def save_file_cb(self, widget, data):
        self.save_file(self.current_pane)

    # callback for save file button
    def save_file_button_cb(self, widget, data):
        self.save_file(data)

    # callback for save file as menu item
    def save_file_as_cb(self, widget, data):
        self.save_file(self.current_pane, True)

    # callback for save file as button
    def save_file_as_button_cb(self, widget, data):
        self.save_file(data, True)

    # callback for copy menu item
    def copy_cb(self, widget, data):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            gtk.clipboard_get(gtk.gdk.SELECTION_CLIPBOARD).set_text(self.getSelectedText())

    # callback for cut menu item
    def cut_cb(self, widget, data):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            self.copy_cb(widget, data)
            self.openUndoBlock()
            self.replaceText('')
            self.closeUndoBlock()

    # callback used when receiving clipboard text
    def receive_clipboard_text_cb(self, clipboard, text, data):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            self.openUndoBlock()
            self.replaceText(self.prefs.convertToUnicode([ text ])[0][0])
            self.closeUndoBlock()

    # callback for paste menu item
    def paste_cb(self, widget, data):
         gtk.clipboard_get(gtk.gdk.SELECTION_CLIPBOARD).request_text(self.receive_clipboard_text_cb)

    # callback for select all menu item
    def select_all_cb(self, widget, data):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            self.selectAll()

    # callback for find menu item
    def find_cb(self, pattern, match_case, backwards):
        self.setCharMode()
        # determine where to start searching from
        f = self.current_pane
        nlines = len(self.panes[f].lines)
        i, j = self.current_line, self.current_char
        si, sj = self.selection_line, self.selection_char
        if backwards:
            if si < i or (i == si and sj < j):
                i, j = si, sj
        elif i < si or (i == si and j < sj):
            i, j = si, sj

        if not match_case:
            pattern = pattern.upper()
        more = True
        while more:
            # iterate over all valid lines
            while i < nlines + 1:
                text = self.getLineText(f, i)
                if text is not None:
                    if not match_case:
                        text = text.upper()
                    # search for pattern
                    if backwards:
                        idx = text.rfind(pattern, 0, j)
                    else:
                        idx = text.find(pattern, j)
                    if idx >= 0:
                        # we found a match
                        end = idx + len(pattern)
                        if backwards:
                            idx, end = end, idx
                        self.setCurrentChar(i, idx)
                        self.setCurrentChar(i, end, True)
                        return
                # advance
                if backwards:
                    if i == 0:
                        break
                    i -= 1
                    text = self.getLineText(f, i)
                    if text is None:
                        j = 0
                    else:
                        j = len(text)
                else:
                    i += 1
                    j = 0

            # we have reached the end, ask if we should continue
            j = 0
            if backwards:
                msg = _('Phrase not found.  Continue from the end of the file?')
                i = nlines
            else:
                msg = _('Phrase not found.  Continue from the start of the file?')
                i = 0
            dialog = MessageDialog(self.get_toplevel(), gtk.MESSAGE_QUESTION, msg)
            dialog.set_default_response(gtk.RESPONSE_OK)
            more = (dialog.run() == gtk.RESPONSE_OK)
            dialog.destroy()

    # callback for go to line menu item
    def go_to_line_cb(self, widget, data):
        parent = self.get_toplevel()
        dialog = GoToLineDialog(parent)
        okay = (dialog.run() == gtk.RESPONSE_ACCEPT)
        i = dialog.entry.get_text().strip()
        dialog.destroy()
        if okay:
            try:
                # parse the line number
                f, idx, i = self.current_pane, 0, int(i)
                if i > 0:
                    # search for a line matching that number
                    # we want to leave the cursor at the end of the file
                    # if 'i' is greater than the last numbered line
                    lines = self.panes[f].lines
                    while idx < len(lines):
                        line = lines[idx]
                        if line is not None and line.line_number == i:
                            break
                        idx += 1
                # select the line and make sure it is visible
                self.setLineMode()
                self.centre_view_about_y((idx + 0.5) * self.font_height)
                self.setCurrentLine(f, idx)
            except ValueError:
                m = MessageDialog(parent, gtk.MESSAGE_ERROR, _('Error parsing line number.'))
                m.run()
                m.destroy()

    # adjust indenting of the selected lines by 'offset' soft tabs
    def _adjust_indenting(self, offset):
        if self.mode == LINE_MODE or self.mode == CHAR_MODE:
            # find range of lines to operate upon
            f = self.current_pane
            start, end = self.selection_line, self.current_line
            if end < start:
                start, end = end, start
            end += 1

            self.openUndoBlock()
            self.recordEditMode()
            for i in range(start, end):
                text = self.getLineText(f, i)
                if text is not None and len(strip_line_ending(self.panes[f].format, text)) > 0:
                    # count spacing before the first non-whitespace character
                    j, w = 0, 0
                    while j < len(text) and text[j] in ' \t':
                        w += self.characterWidth(w, text[j])
                        j += 1
                    # adjust by a multiple of the soft tab width
                    ws = max(0, w + offset * self.prefs.getInt('editor_soft_tab_width'))
                    if ws != w:
                        if self.prefs.getBool('editor_expand_tabs'):
                            s = u' ' * ws
                        else:
                            tab_width = self.prefs.getInt('display_tab_width')
                            s = u'\t' * (ws // tab_width)
                            s += u' ' * (ws % tab_width)
                        self.updateText(f, i, s + text[j:])
            if self.mode == CHAR_MODE:
                # ensure the cursor position is valid
                i = self.current_line
                self.setCurrentChar(self.selection_line, 0)
                self.setCurrentChar(i, 0, True)
            self.recordEditMode()
            self.closeUndoBlock()

    # callback for decrease indenting menu item
    def decrease_indenting_cb(self, widget, data):
        self._adjust_indenting(-1)

    # callback for increase indenting menu item
    def increase_indenting_cb(self, widget, data):
        self._adjust_indenting(1)

    # callback for convert to DOS format menu item
    def convert_to_dos_cb(self, widget, data):
        self.setLineMode()
        self.openUndoBlock()
        self.recordEditMode()
        f = self.current_pane
        for i in range(len(self.panes[f].lines)):
            text = self.getLineText(f, i)
            s = convert_to_dos(text)
            # only modify lines that actually change
            if s != text:
                self.updateLineText(f, i, True, s)
        self.setFormat(f, DOS_FORMAT)
        self.recordEditMode()
        self.closeUndoBlock()

    # callback for convert to Unix format menu item
    def convert_to_unix_cb(self, widget, data):
        self.setLineMode()
        self.openUndoBlock()
        self.recordEditMode()
        f = self.current_pane
        for i in range(len(self.panes[f].lines)):
            text = self.getLineText(f, i)
            s = convert_to_unix(text)
            # only modify lines that actually change
            if s != text:
                self.updateLineText(f, i, True, s)
        self.setFormat(f, UNIX_FORMAT)
        self.recordEditMode()
        self.closeUndoBlock()

    # Undo for changes to the pane ordering
    class SwapPanesUndo:
        def __init__(self, f_dst, f_src):
            self.data = (f_dst, f_src)

        def undo(self, viewer):
            f_dst, f_src = self.data
            viewer.swapPanes(f_src, f_dst)

        def redo(self, viewer):
            f_dst, f_src = self.data
            viewer.swapPanes(f_dst, f_src)

    # swap the contents of two panes
    def swapPanes(self, f_dst, f_src):
        if self.undoblock is not None:
            self.addUndo(FileDiffViewer.SwapPanesUndo(f_dst, f_src))
        self.current_pane = f_dst
        f0 = self.panes[f_dst]
        f1 = self.panes[f_src]
        self.panes[f_dst], self.panes[f_src] = f1, f0
        self.setLabel(f_dst, f1.label)
        self.setLabel(f_src, f0.label)
        npanes = len(self.panes)
        for f_idx in f_dst, f_src:
            for f in range(f_idx - 1, f_idx + 2):
                if f >= 0 and f < npanes:
                    # clear the diff cache and redraw as the pane has a new
                    # neighour
                    del self.panes[f].diff_cache[:]
                    self.dareas[f].queue_draw()
        # queue redraw
        self.map_cache = None
        self.map.queue_draw()

    # swap the contents of two panes
    def swap_panes(self, f_dst, f_src):
        if f_dst >= 0 and f_dst < len(self.panes):
            if self.mode == ALIGN_MODE:
                self.setLineMode()
            self.openUndoBlock()
            self.recordEditMode()
            self.swapPanes(f_dst, f_src)
            self.recordEditMode()
            self.closeUndoBlock()

    # callback for swap panes menu item
    def swap_panes_cb(self, widget, data):
        self.swap_panes(data, self.current_pane)

    # callback for shift pane left menu item
    def shift_pane_left_cb(self, widget, data):
        f = self.current_pane
        self.swap_panes(f - 1, f)

    # callback for shift pane right menu item
    def shift_pane_right_cb(self, widget, data):
        f = self.current_pane
        self.swap_panes(f + 1, f)

    # recompute viewport size and redraw as the display preferences may have
    # changed
    def prefsUpdated(self):
        self.setFont(pango.FontDescription(self.prefs.getString('display_font')))
        for pane in self.panes:
            del pane.diff_cache[:]
        for darea in self.dareas:
            darea.queue_draw()
        self.map_cache = None
        self.map.queue_draw()
        self.updatePrompt()

    # callback for clear edits menu item
    def clear_edits_cb(self, widget, data):
        self.setLineMode()
        self.openUndoBlock()
        self.recordEditMode()
        f = self.current_pane
        start, end = self.selection_line, self.current_line
        if end < start:
            start, end = end, start
        end = min(end + 1, len(self.panes[0].lines))
        for i in range(start, end):
            line = self.getLine(f, i)
            if line is not None and line.is_modified:
                # remove the edits to the line
                self.updateText(f, i, None, False)
                if line.text is None:
                    # remove the line so it doesn't persist as a spacer
                    self.instanceLine(f, i, True)
        self.recordEditMode()
        self.closeUndoBlock()

    # copies the selected range of lines from pane 'f_src' to 'f_dst'
    def merge_lines(self, f_dst, f_src):
        self.setLineMode()
        pane = self.panes[f_dst]
        start, end = self.selection_line, self.current_line
        if end < start:
            start, end = end, start
        end = min(end + 1, len(pane.lines))
        ss = [ self.getLineText(f_src, i) for i in range(start, end) ]
        if pane.format == 0:
            # copy the format of the source pane if the format for the
            # destination pane as not yet been determined
            self.setFormat(f_dst, getFormat(ss))
        for i, s in enumerate(ss):
            self.updateText(f_dst, start + i, convert_to_format(s, pane.format))

    # callback for merge lines menu item
    def merge_lines_cb(self, widget, data):
        self.openUndoBlock()
        self.recordEditMode()
        self.merge_lines(data, self.current_pane)
        self.recordEditMode()
        self.closeUndoBlock()

    # 'merge_from_left' keybinding action
    def _merge_from_left(self):
        f = self.current_pane
        if f > 0:
            self.merge_lines(f, f - 1)

    # callback for merge from left menu item
    def merge_from_left_cb(self, widget, data):
        self.openUndoBlock()
        self.recordEditMode()
        self._merge_from_left()
        self.recordEditMode()
        self.closeUndoBlock()

    # 'merge_from_right' keybinding action
    def _merge_from_right(self):
        f = self.current_pane
        if f + 1 < len(self.panes):
            self.merge_lines(f, f + 1)

    # callback for merge from right menu item
    def merge_from_right_cb(self, widget, data):
        self.openUndoBlock()
        self.recordEditMode()
        self._merge_from_right()
        self.recordEditMode()
        self.closeUndoBlock()

    # 'isolate' keybinding action
    def _isolate(self):
        self.setLineMode()
        f = self.current_pane
        start, end = self.selection_line, self.current_line
        if end < start:
            start, end = end, start
        end += 1
        nlines = len(self.panes[f].lines)
        end = min(end, nlines)
        n = end - start
        if n > 0:
            lines = [ pane.lines[start:end] for pane in self.panes ]
            space = [ n * [ None ] for pane in self.panes ]
            lines[f], space[f] = space[f], lines[f]

            pre, post = cutBlocks(end, self.blocks)
            pre, middle = cutBlocks(start, pre)

            # remove nulls
            b = createBlock(n)
            removeNullLines(b, space)
            end = start + sum(b)
            if end > start:
                end -= 1
            self.selection_line = start
            self.setCurrentLine(f, end, True)
            removeNullLines(middle, lines)

            for s, line in zip(space, lines):
                s.extend(line)

            # update lines and blocks
            self.updateAlignment(start, n, space)
            pre.extend(b)
            pre.extend(middle)
            pre.extend(post)
            self.updateBlocks(pre)

    # callback for the isolate menu item
    def isolate_cb(self, widget, data):
        self.openUndoBlock()
        self.recordEditMode()
        self._isolate()
        self.recordEditMode()
        self.closeUndoBlock()

    # callback for the align to selection menu item
    def align_to_selection_cb(self, widget, data):
        self.setLineMode()
        self.openUndoBlock()
        self.recordEditMode()
        # get the line and pane where the user right clicked
        f, line1 = data
        f2 = self.current_pane
        line2 = self.current_line
        if f2 < f:
            f = f2
            line1, line2 = line2, line1
        self.align(f, line1, line2)
        self.recordEditMode()
        self.closeUndoBlock()

    # returns True if line 'i' in pane 'f' has an edit or is different from its
    # neighbour
    def hasEditsOrDifference(self, f, i):
        line = self.getLine(f, i)
        if line is not None and line.is_modified:
            return True
        text = self.getCompareString(f, i)
        return (f > 0 and self.getCompareString(f - 1, i) != text) or (f + 1 < len(self.panes) and text != self.getCompareString(f + 1, i))

    # returns True if there are any differences
    def hasDifferences(self):
        n = len(self.panes)
        nlines = len(self.panes[0].lines)
        for i in range(nlines):
            text = self.getCompareString(0, i)
            for f in range(1, n):
                if self.getCompareString(f, i) != text:
                    return True
        return False

    # scroll the viewport so pixels at position 'y' are centred
    def centre_view_about_y(self, y):
        vadj = self.vadj
        y = min(max(0, y - vadj.page_size / 2), vadj.upper - vadj.page_size)
        vadj.set_value(y)

    # move the cursor from line 'i' to the next difference in direction 'delta'
    def goto_difference(self, i, delta):
        f = self.current_pane
        nlines = len(self.panes[f].lines)
        # back up to beginning of difference
        while self.hasEditsOrDifference(f, i):
            i2 = i - delta
            if i2 < 0 or i2 > nlines:
                break
            i = i2
        # step over non-difference
        while i >= 0 and i <= nlines and not self.hasEditsOrDifference(f, i):
            i += delta
        # find extent of difference
        if i >= 0 and i <= nlines:
            start = i
            while i >= 0 and i <= nlines and self.hasEditsOrDifference(f, i):
                i += delta
            i -= delta
            if i < start:
                start, i = i, start
            # centre the view on the selection
            self.centre_view_about_y((start + i) * self.font_height / 2)
            self.selection_line = i
            self.setCurrentLine(f, start, True)

    # 'realign_all' keybinding action
    def realign_all_cb(self, widget, data):
        self.setLineMode()
        f = self.current_pane
        self.openUndoBlock()
        self.recordEditMode()
        lines = []
        blocks = []
        for pane in self.panes:
            # create a new list of lines with no spacers
            newlines = [ [ line for line in pane.lines if line is not None ] ]
            newblocks = createBlock(len(newlines[0]))
            if len(lines) > 0:
                # match with neighbour to the left
                self.alignBlocks(blocks, lines, newblocks, newlines)
                blocks = mergeBlocks(blocks, newblocks)
            else:
                blocks = newblocks
            lines.extend(newlines)
        self.updateAlignment(0, len(self.panes[f].lines), lines)
        self.updateBlocks(blocks)
        self.setCurrentLine(f, min(self.current_line, len(self.panes[f].lines)))
        self.recordEditMode()
        self.closeUndoBlock()

    # 'first_difference' keybinding action
    def _first_difference(self):
        self.setLineMode()
        self.goto_difference(0, 1)

    # callback for the first difference menu item
    def first_difference_cb(self, widget, data):
        self._first_difference()

    # 'previous_difference' keybinding action
    def _previous_difference(self):
        self.setLineMode()
        i = min(self.current_line, self.selection_line) - 1
        self.goto_difference(i, -1)

    # callback for the previous difference menu item
    def previous_difference_cb(self, widget, data):
        self._previous_difference()

    # 'next_difference' keybinding action
    def _next_difference(self):
        self.setLineMode()
        i = max(self.current_line, self.selection_line) + 1
        self.goto_difference(i, 1)

    # callback for the next difference menu item
    def next_difference_cb(self, widget, data):
        self._next_difference()

    # 'last_difference' keybinding action
    def _last_difference(self):
        self.setLineMode()
        f = self.current_pane
        i = len(self.panes[f].lines)
        self.goto_difference(i, -1)

    # callback for the last difference menu item
    def last_difference_cb(self, widget, data):
        self._last_difference()

    # callback used when the viewer is first displayed
    # this must be connected with 'connect_after()' so the final widget sizes
    # are known and the scroll bar can be moved to the first difference
    def _realise_cb(self, widget):
        self._first_difference()

# create 'title_changed' signal for FileDiffViewer
gobject.signal_new('title_changed', FileDiffViewer, gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (str, ))

# dialogue used to search for text
class SearchDialog(gtk.Dialog):
    def __init__(self, parent, pattern=None, history=None):
        gtk.Dialog.__init__(self, _('Find...'), parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, (gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))

        vbox = gtk.VBox()
        vbox.set_border_width(10)

        hbox = gtk.HBox()
        label = gtk.Label(_('Search For: '))
        hbox.pack_start(label, False, False, 0)
        label.show()
        combo = gtk.combo_box_entry_new_text()
        self.entry = combo.child
        self.entry.connect('activate', self.entry_cb)

        if pattern is not None:
            self.entry.set_text(pattern)

        if history is not None:
            completion = gtk.EntryCompletion()
            liststore = gtk.ListStore(gobject.TYPE_STRING)
            completion.set_model(liststore)
            completion.set_text_column(0)
            for h in history:
                liststore.append([h])
                combo.append_text(h)
            self.entry.set_completion(completion)

        hbox.pack_start(combo, True, True, 0)
        combo.show()
        vbox.pack_start(hbox, False, False, 0)
        hbox.show()

        button = gtk.CheckButton(_('Match Case'))
        self.match_case_button = button
        vbox.pack_start(button, False, False, 0)
        button.show()

        button = gtk.CheckButton(_('Search Backwards'))
        self.backwards_button = button
        vbox.pack_start(button, False, False, 0)
        button.show()

        self.vbox.pack_start(vbox, False, False, 0)
        vbox.show()

    # callback used when the Enter key is pressed
    def entry_cb(self, widget):
        self.response(gtk.RESPONSE_ACCEPT)

# widget classed to create notebook tabs with labels and a close button
# use notebooktab.button.connect() to be notified when the button is pressed
class NotebookTab(gtk.HBox):
    def __init__(self, name, stock):
        gtk.HBox.__init__(self)

        if stock is not None:
            image = gtk.Image()
            image.set_from_stock(stock, gtk.ICON_SIZE_MENU)
            self.pack_start(image, False, False, 5)
            image.show()
        self.label = label = gtk.Label(name)
        # left justify the widget
        label.set_alignment(0, 0.5)
        self.pack_start(label, True, True, 0)
        label.show()
        self.button = button = gtk.Button()
        button.set_relief(gtk.RELIEF_NONE)
        image = gtk.Image()
        image.set_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU)
        button.add(image)
        image.show()
        self.pack_start(button, False, False, 0)
        button.show()

    def get_text(self):
        return self.label.get_text()

    def set_text(self, s):
        self.label.set_text(s)

# the main application class containing a set of file viewers
# this class displays tab for switching between viewers and dispatches menu
# commands to the current viewer
class Diffuse(gtk.Window):
    def __init__(self):
        gtk.Window.__init__(self, gtk.WINDOW_TOPLEVEL)

        self.prefs = Preferences()
        # number of created viewers (used to label some tabs)
        self.viewer_count = 0

        # state information that should persist across sessions
        self.bool_state = { 'window_maximized': False, 'search_matchcase': False, 'search_backwards': False }
        self.int_state = { 'window_x': 0, 'window_y': 0, 'window_width': 1024, 'window_height': 768 }
        self.connect('configure_event', self.configure_cb)
        self.connect('window_state_event', self.window_state_cb)

        # search history is application wide
        self.search_pattern = None
        self.search_history = []

        self.connect('delete-event', self.delete_cb)
        accel_group = gtk.AccelGroup()

        # Create a VBox for our contents
        vbox = gtk.VBox()

        menuspecs = []
        menuspecs.append([ _('_File'), [
                     [_('_Open File...'), self.open_file_cb, None, gtk.STOCK_OPEN, 'open_file'],
                     [_('_Reload File'), self.reload_file_cb, None, gtk.STOCK_REFRESH, 'reload_file'],
                     [_('_Save File'), self.save_file_cb, None, gtk.STOCK_SAVE, 'save_file'],
                     [_('Save File _As...'), self.save_file_as_cb, None, gtk.STOCK_SAVE_AS, 'save_file_as'],
                     [],
                     [_('New _2-Way File Merge'), self.new_2way_file_merge_cb, None, None, 'new_2_way_file_merge'],
                     [_('New _3-Way File Merge'), self.new_3way_file_merge_cb, None, None, 'new_3_way_file_merge'],
                     [],
                     [_('_Quit'), self.quit_cb, None, gtk.STOCK_QUIT, 'quit'] ] ])

        menuspecs.append([ _('_Edit'), [
                     [_('_Undo'), self.undo_cb, None, gtk.STOCK_UNDO, 'undo'],
                     [_('_Redo'), self.redo_cb, None, gtk.STOCK_REDO, 'redo'],
                     [],
                     [_('Cu_t'), self.cut_cb, None, gtk.STOCK_CUT, 'cut'],
                     [_('_Copy'), self.copy_cb, None, gtk.STOCK_COPY, 'copy'],
                     [_('_Paste'), self.paste_cb, None, gtk.STOCK_PASTE, 'paste'],
                     [],
                     [_('Select _All'), self.select_all_cb, None, None, 'select_all'],
                     [],
                     [_('_Find...'), self.find_cb, None, gtk.STOCK_FIND, 'find'],
                     [_('Find _Next'), self.find_next_cb, None, None, 'find_next'],
                     [_('Find Pre_vious'), self.find_previous_cb, None, None, 'find_previous'],
                     [_('Go To _Line...'), self.go_to_line_cb, None, None, 'go_to_line'],
                     [],
                     [_('Decrea_se Indenting'), self.decrease_indenting_cb, None, gtk.STOCK_UNINDENT, 'decrease_indenting'],
                     [_('_Increase Indenting'), self.increase_indenting_cb, None, gtk.STOCK_INDENT, 'increase_indenting'],
                     [],
                     [_('Convert to _DOS Format'), self.convert_to_dos_cb, None, None, 'convert_to_dos'],
                     [_('Convert to Uni_x Format'), self.convert_to_unix_cb, None, None, 'convert_to_unix'] ] ])

        submenudef = [ [_('None'), self.syntax_cb, None, None, 'no_syntax_highlighting'] ]
        names = theResources.getSyntaxNames()
        if len(names) > 0:
            submenudef.append([])
            names.sort(key=str.lower)
            for name in names:
                submenudef.append([name, self.syntax_cb, name, None, 'syntax_highlighting_' + name])

        menuspecs.append([ _('_View'), [
                     [_('_Syntax Highlighting'), None, None, None, None, True, submenudef],
                     [],
                     [_('Pre_vious Tab'), self.previous_tab_cb, None, None, 'previous_tab'],
                     [_('_Next Tab'), self.next_tab_cb, None, None, 'next_tab'],
                     [_('_Close Tab'), self.close_tab_cb, None, None, 'close_tab'],
                     [],
                     [_('Shift Pane _Left'), self.shift_pane_left_cb, None, None, 'shift_pane_left'],
                     [_('Shift Pane _Right'), self.shift_pane_right_cb, None, None, 'shift_pane_right'],
                     [],
                     [_('_Preferences...'), self.preferences_cb, None, gtk.STOCK_PREFERENCES, 'preferences'] ] ])

        menuspecs.append([ _('_Merge'), [
                     [_('Re_align All'), self.realign_all_cb, None, gtk.STOCK_EXECUTE, 'realign_all'],
                     [],
                     [_('_First Difference'), self.first_difference_cb, None, gtk.STOCK_GOTO_TOP, 'first_difference'],
                     [_('_Previous Difference'), self.previous_difference_cb, None, gtk.STOCK_GO_UP, 'previous_difference'],
                     [_('_Next Difference'), self.next_difference_cb, None, gtk.STOCK_GO_DOWN, 'next_difference'],
                     [_('_Last Difference'), self.last_difference_cb, None, gtk.STOCK_GOTO_BOTTOM, 'last_difference'],
                     [],
                     [_('_Clear Edits'), self.clear_edits_cb, None, gtk.STOCK_CLEAR, 'clear_edits'],
                     [_('Merge From Le_ft'), self.merge_from_left_cb, None, gtk.STOCK_GO_FORWARD, 'merge_from_left'],
                     [_('Merge From Ri_ght'), self.merge_from_right_cb, None, gtk.STOCK_GO_BACK, 'merge_from_right'],
                     [],
                     [_('_Isolate'), self.isolate_cb, None, None, 'isolate'] ] ])

        menuspecs.append([ _('_Help'), [
                     [_('_Help Contents...'), self.help_contents_cb, None, gtk.STOCK_HELP, 'help_contents'],
                     [],
                     [_('_About %s...') % (APP_NAME, ), self.about_cb, None, gtk.STOCK_ABOUT, 'about'] ] ])

        menu_bar = createMenuBar(menuspecs, accel_group)
        vbox.pack_start(menu_bar, False, False, 0)
        menu_bar.show()

        # create button bar
        hbox = gtk.HBox()
        appendButtons(hbox, gtk.ICON_SIZE_LARGE_TOOLBAR, [
           [ gtk.STOCK_EXECUTE, self.realign_all_cb, None, _('Realign All') ],
           [],
           [ gtk.STOCK_GOTO_TOP, self.first_difference_cb, None, _('First Difference') ],
           [ gtk.STOCK_GO_UP, self.previous_difference_cb, None, _('Previous Difference') ],
           [ gtk.STOCK_GO_DOWN, self.next_difference_cb, None, _('Next Difference') ],
           [ gtk.STOCK_GOTO_BOTTOM, self.last_difference_cb, None, _('Last Difference') ],
           [],
           [ gtk.STOCK_CLEAR, self.clear_edits_cb, None, _('Clear Edits') ],
           [ gtk.STOCK_GO_FORWARD, self.merge_from_left_cb, None, _('Merge From Left') ],
           [ gtk.STOCK_GO_BACK, self.merge_from_right_cb, None, _('Merge From Right') ],
           [],
           [ gtk.STOCK_CUT, self.cut_cb, None, _('Cut') ],
           [ gtk.STOCK_COPY, self.copy_cb, None, _('Copy') ],
           [ gtk.STOCK_PASTE, self.paste_cb, None, _('Paste') ] ])
        vbox.pack_start(hbox, False, False, 0)
        hbox.show()

        self.notebook = notebook = gtk.Notebook()
        notebook.set_scrollable(True)
        notebook.connect('switch-page', self.switch_page_cb)
        vbox.pack_start(notebook, True, True, 0)
        notebook.show()

        self.add_accel_group(accel_group)
        self.add(vbox)
        vbox.show()

    # record the window's position and size
    def configure_cb(self, widget, event):
        # read the state directly instead of using window_maximized as the order
        # of configure/window_state events is undefined
        if (widget.window.get_state() & gtk.gdk.WINDOW_STATE_MAXIMIZED) == 0:
            self.int_state['window_x'], self.int_state['window_y'] = widget.window.get_root_origin()
            self.int_state['window_width'] = event.width
            self.int_state['window_height'] = event.height

    # record the window's maximised state
    def window_state_cb(self, window, event):
        self.bool_state['window_maximized'] = ((event.new_window_state & gtk.gdk.WINDOW_STATE_MAXIMIZED) != 0)

    # load state information that should persist across sessions
    def loadState(self, configpath):
        if os.path.isfile(configpath):
            try:
                f = open(configpath, 'r')
                ss = f.readlines()
                f.close()
                for j, s in enumerate(ss):
                    try:
                        a = shlex.split(s, True)
                        if len(a) > 0:
                            if len(a) == 2 and self.bool_state.has_key(a[0]):
                                self.bool_state[a[0]] = (a[1] == 'True')
                            elif len(a) == 2 and self.int_state.has_key(a[0]):
                                self.int_state[a[0]] = int(a[1])
                            else:
                                raise ValueError()
                    except ValueError:
                        print _('Error parsing line %(line)d of "%(file)s".') % { 'line': j + 1, 'file': configpath }
            except IOError:
                print _('Error reading %s.') % (repr(configpath), )

        self.move(self.int_state['window_x'], self.int_state['window_y'])
        self.resize(self.int_state['window_width'], self.int_state['window_height'])
        if self.bool_state['window_maximized']:
            self.maximize()

    # save state information that should persist across sessions
    def saveState(self, configpath):
        try:
            ss = []
            for k, v in self.bool_state.items():
                ss.append('%s %s\n' % (k, v))
            for k, v in self.int_state.items():
                ss.append('%s %s\n' % (k, v))
            ss.sort()
            f = open(configpath, 'w')
            f.write("# This config file was generated by %s %s.\n\n" % (APP_NAME, VERSION))
            for s in ss:
                f.write(s)
            f.close()
        except IOError:
            print _('Error writing %s.') % (repr(configpath), )

    # callback for the close button on each tab
    def remove_tab_cb(self, widget, data):
        if self.notebook.get_n_pages() > 1:
            # warn about losing unsaved changes before removing a tab
            if not data.hasEdits() or confirmDiscardEdits(self.get_toplevel()):
                self.notebook.remove(data)
                self.notebook.set_show_tabs(self.notebook.get_n_pages() > 1)

    # update window's title
    def refreshTitle(self, page):
        title = self.notebook.get_tab_label(page).get_text()
        self.set_title('%s - %s' % (title, APP_NAME))

    # callback used when switching notebook pages
    def switch_page_cb(self, widget, ptr, page_num):
        self.refreshTitle(widget.get_nth_page(page_num))

    # callback used when a viewer's title changes
    def title_changed_cb(self, widget, title):
        # update the label in the notebook's tab
        self.notebook.get_tab_label(widget).set_text(title)
        if widget is self.getCurrentViewer():
            self.refreshTitle(widget)

    # create an empty viewer with 'n' panes
    def newFileDiffViewer(self, n):
        self.viewer_count += 1
        tabname = _('File Merge %d') % (self.viewer_count, )
        viewer = FileDiffViewer(n, self.prefs, tabname)
        tab = NotebookTab(tabname, gtk.STOCK_FILE)
        tab.button.connect('clicked', self.remove_tab_cb, viewer)
        self.notebook.append_page(viewer, tab)
        if hasattr(self.notebook, 'set_tab_reorderable'):
            # some PyGTK packages incorrectly omit this method
            self.notebook.set_tab_reorderable(viewer, True)
        tab.show()
        viewer.show()
        self.notebook.set_show_tabs(self.notebook.get_n_pages() > 1)
        viewer.connect('title_changed', self.title_changed_cb)
        return viewer

    # create a new viewer to display 'specs'
    def newLoadedFileDiffViewer(self, specs):
        if len(specs) == 1:
            # one file specified
            # determine which other files to compare it with
            name = specs[0].name
            vcs = theVCSs.findByFilename(name)
            if vcs is None:
                # shift the existing file so it will be in the second pane
                specs.insert(0, FileSpec())
            else:
                rev = specs[0].revision
                encoding = specs[0].encoding
                specs = []
                if rev is None:
                    # no revision specified assume defaults
                    for name, rev in vcs.getSingleFileSpecs(self.prefs, name):
                        specs.append(FileSpec(name, rev, vcs, encoding))
                else:
                    # single revision specified
                    specs.append(FileSpec(name, rev, vcs, encoding))
                    specs.append(FileSpec(name, None, None, encoding))
        else:
            # multiple files specified, use one pane for each file
            for spec in specs:
                if spec.revision is not None:
                    spec.vcs = theVCSs.findByFilename(spec.name)

        # open a new viewer
        viewer = self.newFileDiffViewer(len(specs))

        # load the files
        for i, spec in enumerate(specs):
            viewer.load(i, spec)

    # returns True if the application can safely quit
    def confirmQuit(self):
        for i in range(self.notebook.get_n_pages()):
            viewer = self.notebook.get_nth_page(i)
            if viewer.hasEdits():
                return confirmDiscardEdits(self)
        return True

    # respond to close window request from the window manager
    def delete_cb(self, widget, event):
        if self.confirmQuit():
            gtk.main_quit()
            return False
        return True

    # returns the currently focused viewer
    def getCurrentViewer(self):
        return self.notebook.get_nth_page(self.notebook.get_current_page())

    # callback for the open file menu item
    def open_file_cb(self, widget, data):
        self.getCurrentViewer().open_file_cb(widget, data)

    # callback for the reload file menu item
    def reload_file_cb(self, widget, data):
        self.getCurrentViewer().reload_file_cb(widget, data)

    # callback for the save file menu item
    def save_file_cb(self, widget, data):
        self.getCurrentViewer().save_file_cb(widget, data)

    # callback for the save file as menu item
    def save_file_as_cb(self, widget, data):
        self.getCurrentViewer().save_file_as_cb(widget, data)

    # callback for the new 2-way file merge menu item
    def new_2way_file_merge_cb(self, widget, data):
        viewer = self.newFileDiffViewer(2)
        self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
        viewer.grab_focus()

    # callback for the new 3-way file merge menu item
    def new_3way_file_merge_cb(self, widget, data):
        viewer = self.newFileDiffViewer(3)
        self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
        viewer.grab_focus()

    # callback for the quit menu item
    def quit_cb(self, widget, data):
        if self.confirmQuit():
            gtk.main_quit()

    # callback for the undo menu item
    def undo_cb(self, widget, data):
        self.getCurrentViewer().undo()

    # callback for the redo menu item
    def redo_cb(self, widget, data):
        self.getCurrentViewer().redo()

    # callback for the cut menu item
    def cut_cb(self, widget, data):
        self.getCurrentViewer().cut_cb(widget, data)

    # callback for the copy menu item
    def copy_cb(self, widget, data):
        self.getCurrentViewer().copy_cb(widget, data)

    # callback for the paste menu item
    def paste_cb(self, widget, data):
        self.getCurrentViewer().paste_cb(widget, data)

    # callback for the select all menu item
    def select_all_cb(self, widget, data):
        self.getCurrentViewer().select_all_cb(widget, data)

    # request search parameters if force=True and then perform a search in the
    # current viewer pane
    def find(self, force, reverse):
        if force or self.search_pattern is None:
            # construct search dialog
            history = self.search_history
            dialog = SearchDialog(self.get_toplevel(), self.search_pattern, history)
            dialog.match_case_button.set_active(self.bool_state['search_matchcase'])
            dialog.backwards_button.set_active(self.bool_state['search_backwards'])
            keep = (dialog.run() == gtk.RESPONSE_ACCEPT)
            # persist the search options
            pattern = dialog.entry.get_text()
            match_case = dialog.match_case_button.get_active()
            backwards = dialog.backwards_button.get_active()
            dialog.destroy()
            if not keep or pattern == '':
                return
            # perform the search
            self.search_pattern = pattern
            if pattern in history:
                del history[history.index(pattern)]
            history.insert(0, pattern)
            self.bool_state['search_matchcase'] = match_case
            self.bool_state['search_backwards'] = backwards
        self.getCurrentViewer().find_cb(self.search_pattern, self.bool_state['search_matchcase'], reverse ^ self.bool_state['search_backwards'])

    # callback for the find menu item
    def find_cb(self, widget, data):
        self.find(True, False)

    # callback for the find next menu item
    def find_next_cb(self, widget, data):
        self.find(False, False)

    # callback for the find previous menu item
    def find_previous_cb(self, widget, data):
        self.find(False, True)

    # callback for the go to line menu item
    def go_to_line_cb(self, widget, data):
        self.getCurrentViewer().go_to_line_cb(widget, data)

    # callback for the decrease indenting menu item
    def decrease_indenting_cb(self, widget, data):
        self.getCurrentViewer().decrease_indenting_cb(widget, data)

    # callback for the increase indenting menu item
    def increase_indenting_cb(self, widget, data):
        self.getCurrentViewer().increase_indenting_cb(widget, data)

    # callback for the convert to DOS menu item
    def convert_to_dos_cb(self, widget, data):
        self.getCurrentViewer().convert_to_dos_cb(widget, data)

    # callback for the convert to Unix menu item
    def convert_to_unix_cb(self, widget, data):
        self.getCurrentViewer().convert_to_unix_cb(widget, data)

    # callback for all of the syntax highlighting menu items
    def syntax_cb(self, widget, data):
        self.getCurrentViewer().setSyntax(theResources.getSyntax(data))

    # callback for the previous tab menu item
    def previous_tab_cb(self, widget, data):
        i = self.notebook.get_current_page() - 1
        if i >= 0:
            self.notebook.set_current_page(i)

    # callback for the next tab menu item
    def next_tab_cb(self, widget, data):
        i = self.notebook.get_current_page() + 1
        n = self.notebook.get_n_pages()
        if i < n:
            self.notebook.set_current_page(i)

    # callback for the close tab menu item
    def close_tab_cb(self, widget, data):
        self.remove_tab_cb(widget, self.notebook.get_nth_page(self.notebook.get_current_page()))

    # callback for the shift pane left menu item
    def shift_pane_left_cb(self, widget, data):
        self.getCurrentViewer().shift_pane_left_cb(widget, data)

    # callback for the shift pane right menu item
    def shift_pane_right_cb(self, widget, data):
        self.getCurrentViewer().shift_pane_right_cb(widget, data)

    # callback for the preferences menu item
    def preferences_cb(self, widget, data):
        if self.prefs.runDialog(self.get_toplevel()):
            self.preferences_updated()

    # notify all viewers of changes to the preferences
    def preferences_updated(self):
        for i in range(self.notebook.get_n_pages()):
            self.notebook.get_nth_page(i).prefsUpdated()

    # callback for the realign all menu item
    def realign_all_cb(self, widget, data):
        self.getCurrentViewer().realign_all_cb(widget, data)

    # callback for the first difference menu item
    def first_difference_cb(self, widget, data):
        self.getCurrentViewer().first_difference_cb(widget, data)

    # callback for the previous difference menu item
    def previous_difference_cb(self, widget, data):
        self.getCurrentViewer().previous_difference_cb(widget, data)

    # callback for the next difference menu item
    def next_difference_cb(self, widget, data):
        self.getCurrentViewer().next_difference_cb(widget, data)

    # callback for the last difference menu item
    def last_difference_cb(self, widget, data):
        self.getCurrentViewer().last_difference_cb(widget, data)

    # callback for the clear edits menu item
    def clear_edits_cb(self, widget, data):
        self.getCurrentViewer().clear_edits_cb(widget, data)

    # callback for the merge from left menu item
    def merge_from_left_cb(self, widget, data):
        self.getCurrentViewer().merge_from_left_cb(widget, data)

    # callback for the merge from right menu item
    def merge_from_right_cb(self, widget, data):
        self.getCurrentViewer().merge_from_right_cb(widget, data)

    # callback for the isolate menu item
    def isolate_cb(self, widget, data):
        self.getCurrentViewer().isolate_cb(widget, data)

    # display help documenation
    def help_contents_cb(self, widget, data):
        browser = theResources.getString('help_browser')
        help_file = theResources.getString('help_file')
        help_url = theResources.getString('help_url')
        if help_file == '':
            # use the local help documentation and browser if available,
            # otherwise fall back on the on-line manual
            browser = ''
            if isWindows():
                # help documentation is distributed as local HTML files
                help_file = os.path.join(bin_dir, 'manual.html')
                if os.path.isfile(help_file):
                    help_url = path2url(help_file)
            else:
                # help documentation is distributed as in DocBook format
                # find the help directory for the current locale and display
                # using the Gnome help browser
                s = os.path.join(bin_dir, '../share/gnome/help/diffuse')
                d = os.path.join(s, 'C')
                lang = locale.getdefaultlocale()[0]
                if lang is not None:
                    path = os.path.join(s, lang)
                    if os.path.exists(path):
                        d = path
                    else:
                        postfix = lang.index('_')
                        if postfix > 0:
                            path = os.path.join(s, lang[:postfix])
                            if os.path.exists(path):
                                d = path
                help_file = os.path.join(d, 'diffuse.xml')
                if os.path.isfile(help_file):
                    help_file = path2url(help_file, 'ghelp')
                    if os.environ.has_key('PATH'):
                        s = 'gnome-help'
                        for p in os.environ['PATH'].split(os.pathsep):
                            fp = os.path.join(p, s)
                            if os.path.isfile(fp):
                                browser = fp
                                break
        elif browser == '':
            help_url = path2url(help_file)
        if browser == '':
            # use a web browser to display the help documentation
            webbrowser.open(help_url)
        else:
            args = [ browser, help_file ]
            # spawnvp is not available on some systems, use spawnv instead
            os.spawnv(os.P_NOWAIT, args[0], args)

    # callback for the about menu item
    def about_cb(self, widget, data):
        dialog = AboutDialog()
        dialog.run()
        dialog.destroy()

# process the command line arguments
if __name__ == '__main__':
    path = os.path.expanduser('~/.diffuse')
    if not os.path.exists(path):
        try:
            os.mkdir(path)
        except IOError:
            pass

    # load resource files
    i = 1
    if argc == 2 and args[1] == '--no-rcfile':
        i += 1
    elif argc == 3 and args[1] == '--rcfile':
        i += 1
        theResources.parse(args[i])
        i += 1
    else:
        # parse system wide initialisation file
        if isWindows():
            rc_file = os.path.join(bin_dir, 'diffuserc')
        else:
            rc_file = os.path.join(bin_dir, '../../etc/diffuserc')
        if os.path.isfile(rc_file):
            theResources.parse(rc_file)
        # parse personal initialisation file
        rc_file = os.path.expanduser('~/.diffuse/diffuserc')
        if os.path.isfile(rc_file):
            theResources.parse(rc_file)

    # associate our icon with all of our windows
    icon = theResources.getString('icon')
    if os.path.isfile(icon):
        gtk.window_set_default_icon(gtk.gdk.pixbuf_new_from_file(icon))

    diff = Diffuse()
    # load state
    configpath = os.path.join(path, 'config')
    diff.loadState(configpath)

    # process remaining command line arguments
    encoding = None
    specs = []
    revs = []
    isdirviewer = True
    separate = False
    close_on_same = False
    while i < argc:
        arg = args[i]
        if len(arg) > 0 and arg[0] == '-':
            if i + 1 < argc and arg in [ '-r', '--revision' ]:
                # specified revision
                i += 1
                revs.append(args[i])
            elif i + 1 < argc and arg in [ '-c', '--commit' ]:
                # specified revision
                i += 1
                rev = args[i]
                prev = ''
                try:
                    # assume CVS/Subversion style version numbers
                    r = rev.split('.')
                    r[-1] = str(int(r[-1]) - 1)
                    prev = '.'.join(r)
                except:
                    print _('Error parsing revision %s.') % (repr(rev), )
                revs.append(prev)
                revs.append(rev)
            elif arg in [ '-s', '--separate' ]:
                # open items in separate tabs
                n = len(specs)
                if n > 0:
                    diff.newLoadedFileDiffViewer(specs)
                    specs = []
                separate = True
            elif i + 1 < argc and arg in [ '-t', '--tab' ]:
                # start a new tab
                n = len(specs)
                if n > 0:
                    diff.newLoadedFileDiffViewer(specs)
                    specs = []
            elif i + 1 < argc and arg in [ '-e', '--encoding' ]:
                i += 1
                encoding = args[i]
                if encodings.aliases.aliases.has_key(encoding):
                    encoding = encodings.aliases.aliases[encoding]
            elif arg in [ '-b', '--ignore-space-change' ]:
                diff.prefs.setBool('display_ignore_whitespace_changes', True)
                diff.prefs.setBool('align_ignore_whitespace_changes', True)
                diff.preferences_updated()
            elif arg in [ '-B', '--ignore-blank-lines' ]:
                diff.prefs.setBool('display_ignore_blanklines', True)
                diff.prefs.setBool('align_ignore_blanklines', True)
                diff.preferences_updated()
            elif arg in [ '-i', '--ignore-case' ]:
                diff.prefs.setBool('display_ignore_case', True)
                diff.prefs.setBool('align_ignore_case', True)
                diff.preferences_updated()
            elif arg in [ '-w', '--ignore-all-space' ]:
                diff.prefs.setBool('display_ignore_whitespace', True)
                diff.prefs.setBool('align_ignore_whitespace', True)
                diff.preferences_updated()
            elif arg in [ '-D', '--close-if-same' ]:
                close_on_same = True
            else:
                print _('Skipping unknown argument %s.') % (repr(args[i]), )
        else:
            filename = diff.prefs.convertToNativePath(args[i])
            if len(specs) == 0:
                isdirviewer = os.path.isdir(filename)
            elif not isdirviewer and os.path.isdir(filename):
                filename = os.path.join(filename, os.path.basename(specs[0].name))
            if len(revs) == 0:
                revs.append(None)
            for rev in revs:
                specs.append(FileSpec(filename, rev, None, encoding))
            revs = []
            if separate:
                diff.newLoadedFileDiffViewer(specs)
                specs = []
        i += 1
    n = len(specs)
    if n > 0:
        diff.newLoadedFileDiffViewer(specs)
    nb = diff.notebook

    # create a file diff viewer if the command line arguments haven't
    # implicitly created any
    n = nb.get_n_pages()
    if n == 0:
        diff.newLoadedFileDiffViewer([])
    elif close_on_same:
        for i in range(n - 1, -1, -1):
            if not nb.get_nth_page(i).hasDifferences():
                nb.remove_page(i)
    n = nb.get_n_pages()
    if n > 0:
        nb.set_show_tabs(n > 1)
        nb.get_nth_page(0).grab_focus()
        diff.show()
        gtk.main()
        # save state
        diff.saveState(configpath)
