2016-07-04 00:22:54 +00:00
#!/usr/bin/env python3
2015-11-21 17:57:32 +00:00
# -*- coding: utf-8 -*-
# Copyright (C) 2006-2014 Derrick Moser <derrick_moser@yahoo.com>
2016-07-03 17:18:38 +00:00
# Copyright (C) 2015-2016 Romain Failliot <romain.failliot@foolstep.com>
2015-11-21 17:57:32 +00:00
#
# 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 codecs
import gettext
import locale
import os
import sys
# use the program's location as a starting place to search for supporting files
# such as icon and help documentation
if hasattr(sys, 'frozen'):
app_path = sys.executable
else:
app_path = os.path.realpath(sys.argv[0])
bin_dir = os.path.dirname(app_path)
# platform test
def isWindows():
return os.name == 'nt'
# translation location: '../share/locale/<LANG>/LC_MESSAGES/diffuse.mo'
# where '<LANG>' is the language key
lang = locale.getdefaultlocale()[0]
if isWindows():
# gettext looks for the language using environment variables which
# are normally not set on Windows so we try setting it for them
for v in 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG':
if v in os.environ:
lang = os.environ[v]
# remove any additional languages, encodings, or modifications
for v in ':.@':
lang = lang.split(v)[0]
break
else:
if lang is not None:
os.environ['LANG'] = lang
del v
locale_dir = 'locale'
else:
locale_dir = '../share/locale'
locale_dir = os.path.join(bin_dir, locale_dir)
gettext.bindtextdomain('diffuse', locale_dir)
gettext.textdomain('diffuse')
_ = gettext.gettext
APP_NAME = 'Diffuse'
2016-07-21 03:31:47 +00:00
VERSION = '0.5.0'
COPYRIGHT = _('''Copyright © 2006-2014 Derrick Moser
Copyright © 2015-2016 Romain Failliot''')
WEBSITE = 'https://github.com/MightyCreak/diffuse'
2015-11-21 17:57:32 +00:00
# process help options
if __name__ == '__main__':
args = sys.argv
argc = len(args)
if argc == 2 and args[1] in [ '-v', '--version' ]:
2016-07-04 00:22:54 +00:00
print('%s %s\n%s' % (APP_NAME, VERSION, COPYRIGHT))
2015-11-21 17:57:32 +00:00
sys.exit(0)
if argc == 2 and args[1] in [ '-h', '-?', '--help' ]:
2016-07-04 00:22:54 +00:00
print(_('''Usage:
2015-11-21 17:57:32 +00:00
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, RCS, 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
( -L | --label ) <label> Display <label> instead of the file name
( -m | --modified ) Create a new tab for each modified file
( -r | --revision ) <rev> File revision <rev>
( -s | --separate ) Create a new tab for each file
( -t | --tab ) Start a new tab
( -V | --vcs ) <vcs-list> Version control system search order
--line <line> Start with line <line> selected
--null-file Create a blank file comparison pane
Display Options:
( -b | --ignore-space-change ) Ignore changes to white space
( -B | --ignore-blank-lines ) Ignore changes in blank lines
( -E | --ignore-end-of-line ) Ignore end of line differences
( -i | --ignore-case ) Ignore case differences
( -w | --ignore-all-space ) Ignore white space differences'''))
sys.exit(0)
2016-07-04 00:22:54 +00:00
import gi
2016-07-09 21:38:29 +00:00
gi.require_version('GObject', '2.0')
from gi.repository import GObject
2016-07-04 00:22:54 +00:00
2016-07-09 21:38:29 +00:00
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import GdkPixbuf
2016-07-04 00:22:54 +00:00
2016-07-09 21:38:29 +00:00
gi.require_version('Pango', '1.0')
from gi.repository import Pango
gi.require_version('PangoCairo', '1.0')
from gi.repository import PangoCairo
2015-11-21 17:57:32 +00:00
import difflib
import encodings
import glob
import re
import shlex
import stat
import string
import subprocess
import unicodedata
import webbrowser
if not hasattr(__builtins__, 'WindowsError'):
# define 'WindowsError' so 'except' statements will work on all platforms
WindowsError = IOError
# convenience function to display debug messages
def logDebug(s):
pass #sys.stderr.write('%s: %s\n', (APP_NAME, s))
# avoid some dictionary lookups when string.whitespace is used in loops
# this is sorted based upon frequency to speed up code for stripping whitespace
whitespace = ' \t\n\r\x0b\x0c'
2016-07-09 03:11:43 +00:00
# escape special glob characters
def globEscape(s):
m = dict([ (c, u'[%s]' % (c, )) for c in u'[]?*' ])
return u''.join([ m.get(c, c) for c in s ])
2015-11-21 17:57:32 +00:00
# associate our icon with all of our windows
if __name__ == '__main__':
# this is not automatically set on some older version of PyGTK
2016-07-09 21:38:29 +00:00
Gtk.Window.set_default_icon_name('diffuse')
2015-11-21 17:57:32 +00:00
# convenience class for displaying a message dialogue
2016-07-09 21:38:29 +00:00
class MessageDialog(Gtk.MessageDialog):
2015-11-21 17:57:32 +00:00
def __init__(self, parent, type, s):
2016-07-09 21:38:29 +00:00
if type == Gtk.MessageType.ERROR:
buttons = Gtk.ButtonsType.OK
2015-11-21 17:57:32 +00:00
else:
2016-07-09 21:38:29 +00:00
buttons = Gtk.ButtonsType.OK_CANCEL
Gtk.MessageDialog.__init__(self, parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, type, buttons, s)
2015-11-21 17:57:32 +00:00
self.set_title(APP_NAME)
# report error messages
def logError(s):
2016-07-09 21:38:29 +00:00
m = MessageDialog(None, Gtk.MessageType.ERROR, s)
2015-11-21 17:57:32 +00:00
m.run()
m.destroy()
# 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):
return self + other * (1 - self.alpha)
# 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 state not in lookup:
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
# split string into lines based upon DOS, Mac, and Unix line endings
def splitlines(s):
# split on new line characters
temp, i, n = [], 0, len(s)
while i < n:
j = s.find('\n', i)
if j < 0:
temp.append(s[i:])
break
j += 1
temp.append(s[i:j])
i = j
# split on carriage return characters
ss = []
for s in temp:
i, n = 0, len(s)
while i < n:
j = s.find('\r', i)
if j < 0:
ss.append(s[i:])
break
j += 1
if j < n and s[j] == '\n':
j += 1
ss.append(s[i:j])
i = j
return ss
# also recognise old Mac OS line endings
def readlines(fd):
return strip_eols(splitlines(fd.read()))
2016-07-09 03:11:43 +00:00
def readconfiglines(fd):
2016-07-04 00:22:54 +00:00
return fd.read().replace(u'\r', u'').split(u'\n')
2016-07-09 03:11:43 +00:00
2015-11-21 17:57:32 +00:00
# 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', 'open_file_in_new_tab', 'Ctrl+t')
set_binding('menu', 'open_modified_files', 'Shift+Ctrl+O')
set_binding('menu', 'open_commit', 'Shift+Ctrl+T')
set_binding('menu', 'reload_file', 'Shift+Ctrl+R')
set_binding('menu', 'save_file', 'Ctrl+s')
set_binding('menu', 'save_file_as', 'Shift+Ctrl+A')
set_binding('menu', 'save_all', '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', 'new_n_way_file_merge', 'Ctrl+4')
set_binding('menu', 'close_tab', 'Ctrl+w')
set_binding('menu', 'undo_close_tab', 'Shift+Ctrl+W')
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', 'clear_edits', 'Ctrl+r')
set_binding('menu', 'dismiss_all_edits', 'Ctrl+d')
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', 'realign_all', 'Ctrl+l')
set_binding('menu', 'isolate', 'Ctrl+i')
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', 'first_tab', 'Shift+Ctrl+Page_Up')
set_binding('menu', 'previous_tab', 'Ctrl+Page_Up')
set_binding('menu', 'next_tab', 'Ctrl+Page_Down')
set_binding('menu', 'last_tab', 'Shift+Ctrl+Page_Down')
set_binding('menu', 'shift_pane_right', 'Shift+Ctrl+parenright')
set_binding('menu', 'shift_pane_left', 'Shift+Ctrl+parenleft')
set_binding('menu', 'convert_to_upper_case', 'Ctrl+u')
set_binding('menu', 'convert_to_lower_case', 'Shift+Ctrl+U')
set_binding('menu', 'sort_lines_in_ascending_order', 'Ctrl+y')
set_binding('menu', 'sort_lines_in_descending_order', 'Shift+Ctrl+Y')
set_binding('menu', 'remove_trailing_white_space', 'Ctrl+k')
set_binding('menu', 'convert_tabs_to_spaces', 'Ctrl+b')
set_binding('menu', 'convert_leading_spaces_to_tabs', 'Shift+Ctrl+B')
set_binding('menu', 'increase_indenting', 'Shift+Ctrl+greater')
set_binding('menu', 'decrease_indenting', 'Shift+Ctrl+less')
set_binding('menu', 'convert_to_dos', 'Shift+Ctrl+E')
set_binding('menu', 'convert_to_mac', 'Shift+Ctrl+C')
set_binding('menu', 'convert_to_unix', 'Ctrl+e')
set_binding('menu', 'copy_selection_right', 'Shift+Ctrl+Right')
set_binding('menu', 'copy_selection_left', 'Shift+Ctrl+Left')
set_binding('menu', 'copy_left_into_selection', 'Ctrl+Right')
set_binding('menu', 'copy_right_into_selection', 'Ctrl+Left')
set_binding('menu', 'merge_from_left_then_right', 'Ctrl+m')
set_binding('menu', 'merge_from_right_then_left', 'Shift+Ctrl+M')
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', 'enter_character_mode', 'KP_Enter')
set_binding('line_mode', 'first_line', 'Home')
set_binding('line_mode', 'first_line', 'g')
set_binding('line_mode', 'extend_first_line', 'Shift+Home')
set_binding('line_mode', 'last_line', 'End')
set_binding('line_mode', 'last_line', 'Shift+G')
set_binding('line_mode', 'extend_last_line', 'Shift+End')
set_binding('line_mode', 'up', 'Up')
set_binding('line_mode', 'up', 'k')
set_binding('line_mode', 'extend_up', 'Shift+Up')
set_binding('line_mode', 'extend_up', 'Shift+K')
set_binding('line_mode', 'down', 'Down')
set_binding('line_mode', 'down', 'j')
set_binding('line_mode', 'extend_down', 'Shift+Down')
set_binding('line_mode', 'extend_down', 'Shift+J')
set_binding('line_mode', 'left', 'Left')
set_binding('line_mode', 'left', 'h')
set_binding('line_mode', 'extend_left', 'Shift+Left')
set_binding('line_mode', 'right', 'Right')
set_binding('line_mode', 'right', 'l')
set_binding('line_mode', 'extend_right', 'Shift+Right')
set_binding('line_mode', 'page_up', 'Page_Up')
set_binding('line_mode', 'page_up', 'Ctrl+u')
set_binding('line_mode', 'extend_page_up', 'Shift+Page_Up')
set_binding('line_mode', 'extend_page_up', 'Shift+Ctrl+U')
set_binding('line_mode', 'page_down', 'Page_Down')
set_binding('line_mode', 'page_down', 'Ctrl+d')
set_binding('line_mode', 'extend_page_down', 'Shift+Page_Down')
set_binding('line_mode', 'extend_page_down', 'Shift+Ctrl+D')
set_binding('line_mode', 'delete_text', 'BackSpace')
set_binding('line_mode', 'delete_text', 'Delete')
set_binding('line_mode', 'delete_text', 'x')
set_binding('line_mode', 'clear_edits', 'r')
set_binding('line_mode', 'isolate', 'i')
set_binding('line_mode', 'first_difference', 'Ctrl+Home')
set_binding('line_mode', 'first_difference', 'Shift+P')
set_binding('line_mode', 'previous_difference', 'p')
set_binding('line_mode', 'next_difference', 'n')
set_binding('line_mode', 'last_difference', 'Ctrl+End')
set_binding('line_mode', 'last_difference', 'Shift+N')
#set_binding('line_mode', 'copy_selection_right', 'Shift+L')
#set_binding('line_mode', 'copy_selection_left', 'Shift+H')
set_binding('line_mode', 'copy_left_into_selection', 'Shift+L')
set_binding('line_mode', 'copy_right_into_selection', 'Shift+H')
set_binding('line_mode', 'merge_from_left_then_right', 'm')
set_binding('line_mode', 'merge_from_right_then_left', 'Shift+M')
set_binding('align_mode', 'enter_line_mode', 'Escape')
set_binding('align_mode', 'align', 'space')
set_binding('align_mode', 'enter_character_mode', 'Return')
set_binding('align_mode', 'enter_character_mode', 'KP_Enter')
set_binding('align_mode', 'first_line', 'g')
set_binding('align_mode', 'last_line', 'Shift+G')
set_binding('align_mode', 'up', 'Up')
set_binding('align_mode', 'up', 'k')
set_binding('align_mode', 'down', 'Down')
set_binding('align_mode', 'down', 'j')
set_binding('align_mode', 'left', 'Left')
set_binding('align_mode', 'left', 'h')
set_binding('align_mode', 'right', 'Right')
set_binding('align_mode', 'right', 'l')
set_binding('align_mode', 'page_up', 'Page_Up')
set_binding('align_mode', 'page_up', 'Ctrl+u')
set_binding('align_mode', 'page_down', 'Page_Down')
set_binding('align_mode', 'page_down', 'Ctrl+d')
set_binding('character_mode', 'enter_line_mode', 'Escape')
# default colours
self.colours = {
'alignment' : Colour(1.0, 1.0, 0.0),
'character_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),
'margin' : Colour(0.8, 0.8, 0.8),
'edited' : Colour(0.5, 1.0, 0.5),
'preedit' : Colour(0.0, 0.0, 0.0),
'text' : Colour(0.0, 0.0, 0.0),
'text_background' : Colour(1.0, 1.0, 1.0) }
# default floats
self.floats = {
'alignment_opacity' : 1.0,
'character_difference_opacity' : 0.4,
'character_selection_opacity' : 0.4,
'edited_opacity' : 0.4,
'line_difference_opacity' : 0.3,
'line_selection_opacity' : 0.4 }
# default strings
self.strings = {}
# syntax highlighting support
self.syntaxes = {}
self.syntax_file_patterns = {}
self.syntax_magic_patterns = {}
self.current_syntax = None
# list of imported resources files (we only import each file once)
self.resource_files = set()
# special string resources
self.setDifferenceColours('difference_1 difference_2 difference_3')
# keyboard action processing
def setKeyBinding(self, ctx, s, v):
action_tuple = (ctx, s)
2016-07-09 21:38:29 +00:00
modifiers = Gdk.ModifierType(0)
2015-11-21 17:57:32 +00:00
key = None
for token in v.split('+'):
if token == 'Shift':
2016-07-09 21:38:29 +00:00
modifiers |= Gdk.ModifierType.SHIFT_MASK
2015-11-21 17:57:32 +00:00
elif token == 'Ctrl':
2016-07-09 21:38:29 +00:00
modifiers |= Gdk.ModifierType.CONTROL_MASK
2015-11-21 17:57:32 +00:00
elif token == 'Alt':
2016-07-09 21:38:29 +00:00
modifiers |= Gdk.ModifierType.MOD1_MASK
2015-11-21 17:57:32 +00:00
elif len(token) == 0 or token[0] == '_':
raise ValueError()
else:
2016-07-09 21:38:29 +00:00
token = 'KEY_' + token
if not hasattr(Gdk, token):
2015-11-21 17:57:32 +00:00
raise ValueError()
2016-07-09 21:38:29 +00:00
key = getattr(Gdk, token)
2015-11-21 17:57:32 +00:00
if key is None:
raise ValueError()
key_tuple = (ctx, (key, modifiers))
# remove any existing binding
if key_tuple in self.keybindings_lookup:
self._removeKeyBinding(key_tuple)
# ensure we have a set to hold this action
if action_tuple not in self.keybindings:
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):
try:
return self.keybindings_lookup[(ctx, (key, modifiers))][1]
except KeyError:
pass
def getKeyBindings(self, ctx, s):
try:
return [ t for c, t in self.keybindings[(ctx, s)].keys() ]
except KeyError:
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])
# colour resources
def getColour(self, symbol):
try:
return self.colours[symbol]
except KeyError:
logDebug('Warning: unknown colour "%s"' % (symbol, ))
self.colours[symbol] = v = Colour(0.0, 0.0, 0.0)
return v
# float resources
def getFloat(self, symbol):
try:
return self.floats[symbol]
except KeyError:
logDebug('Warning: unknown float "%s"' % (symbol, ))
self.floats[symbol] = v = 0.5
return v
# string resources
def getString(self, symbol):
try:
return self.strings[symbol]
except KeyError:
logDebug('Warning: unknown string "%s"' % (symbol, ))
self.strings[symbols] = v = ''
return v
# syntax highlighting
def getSyntaxNames(self):
2016-07-04 00:22:54 +00:00
return list(self.syntaxes.keys())
2015-11-21 17:57:32 +00:00
def getSyntax(self, name):
return self.syntaxes.get(name, None)
def guessSyntaxForFile(self, name, ss):
name = os.path.basename(name)
for key, pattern in self.syntax_file_patterns.items():
if pattern.search(name):
return key
# fallback to analysing the first line of the file
if len(ss) > 0:
s = ss[0]
for key, pattern in self.syntax_magic_patterns.items():
if pattern.search(s):
return key
# parse resource files
def parse(self, file_name):
# only process files once
if file_name in self.resource_files:
return
self.resource_files.add(file_name)
f = open(file_name, 'r')
2016-07-09 03:11:43 +00:00
ss = readconfiglines(f)
2015-11-21 17:57:32 +00:00
f.close()
# FIXME: improve validation
for i, s in enumerate(ss):
args = shlex.split(s, True)
if len(args) == 0:
continue
try:
# eg. add Python syntax highlighting:
# import /usr/share/diffuse/syntax/python.syntax
2016-07-09 03:11:43 +00:00
if args[0] == u'import' and len(args) == 2:
2015-11-21 17:57:32 +00:00
path = os.path.expanduser(args[1])
# relative paths are relative to the parsed file
2016-07-09 03:11:43 +00:00
path = os.path.join(globEscape(os.path.dirname(file_name)), path)
2015-11-21 17:57:32 +00:00
paths = glob.glob(path)
if len(paths) == 0:
paths = [ path ]
for path in paths:
# convert to absolute path so the location of
# any processing errors are reported with
# normalised file names
self.parse(os.path.abspath(path))
# eg. make Ctrl+o trigger the open_file menu item
# keybinding menu open_file Ctrl+o
2016-07-09 03:11:43 +00:00
elif args[0] == u'keybinding' and len(args) == 4:
2015-11-21 17:57:32 +00:00
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
2016-07-09 03:11:43 +00:00
elif args[0] in [ u'colour', u'color' ] and len(args) == 5:
2015-11-21 17:57:32 +00:00
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_opacity 0.4
2016-07-09 03:11:43 +00:00
elif args[0] == u'float' and len(args) == 3:
2015-11-21 17:57:32 +00:00
self.floats[args[1]] = float(args[2])
# eg. set the help browser
# string help_browser gnome-help
2016-07-09 03:11:43 +00:00
elif args[0] == u'string' and len(args) == 3:
2015-11-21 17:57:32 +00:00
self.strings[args[1]] = args[2]
2016-07-09 03:11:43 +00:00
if args[1] == u'difference_colours':
2015-11-21 17:57:32 +00:00
self.setDifferenceColours(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
2016-07-09 03:11:43 +00:00
elif args[0] == u'syntax' and (len(args) == 2 or len(args) == 4):
2015-11-21 17:57:32 +00:00
key = args[1]
if len(args) == 2:
# remove file pattern for a syntax specification
try:
del self.syntax_file_patterns[key]
except KeyError:
pass
# remove magic pattern for a syntax specification
try:
del self.syntax_magic_patterns[key]
except KeyError:
pass
# remove a syntax specification
self.current_syntax = None
try:
del self.syntaxes[key]
except KeyError:
pass
else:
self.current_syntax = SyntaxParser(args[2], args[3])
self.syntaxes[key] = 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 '#'
2016-07-09 03:11:43 +00:00
elif args[0] == u'syntax_pattern' and self.current_syntax is not None and len(args) >= 5:
2015-11-21 17:57:32 +00:00
flags = 0
for arg in args[5:]:
2016-07-09 03:11:43 +00:00
if arg == u'ignorecase':
2015-11-21 17:57:32 +00:00
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?$'
2016-07-09 03:11:43 +00:00
elif args[0] == u'syntax_files' and (len(args) == 2 or len(args) == 3):
2015-11-21 17:57:32 +00:00
key = args[1]
if len(args) == 2:
# remove file pattern for a syntax specification
try:
del self.syntax_file_patterns[key]
except KeyError:
pass
else:
flags = 0
if isWindows():
flags |= re.IGNORECASE
self.syntax_file_patterns[key] = re.compile(args[2], flags)
# eg. default to the Python syntax rules when viewing
# a files starting with patterns like #!/usr/bin/python
# syntax_magic Python '^#!/usr/bin/python$'
2016-07-09 03:11:43 +00:00
elif args[0] == u'syntax_magic' and len(args) > 1:
2015-11-21 17:57:32 +00:00
key = args[1]
if len(args) == 2:
# remove magic pattern for a syntax specification
try:
del self.syntax_magic_patterns[key]
except KeyError:
pass
else:
flags = 0
for arg in args[3:]:
2016-07-09 03:11:43 +00:00
if arg == u'ignorecase':
2015-11-21 17:57:32 +00:00
flags |= re.IGNORECASE
else:
raise ValueError()
self.syntax_magic_patterns[key] = re.compile(args[2], flags)
else:
raise ValueError()
except: # Grr... the 're' module throws weird errors
#except ValueError:
logError(_('Error processing line %(line)d of %(file)s.') % { 'line': i + 1, 'file': file_name })
theResources = Resources()
# map an encoding name to its standard form
def norm_encoding(e):
if e is not None:
return e.replace('-', '_').lower()
# widget to help pick an encoding
2016-07-09 21:38:29 +00:00
class EncodingMenu(Gtk.HBox):
2015-11-21 17:57:32 +00:00
def __init__(self, prefs, autodetect=False):
2016-07-09 21:38:29 +00:00
Gtk.HBox.__init__(self)
self.combobox = combobox = Gtk.ComboBoxText.new()
2015-11-21 17:57:32 +00:00
self.encodings = prefs.getEncodings()[:]
for e in self.encodings:
combobox.append_text(e)
if autodetect:
self.encodings.insert(0, None)
combobox.prepend_text(_('Auto Detect'))
self.pack_start(combobox, False, False, 0)
combobox.show()
def set_text(self, encoding):
encoding = norm_encoding(encoding)
if encoding in self.encodings:
self.combobox.set_active(self.encodings.index(encoding))
def get_text(self):
i = self.combobox.get_active()
if i >= 0:
return self.encodings[i]
# text entry widget with a button to help pick file names
2016-07-09 21:38:29 +00:00
class FileEntry(Gtk.HBox):
2015-11-21 17:57:32 +00:00
def __init__(self, parent, title):
2016-07-09 21:38:29 +00:00
Gtk.HBox.__init__(self)
2015-11-21 17:57:32 +00:00
self.toplevel = parent
self.title = title
2016-07-09 21:38:29 +00:00
self.entry = entry = Gtk.Entry.new()
2015-11-21 17:57:32 +00:00
self.pack_start(entry, True, True, 0)
entry.show()
2016-07-09 21:38:29 +00:00
button = Gtk.Button.new()
image = Gtk.Image.new()
image.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.MENU)
2015-11-21 17:57:32 +00:00
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):
2016-07-09 21:38:29 +00:00
dialog = Gtk.FileChooserDialog(self.title, self.toplevel, Gtk.FileChooserAction.OPEN, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
2016-07-04 00:22:54 +00:00
dialog.set_current_folder(os.path.realpath(os.curdir))
2016-07-09 21:38:29 +00:00
if dialog.run() == Gtk.ResponseType.OK:
2015-11-21 17:57:32 +00:00
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()
2016-07-09 21:38:29 +00:00
# adaptor class to allow a Gtk.FontButton to be read like a Gtk.Entry
class FontButton(Gtk.FontButton):
2015-11-21 17:57:32 +00:00
def __init__(self):
2016-07-09 21:38:29 +00:00
Gtk.FontButton.__init__(self)
2015-11-21 17:57:32 +00:00
def get_text(self):
return self.get_font_name()
# class to store preferences and construct a dialogue for manipulating them
class Preferences:
def __init__(self, path):
self.bool_prefs = {}
self.int_prefs = {}
self.string_prefs = {}
self.int_prefs_min = {}
self.int_prefs_max = {}
self.string_prefs_enums = {}
# find available encodings
self.encodings = sorted(set(encodings.aliases.aliases.values()))
if isWindows():
svk_bin = 'svk.bat'
else:
svk_bin = 'svk'
auto_detect_codecs = [ 'utf_8', 'utf_16', 'latin_1' ]
e = norm_encoding(sys.getfilesystemencoding())
if e not in auto_detect_codecs:
# insert after UTF-8 as the default encoding may prevent UTF-8 from
# being tried
auto_detect_codecs.insert(2, e)
# 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', 'Monospace 10', _('Font') ],
[ 'Integer', 'display_tab_width', 8, _('Tab width'), 1, 1024 ],
[ 'Boolean', 'display_show_right_margin', True, _('Show right margin') ],
[ 'Integer', 'display_right_margin', 80, _('Right margin'), 1, 8192 ],
[ 'Boolean', 'display_show_line_numbers', True, _('Show line numbers') ],
[ 'Boolean', 'display_show_whitespace', False, _('Show white space characters') ],
[ 'Boolean', 'display_ignore_case', False, _('Ignore case differences') ],
[ 'Boolean', 'display_ignore_whitespace', False, _('Ignore white space differences') ],
[ 'Boolean', 'display_ignore_whitespace_changes', False, _('Ignore changes to white space') ],
[ 'Boolean', 'display_ignore_blanklines', False, _('Ignore blank line differences') ],
[ 'Boolean', 'display_ignore_endofline', False, _('Ignore end of line differences') ]
],
_('Alignment'),
[ 'List',
[ 'Boolean', 'align_ignore_case', False, _('Ignore case') ],
[ '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_endofline', True, _('Ignore end of line characters') ]
],
_('Editor'),
[ 'List',
[ 'Boolean', 'editor_auto_indent', True, _('Auto indent') ],
[ 'Boolean', 'editor_expand_tabs', False, _('Expand tabs to spaces') ],
[ 'Integer', 'editor_soft_tab_width', 8, _('Soft tab width'), 1, 1024 ]
],
_('Tabs'),
[ 'List',
[ 'Integer', 'tabs_default_panes', 2, _('Default panes'), 2, 16 ],
[ 'Boolean', 'tabs_always_show', False, _('Always show the tab bar') ],
[ 'Boolean', 'tabs_warn_before_quit', True, _('Warn me when closing a tab will quit %s') % APP_NAME ]
],
_('Regional Settings'),
[ 'List',
[ 'Encoding', 'encoding_default_codec', sys.getfilesystemencoding(), _('Default codec') ],
[ 'String', 'encoding_auto_detect_codecs', ' '.join(auto_detect_codecs), _('Order of codecs used to identify encoding') ]
],
]
# conditions used to determine if a preference should be greyed out
self.disable_when = {
'display_right_margin': ('display_show_right_margin', False),
'display_ignore_whitespace_changes': ('display_ignore_whitespace', True),
'display_ignore_blanklines': ('display_ignore_whitespace', True),
'display_ignore_endofline': ('display_ignore_whitespace', True),
'align_ignore_whitespace_changes': ('align_ignore_whitespace', True),
'align_ignore_blanklines': ('align_ignore_whitespace', True),
'align_ignore_endofline': ('align_ignore_whitespace', True)
}
if isWindows():
root = os.environ.get('SYSTEMDRIVE', None)
if root is None:
root = 'C:\\'
elif not root.endswith('\\'):
root += '\\'
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'),
('cvs', 'CVS', 'cvs'),
('darcs', 'Darcs', 'darcs'),
('git', 'Git', 'git'),
('hg', 'Mercurial', 'hg'),
('mtn', 'Monotone', 'mtn'),
('rcs', 'RCS', None),
('svn', 'Subversion', 'svn'),
('svk', 'SVK', svk_bin) ]
vcs_template = [ 'List',
[ 'String', 'vcs_search_order', 'bzr cvs darcs git hg mtn rcs svn svk', _('Version control system search order') ] ]
vcs_folders_template = [ 'FolderSet' ]
for key, name, cmd in vcs:
temp = [ 'List' ]
if key == 'rcs':
# RCS uses multiple commands
temp.extend([ [ 'File', key + '_bin_co', 'co', _('"co" command') ],
[ 'File', key + '_bin_rlog', 'rlog', _('"rlog" command') ] ])
else:
temp.extend([ [ 'File', key + '_bin', cmd, _('Command') ] ])
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_folders_template.extend([ name, temp ])
2016-07-03 17:14:12 +00:00
vcs_template.append(vcs_folders_template)
2015-11-21 17:57:32 +00:00
self.template.extend([ _('Version Control'), vcs_template ])
self._initFromTemplate(self.template)
self.default_bool_prefs = self.bool_prefs.copy()
self.default_int_prefs = self.int_prefs.copy()
self.default_string_prefs = self.string_prefs.copy()
# load the user's preferences
self.path = path
if os.path.isfile(self.path):
try:
f = open(self.path, 'r')
2016-07-09 03:11:43 +00:00
ss = readconfiglines(f)
2015-11-21 17:57:32 +00:00
f.close()
for j, s in enumerate(ss):
try:
a = shlex.split(s, True)
if len(a) > 0:
p = a[0]
if len(a) == 2 and p in self.bool_prefs:
self.bool_prefs[p] = (a[1] == 'True')
elif len(a) == 2 and p in self.int_prefs:
self.int_prefs[p] = max(self.int_prefs_min[p], min(int(a[1]), self.int_prefs_max[p]))
elif len(a) == 2 and p in self.string_prefs:
self.string_prefs[p] = a[1]
else:
raise ValueError()
except ValueError:
# this may happen if the prefs were written by a
# different version -- don't bother the user
logDebug('Error processing line %d of %s.' % (j + 1, self.path))
except IOError:
# bad $HOME value? -- don't bother the user
logDebug('Error reading %s.' % (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]
self.int_prefs_min[template[1]] = template[4]
self.int_prefs_max[template[1]] = template[5]
elif template[0] in [ 'String', 'File', 'Font', 'Encoding' ]:
self.string_prefs[template[1]] = template[2]
# callback used when a preference is toggled
def _toggled_cb(self, widget, widgets, name):
# disable any preferences than are no longer relevant
for k, v in self.disable_when.items():
p, t = v
if p == name:
widgets[k].set_sensitive(widgets[p].get_active() != t)
# display the dialogue and update the preference values if the accept
# button was pressed
def runDialog(self, parent):
2016-07-09 21:38:29 +00:00
dialog = Gtk.Dialog(_('Preferences'), parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, (Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, Gtk.STOCK_OK, Gtk.ResponseType.OK))
2015-11-21 17:57:32 +00:00
widgets = {}
w = self._buildPrefsDialog(parent, widgets, self.template)
# disable any preferences than are not relevant
for k, v in self.disable_when.items():
p, t = v
if widgets[p].get_active() == t:
widgets[k].set_sensitive(False)
dialog.vbox.add(w)
w.show()
2016-07-09 21:38:29 +00:00
accept = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
if accept:
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] = nullToEmpty(widgets[k].get_text())
try:
ss = []
for k, v in self.bool_prefs.items():
if v != self.default_bool_prefs[k]:
ss.append('%s %s\n' % (k, v))
for k, v in self.int_prefs.items():
if v != self.default_int_prefs[k]:
ss.append('%s %s\n' % (k, v))
for k, v in self.string_prefs.items():
if v != self.default_string_prefs[k]:
ss.append('%s "%s"\n' % (k, v.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:
2016-07-09 21:38:29 +00:00
m = MessageDialog(parent, Gtk.MessageType.ERROR, _('Error writing %s.') % (self.path, ))
2015-11-21 17:57:32 +00:00
m.run()
m.destroy()
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':
2016-07-09 21:38:29 +00:00
notebook = Gtk.Notebook.new()
2015-11-21 17:57:32 +00:00
notebook.set_border_width(10)
i = 1
while i < len(template):
2016-07-09 21:38:29 +00:00
label = Gtk.Label.new(template[i])
2015-11-21 17:57:32 +00:00
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
2016-07-09 21:38:29 +00:00
table = Gtk.Table.new(2, n, False)
2015-11-21 17:57:32 +00:00
table.set_border_width(10)
for i, tpl in enumerate(template[1:]):
type = tpl[0]
if type == 'FolderSet':
w = self._buildPrefsDialog(parent, widgets, tpl)
2016-07-09 21:38:29 +00:00
table.attach(w, 0, 2, i, i + 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
w.show()
elif type == 'Boolean':
2016-07-09 21:38:29 +00:00
button = Gtk.CheckButton.new_with_mnemonic(tpl[3])
2015-11-21 17:57:32 +00:00
button.set_active(self.bool_prefs[tpl[1]])
widgets[tpl[1]] = button
2016-07-09 21:38:29 +00:00
table.attach(button, 1, 2, i, i + 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
button.connect('toggled', self._toggled_cb, widgets, tpl[1])
button.show()
else:
2016-07-09 21:38:29 +00:00
label = Gtk.Label.new(tpl[3] + ': ')
2015-11-21 17:57:32 +00:00
label.set_alignment(1.0, 0.5)
2016-07-09 21:38:29 +00:00
table.attach(label, 0, 1, i, i + 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
label.show()
if tpl[0] in [ 'Font', 'Integer' ]:
2016-07-09 21:38:29 +00:00
entry = Gtk.HBox.new(False, 0)
2015-11-21 17:57:32 +00:00
if tpl[0] == 'Font':
button = FontButton()
button.set_font_name(self.string_prefs[tpl[1]])
else:
2016-07-09 21:38:29 +00:00
button = Gtk.SpinButton.new(Gtk.Adjustment.new(self.int_prefs[tpl[1]], tpl[4], tpl[5], 1, 0, 0), 1.0, 0)
2015-11-21 17:57:32 +00:00
widgets[tpl[1]] = button
entry.pack_start(button, False, False, 0)
button.show()
else:
if tpl[0] == 'Encoding':
entry = EncodingMenu(self)
entry.set_text(tpl[3])
elif tpl[0] == 'File':
entry = FileEntry(parent, tpl[3])
else:
2016-07-09 21:38:29 +00:00
entry = Gtk.Entry.new()
2015-11-21 17:57:32 +00:00
widgets[tpl[1]] = entry
entry.set_text(self.string_prefs[tpl[1]])
2016-07-09 21:38:29 +00:00
table.attach(entry, 1, 2, i, i + 1, Gtk.AttachOptions.EXPAND|Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
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 setString(self, name, value):
self.string_prefs[name] = value
def getEncodings(self):
return self.encodings
def _getDefaultEncodings(self):
return self.string_prefs['encoding_auto_detect_codecs'].split()
def getDefaultEncoding(self):
return self.string_prefs['encoding_default_codec']
# attempt to convert a string to unicode from an unknown encoding
def convertToUnicode(self, s):
# a BOM is required for autodetecting UTF16 and UTF32
magic = { 'utf16': [ codecs.BOM_UTF16_BE, codecs.BOM_UTF16_LE ],
'utf32': [ codecs.BOM_UTF32_BE, codecs.BOM_UTF32_LE ] }
for encoding in self._getDefaultEncodings():
try:
for m in magic.get(encoding.lower().replace('-', '').replace('_', ''), [ '' ]):
2016-07-04 00:22:54 +00:00
if str(s).startswith(m):
2015-11-21 17:57:32 +00:00
break
else:
continue
2016-07-04 00:22:54 +00:00
return str(s, encoding=encoding), encoding
2015-11-21 17:57:32 +00:00
except (UnicodeDecodeError, LookupError):
pass
return u''.join([ unichr(ord(c)) for c in s ]), 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() and s.find('/') >= 0:
# treat as a cygwin path
s = s.replace(os.sep, '/')
# convert to a Windows native style path
p = [ a for a in s.split('/') if a != '' ]
if s.startswith('//'):
p[:0] = [ '', '' ]
elif s.startswith('/'):
pr = [ a for a in self.getString('cygwin_cygdrive_prefix').split('/') if a != '' ]
n = len(pr)
if len(p) > n and len(p[n]) == 1 and p[:n] == pr:
# path starts with cygdrive prefix
p[:n + 1] = [ p[n] + ':' ]
else:
# full path
p[:0] = [ a for a in self.getString('cygwin_root').split(os.sep) if a != '' ]
# add trailing slash
if p[-1] != '' and s.endswith('/'):
p.append('')
s = os.sep.join(p)
return s
# convenience method for creating a menu according to a template
def createMenu(specs, radio=None, accel_group=None):
2016-07-09 21:38:29 +00:00
menu = Gtk.Menu.new()
2015-11-21 17:57:32 +00:00
for spec in specs:
if len(spec) > 0:
if len(spec) > 7 and spec[7] is not None:
g, k = spec[7]
if g not in radio:
2016-07-09 21:38:29 +00:00
item = Gtk.RadioMenuItem.new_with_mnemonic_from_widget(None, spec[0])
2015-11-21 17:57:32 +00:00
radio[g] = (item, {})
else:
2016-07-09 21:38:29 +00:00
item = Gtk.RadioMenuItem.new_with_mnemonic_from_widget(radio[g][0], spec[0])
2015-11-21 17:57:32 +00:00
radio[g][1][k] = item
else:
2016-07-09 21:38:29 +00:00
item = Gtk.ImageMenuItem.new_with_mnemonic(spec[0])
2015-11-21 17:57:32 +00:00
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:
2016-07-09 21:38:29 +00:00
image = Gtk.Image.new()
image.set_from_stock(spec[3], Gtk.IconSize.MENU)
2015-11-21 17:57:32 +00:00
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]
2016-07-09 21:38:29 +00:00
item.add_accelerator('activate', accel_group, key, modifier, Gtk.AccelFlags.VISIBLE)
2015-11-21 17:57:32 +00:00
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], radio, accel_group))
2016-07-04 00:22:54 +00:00
item.set_use_underline(True)
2015-11-21 17:57:32 +00:00
else:
2016-07-09 21:38:29 +00:00
item = Gtk.SeparatorMenuItem.new()
2015-11-21 17:57:32 +00:00
item.show()
2016-07-04 00:22:54 +00:00
menu.append(item)
2015-11-21 17:57:32 +00:00
return menu
# convenience method for creating a menu bar according to a template
def createMenuBar(specs, radio, accel_group):
2016-07-09 21:38:29 +00:00
menu_bar = Gtk.MenuBar.new()
2015-11-21 17:57:32 +00:00
for label, spec in specs:
2016-07-09 21:38:29 +00:00
menu = Gtk.MenuItem.new_with_mnemonic(label)
2015-11-21 17:57:32 +00:00
menu.set_submenu(createMenu(spec, radio, accel_group))
2016-07-04 00:22:54 +00:00
menu.set_use_underline(True)
2015-11-21 17:57:32 +00:00
menu.show()
2016-07-04 00:22:54 +00:00
menu_bar.append(menu)
2015-11-21 17:57:32 +00:00
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:
2016-07-09 21:38:29 +00:00
button = Gtk.Button.new()
button.set_relief(Gtk.ReliefStyle.NONE)
2016-07-04 00:22:54 +00:00
button.set_can_focus(False)
2016-07-09 21:38:29 +00:00
image = Gtk.Image.new()
2015-11-21 17:57:32 +00:00
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:
2016-07-09 21:38:29 +00:00
button.set_tooltip_text(spec[3])
2015-11-21 17:57:32 +00:00
box.pack_start(button, False, False, 0)
button.show()
else:
2016-07-09 21:38:29 +00:00
separator = Gtk.VSeparator.new()
2015-11-21 17:57:32 +00:00
box.pack_start(separator, False, False, 5)
separator.show()
# masks used to indicate the presence of particular line endings
DOS_FORMAT = 1
MAC_FORMAT = 2
UNIX_FORMAT = 4
# True if the string ends with '\r\n'
def has_dos_line_ending(s):
return s.endswith('\r\n')
# True if the string ends with '\r'
def has_mac_line_ending(s):
return s.endswith('\r')
# True if the string ends with '\n' but not '\r\n'
def has_unix_line_ending(s):
return s.endswith('\n') and not s.endswith('\r\n')
# returns the format mask for a list of strings
def getFormat(ss):
flags = 0
for s in ss:
if s is not None:
if has_dos_line_ending(s):
flags |= DOS_FORMAT
elif has_mac_line_ending(s):
flags |= MAC_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(s):
if s is None:
return 0
n = len(s)
if s.endswith('\r\n'):
n -= 2
elif s.endswith('\r') or s.endswith('\n'):
n -= 1
return n
# returns the string without the line ending characters
def strip_eol(s):
if s is not None:
return s[:len_minus_line_ending(s)]
# returns the list of strings without line ending characters
def strip_eols(ss):
return [ strip_eol(s) for s in ss ]
# convenience method to change the line ending of a string
def convert_to_format(s, format):
if s is not None and format != 0:
old_format = getFormat([ s ])
if old_format != 0 and (old_format & format) == 0:
s = strip_eol(s)
# prefer the host line ending style
if (format & DOS_FORMAT) and os.linesep == '\r\n':
s += os.linesep
elif (format & MAC_FORMAT) and os.linesep == '\r':
s += os.linesep
elif (format & UNIX_FORMAT) and os.linesep == '\n':
s += os.linesep
elif format & UNIX_FORMAT:
s += '\n'
elif format & DOS_FORMAT:
s += '\r\n'
elif format & MAC_FORMAT:
s += '\r'
return s
# returns the Windows drive or share from a from an absolute path
def drive_from_path(s):
c = s.split(os.sep)
if len(c) > 3 and c[0] == '' and c[1] == '':
return os.path.join(c[:4])
return c[0]
# constructs a relative path from 'a' to 'b', both should be absolute paths
def relpath(a, b):
if isWindows():
if drive_from_path(a) != drive_from_path(b):
return b
c1 = [ c for c in a.split(os.sep) if c != '' ]
c2 = [ c for c in b.split(os.sep) if c != '' ]
i, n = 0, len(c1)
while i < n and i < len(c2) and c1[i] == c2[i]:
i += 1
r = (n - i) * [ os.pardir ]
r.extend(c2[i:])
return os.sep.join(r)
# helper function prevent files from being confused with command line options
# by prepending './' to the basename
def safeRelativePath(abspath1, name, prefs, cygwin_pref):
s = os.path.join(os.curdir, relpath(abspath1, os.path.abspath(name)))
s = codecs.encode(s, sys.getfilesystemencoding())
if isWindows():
if prefs.getBool(cygwin_pref):
s = s.replace('\\', '/')
else:
s = s.replace('/', '\\')
return s
# escape arguments for use with bash
def bashEscape(s):
return "'" + s.replace("'", "'\\''") + "'"
# use popen to read the output of a command
def popenRead(dn, cmd, prefs, bash_pref, success_results=None):
if success_results is None:
success_results = [ 0 ]
if isWindows() and prefs.getBool(bash_pref):
# launch the command from a bash shell is requested
cmd = [ 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 command's output
s = fd.read()
fd.close()
if proc.wait() not in success_results:
raise IOError('Command failed.')
return s
# use popen to read the output of a command
def popenReadLines(dn, cmd, prefs, bash_pref, success_results=None):
return strip_eols(splitlines(popenRead(dn, cmd, prefs, bash_pref, success_results)))
# simulate use of popen with xargs to read the output of a command
def popenXArgsReadLines(dn, cmd, args, prefs, bash_pref):
# os.sysconf() is only available on Unix
if hasattr(os, 'sysconf'):
maxsize = os.sysconf('SC_ARG_MAX')
maxsize -= sum([ len(k) + len(v) + 2 for k, v in os.environ.items() ])
else:
# assume the Window's limit to CreateProcess()
maxsize = 32767
maxsize -= sum([ len(k) + 1 for k in cmd ])
ss = []
i, s, a = 0, 0, []
while i < len(args):
f = (len(a) == 0)
if f:
# start a new command line
a = cmd[:]
elif s + len(args[i]) + 1 <= maxsize:
f = True
if f:
# append another argument to the current command line
a.append(args[i])
s += len(args[i]) + 1
i += 1
if i == len(args) or not f:
ss.extend(popenReadLines(dn, a, prefs, bash_pref))
s, a = 0, []
return ss
# utility class to help support Git and Monotone
# represents a set of files and folders of interest for "git status" or
# "mtn automate inventory"
class _VcsFolderSet:
def __init__(self, names):
self.folders = f = []
for name in names:
name = os.path.abspath(name)
# ensure all names end with os.sep
if not name.endswith(os.sep):
name += os.sep
f.append(name)
# returns True if the given abspath is a file that should be included in
# the interesting file subset
def contains(self, abspath):
if not abspath.endswith(os.sep):
abspath += os.sep
for f in self.folders:
if abspath.startswith(f):
return True
return False
# utility method to help find folders used by version control systems
def _find_parent_dir_with(path, dir_name):
while True:
name = os.path.join(path, dir_name)
if os.path.isdir(name):
return path
newpath = os.path.dirname(path)
if newpath == path:
break
path = newpath
# These class implement the set of supported version control systems. Each
# version control system should implement:
#
# __init__():
# the object will initialised with the repository's root folder
#
# getFileTemplate():
# indicates which revisions to display for a file when none were
# explicitly requested
#
# getCommitTemplate():
# indicates which file revisions to display for a commit
#
# getFolderTemplate():
# indicates which file revisions to display for a set of folders
#
# getRevision():
# returns the contents of the specified file revision
# Bazaar support
class _Bzr:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
# merge conflict
left = name + '.OTHER'
right = name + '.THIS'
if os.path.isfile(left) and os.path.isfile(right):
return [ (left, None), (name, None), (right, None) ]
# default case
return [ (name, '-1'), (name, None) ]
def getCommitTemplate(self, prefs, rev, names):
# build command
args = [ prefs.getString('bzr_bin'), 'log', '-v', '-r', rev ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
args.append(safeRelativePath(self.root, name, prefs, 'bzr_cygwin'))
# run command
ss = popenReadLines(self.root, args, prefs, 'bzr_bash')
# parse response
prev = 'before:' + rev
fs = _VcsFolderSet(names)
added, modified, removed, renamed = {}, {}, {}, {}
i, n = 0, len(ss)
while i < n:
s = ss[i]
i += 1
if s.startswith('added:'):
# added files
while i < n and ss[i].startswith(' '):
k = prefs.convertToNativePath(ss[i][2:])
i += 1
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, rev) ]
elif s.startswith('modified:'):
# modified files
while i < n and ss[i].startswith(' '):
k = prefs.convertToNativePath(ss[i][2:])
i += 1
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
modified[k] = [ (k, prev), (k, rev) ]
elif s.startswith('removed:'):
# removed files
while i < n and ss[i].startswith(' '):
k = prefs.convertToNativePath(ss[i][2:])
i += 1
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
elif s.startswith('renamed:'):
# renamed files
while i < n and ss[i].startswith(' '):
k = ss[i][2:].split(' => ')
i += 1
if len(k) == 2:
k0 = prefs.convertToNativePath(k[0])
k1 = prefs.convertToNativePath(k[1])
if not k0.endswith(os.sep) and not k1.endswith(os.sep):
k0 = os.path.join(self.root, k0)
k1 = os.path.join(self.root, k1)
if fs.contains(k0) or fs.contains(k1):
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
renamed[k1] = [ (k0, prev), (k1, rev) ]
# sort the results
result, r = [], set()
for m in removed, added, modified, renamed:
r.update(m.keys())
for k in sorted(r):
for m in removed, added, modified, renamed:
if k in m:
result.append(m[k])
return result
def getFolderTemplate(self, prefs, names):
# build command
args = [ prefs.getString('bzr_bin'), 'status', '-SV' ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
args.append(safeRelativePath(self.root, name, prefs, 'bzr_cygwin'))
# run command
prev = '-1'
fs = _VcsFolderSet(names)
added, modified, removed, renamed = {}, {}, {}, {}
for s in popenReadLines(self.root, args, prefs, 'bzr_bash'):
# parse response
if len(s) < 5:
continue
y, k = s[1], s[4:]
if y == 'D':
# removed
k = prefs.convertToNativePath(k)
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
elif y == 'N':
# added
k = prefs.convertToNativePath(k)
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, None) ]
elif y == 'M':
# modified or merge conflict
k = prefs.convertToNativePath(k)
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
modified[k] = self.getFileTemplate(prefs, k)
elif s[0] == 'R':
# renamed
k = k.split(' => ')
if len(k) == 2:
k0 = prefs.convertToNativePath(k[0])
k1 = prefs.convertToNativePath(k[1])
if not k0.endswith(os.sep) and not k1.endswith(os.sep):
k0 = os.path.join(self.root, k0)
k1 = os.path.join(self.root, k1)
if fs.contains(k0) or fs.contains(k1):
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
renamed[k1] = [ (k0, prev), (k1, None) ]
# sort the results
result, r = [], set()
for m in removed, added, modified, renamed:
r.update(m.keys())
for k in sorted(r):
for m in removed, added, modified, renamed:
if k in m:
result.append(m[k])
return result
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('bzr_bin'), 'cat', '--name-from-revision', '-r', rev, safeRelativePath(self.root, name, prefs, 'bzr_cygwin') ], prefs, 'bzr_bash')
def _get_bzr_repo(path, prefs):
p = _find_parent_dir_with(path, '.bzr')
if p:
return _Bzr(p)
# CVS support
class _Cvs:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
return [ (name, 'BASE'), (name, None) ]
def getCommitTemplate(self, prefs, rev, names):
result = []
try:
r, prev = rev.split('.'), None
if len(r) > 1:
m = int(r.pop())
if m > 1:
r.append(str(m - 1))
else:
m = int(r.pop())
if len(r):
prev = '.'.join(r)
for k in sorted(names):
if prev is None:
k0 = None
else:
k0 = k
result.append([ (k0, prev), (k, rev) ])
except ValueError:
logError(_('Error parsing revision %s.') % (rev, ))
return result
def getFolderTemplate(self, prefs, names):
# build command
args = [ prefs.getString('cvs_bin'), '-nq', 'update', '-R' ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
args.append(safeRelativePath(self.root, name, prefs, 'cvs_cygwin'))
# run command
prev = 'BASE'
fs = _VcsFolderSet(names)
modified = {}
for s in popenReadLines(self.root, args, prefs, 'cvs_bash'):
# parse response
if len(s) < 3 or s[0] not in 'ACMR':
continue
k = os.path.join(self.root, prefs.convertToNativePath(s[2:]))
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
if s[0] == 'R':
# removed
modified[k] = [ (k, prev), (None, None) ]
pass
elif s[0] == 'A':
# added
modified[k] = [ (None, None), (k, None) ]
else:
# modified
modified[k] = [ (k, prev), (k, None) ]
# sort the results
return [ modified[k] for k in sorted(modified.keys()) ]
def getRevision(self, prefs, name, rev):
if rev == 'BASE' and not os.path.exists(name):
# find revision for removed files
for s in popenReadLines(self.root, [ prefs.getString('cvs_bin'), 'status', safeRelativePath(self.root, name, prefs, 'cvs_cygwin') ], prefs, 'cvs_bash'):
if s.startswith(' Working revision:\t-'):
rev = s.split('\t')[1][1:]
return popenRead(self.root, [ prefs.getString('cvs_bin'), '-Q', 'update', '-p', '-r', rev, safeRelativePath(self.root, name, prefs, 'cvs_cygwin') ], prefs, 'cvs_bash')
def _get_cvs_repo(path, prefs):
if os.path.isdir(os.path.join(path, 'CVS')):
return _Cvs(path)
# Darcs support
class _Darcs:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
return [ (name, ''), (name, None) ]
def _getCommitTemplate(self, prefs, names, rev):
mods = (rev is None)
# build command
args = [ prefs.getString('darcs_bin') ]
if mods:
args.extend(['whatsnew', '-s'])
else:
args.extend(['changes', '--number', '-s'])
try:
args.extend(['-n', str(int(rev))])
except ValueError:
args.extend(['--max-count', '1', '-p', rev])
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
if mods:
args.append(safeRelativePath(self.root, name, prefs, 'darcs_cygwin'))
# run command
# 'darcs whatsnew' will return 1 if there are no changes
ss = popenReadLines(self.root, args, prefs, 'darcs_bash', [0, 1])
# parse response
i, n = 0, len(ss)
if mods:
prev = ''
rev = None
else:
try:
rev = ss[0].split(':')[0]
prev = str(int(rev) + 1)
# skip to the beginning of the summary
while i < n and len(ss[i]):
i += 1
except (ValueError, IndexError):
i = n
fs = _VcsFolderSet(names)
added, modified, removed, renamed = {}, {}, {}, {}
while i < n:
s = ss[i]
i += 1
if not mods:
if s.startswith(' '):
s = s[4:]
else:
continue
if len(s) < 2:
continue
x = s[0]
if x == 'R':
# removed
k = prefs.convertToNativePath(s[2:])
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
elif x == 'A':
# added
k = prefs.convertToNativePath(s[2:])
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, rev) ]
elif x == 'M':
# modified
k = prefs.convertToNativePath(s[2:].split(' ')[0])
if not k.endswith(os.sep):
k = os.path.join(self.root, k)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
modified[k] = [ (k, prev), (k, rev) ]
elif x == ' ':
# renamed
k = s[1:].split(' -> ')
if len(k) == 2:
k0 = prefs.convertToNativePath(k[0])
k1 = prefs.convertToNativePath(k[1])
if not k0.endswith(os.sep):
k0 = os.path.join(self.root, k0)
k1 = os.path.join(self.root, k1)
if fs.contains(k0) or fs.contains(k1):
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
renamed[k1] = [ (k0, prev), (k1, rev) ]
# sort the results
result, r = [], set()
for m in added, modified, removed, renamed:
r.update(m.keys())
for k in sorted(r):
for m in removed, added, modified, renamed:
if k in m:
result.append(m[k])
return result
def getCommitTemplate(self, prefs, rev, names):
return self._getCommitTemplate(prefs, names, rev)
def getFolderTemplate(self, prefs, names):
return self._getCommitTemplate(prefs, names, None)
def getRevision(self, prefs, name, rev):
args = [ prefs.getString('darcs_bin'), 'show', 'contents' ]
try:
args.extend([ '-n', str(int(rev)) ])
except ValueError:
args.extend([ '-p', rev ])
args.append(safeRelativePath(self.root, name, prefs, 'darcs_cygwin'))
return popenRead(self.root, args, prefs, 'darcs_bash')
def _get_darcs_repo(path, prefs):
p = _find_parent_dir_with(path, '_darcs')
if p:
return _Darcs(p)
# Git support
class _Git:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
return [ (name, 'HEAD'), (name, None) ]
def getCommitTemplate(self, prefs, rev, names):
# build command
args = [ prefs.getString('git_bin'), 'show', '--pretty=format:', '--name-status', rev ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
# run command
prev = rev + '^'
fs = _VcsFolderSet(names)
modified = {}
for s in popenReadLines(self.root, args, prefs, 'git_bash'):
# parse response
if len(s) < 2 or s[0] not in 'ADM':
continue
k = self._extractPath(s[2:], prefs)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
if s[0] == 'D':
# removed
modified[k] = [ (k, prev), (None, None) ]
elif s[0] == 'A':
# added
modified[k] = [ (None, None), (k, rev) ]
else:
# modified
modified[k] = [ (k, prev), (k, rev) ]
# sort the results
return [ modified[k] for k in sorted(modified.keys()) ]
def _extractPath(self, s, prefs):
return os.path.join(self.root, prefs.convertToNativePath(s.strip()))
def getFolderTemplate(self, prefs, names):
# build command
2016-07-09 03:00:58 +00:00
args = [ prefs.getString('git_bin'), 'status', '--porcelain', '-s', '--untracked-files=no', '--ignore-submodules=all' ]
2015-11-21 17:57:32 +00:00
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
# run command
prev = 'HEAD'
fs = _VcsFolderSet(names)
modified, renamed = {}, {}
# 'git status' will return 1 when a commit would fail
for s in popenReadLines(self.root, args, prefs, 'git_bash', [0, 1]):
# parse response
if len(s) < 3:
continue
x, y, k = s[0], s[1], s[2:]
if x == 'R':
# renamed
k = k.split(' -> ')
if len(k) == 2:
k0 = self._extractPath(k[0], prefs)
k1 = self._extractPath(k[1], prefs)
if fs.contains(k0) or fs.contains(k1):
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
renamed[k1] = [ (k0, prev), (k1, None) ]
elif x == 'U' or y == 'U' or (x == 'D' and y == 'D'):
# merge conflict
k = self._extractPath(k, prefs)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
if x == 'D':
panes = [ (None, None) ]
else:
panes = [ (k, ':2') ]
panes.append((k, None))
if y == 'D':
panes.append((None, None))
else:
panes.append((k, ':3'))
if x != 'A' and y != 'A':
panes.append((k, ':1'))
modified[k] = panes
else:
k = self._extractPath(k, prefs)
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
if x == 'A':
# added
panes = [ (None, None) ]
else:
panes = [ (k, prev) ]
# staged changes
if x == 'D':
panes.append((None, None))
elif x != ' ':
panes.append((k, ':0'))
# working copy changes
if y == 'D':
panes.append((None, None))
elif y != ' ':
panes.append((k, None))
modified[k] = panes
# sort the results
result, r = [], set()
for m in modified, renamed:
r.update(m.keys())
for k in sorted(r):
for m in modified, renamed:
if k in m:
result.append(m[k])
return result
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('git_bin'), 'show', '%s:%s' % (rev, relpath(self.root, os.path.abspath(name)).replace(os.sep, '/')) ], prefs, 'git_bash')
def _get_git_repo(path, prefs):
if 'GIT_DIR' in os.environ:
try:
d = path
ss = popenReadLines(d, [ prefs.getString('git_bin'), 'rev-parse', '--show-prefix' ], prefs, 'git_bash')
if len(ss) > 0:
# be careful to handle trailing slashes
d = d.split(os.sep)
if d[-1] != '':
d.append('')
ss = strip_eol(ss[0]).split('/')
if ss[-1] != '':
ss.append('')
n = len(ss)
if n <= len(d):
del d[-n:]
if len(d) == 0:
d = os.curdir
else:
d = os.sep.join(d)
return _Git(d)
except (IOError, OSError, WindowsError):
# working tree not found
pass
2016-07-09 03:00:58 +00:00
# search for .git direcotry (project) or .git file (submodule)
while True:
name = os.path.join(path, '.git')
if os.path.isdir(name) or os.path.isfile(name):
return _Git(path)
newpath = os.path.dirname(path)
if newpath == path:
break
path = newpath
2015-11-21 17:57:32 +00:00
# Mercurial support
class _Hg:
def __init__(self, root):
self.root = root
self.working_rev = None
def _getPreviousRevision(self, prefs, rev):
if rev is None:
if self.working_rev is None:
ss = popenReadLines(self.root, [ prefs.getString('hg_bin'), 'id', '-i', '-t' ], prefs, 'hg_bash')
if len(ss) != 1:
raise IOError('Unknown working revision')
ss = ss[0].split(' ')
prev = ss[-1]
if len(ss) == 1 and prev.endswith('+'):
# remove local modifications indicator
prev = prev[:-1]
self.working_rev = prev
return self.working_rev
return 'p1(%s)' % (rev, )
def getFileTemplate(self, prefs, name):
return [ (name, self._getPreviousRevision(prefs, None)), (name, None) ]
def _getCommitTemplate(self, prefs, names, cmd, rev):
# build command
args = [ prefs.getString('hg_bin') ]
args.extend(cmd)
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
args.append(safeRelativePath(self.root, name, prefs, 'hg_cygwin'))
# run command
prev = self._getPreviousRevision(prefs, rev)
fs = _VcsFolderSet(names)
modified = {}
for s in popenReadLines(self.root, args, prefs, 'hg_bash'):
# parse response
if len(s) < 3 or s[0] not in 'AMR':
continue
k = os.path.join(self.root, prefs.convertToNativePath(s[2:]))
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
if s[0] == 'R':
# removed
modified[k] = [ (k, prev), (None, None) ]
elif s[0] == 'A':
# added
modified[k] = [ (None, None), (k, rev) ]
else:
# modified or merge conflict
modified[k] = [ (k, prev), (k, rev) ]
# sort the results
return [ modified[k] for k in sorted(modified.keys()) ]
def getCommitTemplate(self, prefs, rev, names):
return self._getCommitTemplate(prefs, names, [ 'log', '--template', 'A\t{file_adds}\nM\t{file_mods}\nR\t{file_dels}\n', '-r', rev ], rev)
def getFolderTemplate(self, prefs, names):
return self._getCommitTemplate(prefs, names, [ 'status', '-q' ], None)
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('hg_bin'), 'cat', '-r', rev, safeRelativePath(self.root, name, prefs, 'hg_cygwin') ], prefs, 'hg_bash')
def _get_hg_repo(path, prefs):
p = _find_parent_dir_with(path, '.hg')
if p:
return _Hg(p)
# Monotone support
class _Mtn:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
# FIXME: merge conflicts?
return [ (name, 'h:'), (name, None) ]
def getCommitTemplate(self, prefs, rev, names):
# build command
vcs_bin = prefs.getString('mtn_bin')
ss = popenReadLines(self.root, [ vcs_bin, 'automate', 'select', '-q', rev ], prefs, 'mtn_bash')
if len(ss) != 1:
raise IOError('Ambiguous revision specifier')
args = [ vcs_bin, 'automate', 'get_revision', ss[0] ]
# build list of interesting files
fs = _VcsFolderSet(names)
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
# run command
prev = None
removed, added, modified, renamed = {}, {}, {}, {}
ss = popenReadLines(self.root, args, prefs, 'mtn_bash')
i = 0
while i < len(ss):
# process results
s = shlex.split(ss[i])
i += 1
if len(s) < 2:
continue
arg, arg1 = s[0], s[1]
if arg == 'old_revision' and len(arg1) > 2:
if prev is not None:
break
prev = arg1[1:-1]
continue
elif prev is None:
continue
if arg == 'delete':
# deleted file
k = os.path.join(self.root, prefs.convertToNativePath(arg1))
if fs.contains(k):
removed[arg1] = k
elif arg == 'add_file':
# new file
k = os.path.join(self.root, prefs.convertToNativePath(arg1))
if fs.contains(k):
added[arg1] = k
elif arg == 'patch':
# modified file
k = os.path.join(self.root, prefs.convertToNativePath(arg1))
if fs.contains(k):
modified[arg1] = k
elif arg == 'rename':
s = shlex.split(ss[i])
i += 1
if len(s) > 1 and s[0] == 'to':
# renamed file
k0 = os.path.join(self.root, prefs.convertToNativePath(arg1))
k1 = os.path.join(self.root, prefs.convertToNativePath(s[1]))
if fs.contains(k0) or fs.contains(k1):
renamed[s[1]] = (arg1, k0, k1)
if removed or renamed:
# remove directories
removed_dirs = set()
for s in popenReadLines(self.root, [ vcs_bin, 'automate', 'get_manifest_of', prev ], prefs, 'mtn_bash'):
s = shlex.split(s)
if len(s) > 1 and s[0] == 'dir':
removed_dirs.add(s[1])
for k in removed_dirs:
for m in removed, modified:
if k in m:
del m[k]
for k, v in renamed.items():
arg1, k0, k1 = v
if arg1 in removed_dirs:
del renamed[k]
# sort results
result, r = [], set()
for m in removed, added, modified, renamed:
r.update(m)
for k in sorted(r):
if k in removed:
k = removed[k]
if not isabs:
k = relpath(pwd, k)
result.append([ (k, prev), (None, None) ])
elif k in added:
k = added[k]
if not isabs:
k = relpath(pwd, k)
result.append([ (None, None), (k, rev) ])
else:
if k in renamed:
arg1, k0, k1 = renamed[k]
else:
k0 = k1 = modified[k]
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
result.append([ (k0, prev), (k1, rev) ])
return result
def getFolderTemplate(self, prefs, names):
fs = _VcsFolderSet(names)
result = []
pwd, isabs = os.path.abspath(os.curdir), False
args = [ prefs.getString('mtn_bin'), 'automate', 'inventory', '--no-ignored', '--no-unchanged', '--no-unknown' ]
for name in names:
isabs |= os.path.isabs(name)
# build list of interesting files
prev = 'h:'
ss = popenReadLines(self.root, args, prefs, 'mtn_bash')
removed, added, modified, renamed = {}, {}, {}, {}
i = 0
while i < len(ss):
# parse properties
m = {}
while i < len(ss):
s = ss[i]
i += 1
# properties are terminated by a blank line
s = shlex.split(s)
if len(s) == 0:
break
m[s[0]] = s[1:]
# scan the list of properties for files that interest us
if len(m.get('path', [])) > 0:
p, s, processed = m['path'][0], m.get('status', []), False
if 'dropped' in s and 'file' in m.get('old_type', []):
# deleted file
k = os.path.join(self.root, prefs.convertToNativePath(p))
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
processed = True
if 'added' in s and 'file' in m.get('new_type', []):
# new file
k = os.path.join(self.root, prefs.convertToNativePath(p))
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, None) ]
processed = True
if 'rename_target' in s and 'file' in m.get('new_type', []) and len(m.get('old_path', [])) > 0:
# renamed file
k0 = os.path.join(self.root, prefs.convertToNativePath(m['old_path'][0]))
k1 = os.path.join(self.root, prefs.convertToNativePath(p))
if fs.contains(k0) or fs.contains(k1):
if not isabs:
k0 = relpath(pwd, k0)
k1 = relpath(pwd, k1)
renamed[k1] = [ (k0, prev), (k1, None) ]
processed = True
if not processed and 'file' in m.get('fs_type', []):
# modified file or merge conflict
k = os.path.join(self.root, prefs.convertToNativePath(p))
if fs.contains(k):
if not isabs:
k = relpath(pwd, k)
modified[k] = [ (k, prev), (k, None) ]
# sort the results
r = set()
for m in removed, added, modified, renamed:
r.update(m.keys())
for k in sorted(r):
for m in removed, added, modified, renamed:
if k in m:
result.append(m[k])
return result
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('mtn_bin'), 'automate', 'get_file_of', '-q', '-r', rev, safeRelativePath(self.root, name, prefs, 'mtn_cygwin') ], prefs, 'mtn_bash')
def _get_mtn_repo(path, prefs):
p = _find_parent_dir_with(path, '_MTN')
if p:
return _Mtn(p)
# RCS support
class _Rcs:
def __init__(self, root):
self.root = root
def getFileTemplate(self, prefs, name):
args = [ prefs.getString('rcs_bin_rlog'), '-L', '-h', safeRelativePath(self.root, name, prefs, 'rcs_cygwin') ]
rev = ''
for line in popenReadLines(self.root, args, prefs, 'rcs_bash'):
if line.startswith('head: '):
rev = line[6:]
return [ (name, rev), (name, None) ]
def getCommitTemplate(self, prefs, rev, names):
result = []
try:
r, prev = rev.split('.'), None
if len(r) > 1:
m = int(r.pop())
if m > 1:
r.append(str(m - 1))
else:
m = int(r.pop())
if len(r):
prev = '.'.join(r)
for k in sorted(names):
if prev is None:
k0 = None
else:
k0 = k
result.append([ (k0, prev), (k, rev) ])
except ValueError:
logError(_('Error parsing revision %s.') % (rev, ))
return result
def getFolderTemplate(self, prefs, names):
# build command
cmd = [ prefs.getString('rcs_bin_rlog'), '-L', '-h' ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
r = []
for k in names:
if os.path.isdir(k):
# the user specified a folder
n, ex = [ k ], True
while len(n) > 0:
s = n.pop()
recurse = os.path.isdir(os.path.join(s, 'RCS'))
if ex or recurse:
ex = False
for v in os.listdir(s):
dn = os.path.join(s, v)
if v.endswith(',v') and os.path.isfile(dn):
# map to checkout name
r.append(dn[:-2])
elif v == 'RCS' and os.path.isdir(dn):
for v in os.listdir(dn):
if os.path.isfile(os.path.join(dn, v)):
if v.endswith(',v'):
v = v[:-2]
r.append(os.path.join(s, v))
elif recurse and os.path.isdir(dn) and not os.path.islink(dn):
n.append(dn)
else:
# the user specified a file
s = k + ',v'
if os.path.isfile(s):
r.append(k)
continue
s = k.split(os.sep)
s.insert(-1, 'RCS')
# old-style RCS repository
if os.path.isfile(os.sep.join(s)):
r.append(k)
continue
# new-style RCS repository
s[-1] += ',v'
if os.path.isfile(os.sep.join(s)):
r.append(k)
for k in r:
isabs |= os.path.isabs(k)
args = [ safeRelativePath(self.root, k, prefs, 'rcs_cygwin') for k in r ]
# run command
r, k = {}, ''
for line in popenXArgsReadLines(self.root, cmd, args, prefs, 'rcs_bash'):
# parse response
if line.startswith('Working file: '):
k = prefs.convertToNativePath(line[14:])
k = os.path.join(self.root, os.path.normpath(k))
if not isabs:
k = relpath(pwd, k)
elif line.startswith('head: '):
r[k] = line[6:]
# sort the results
return [ [ (k, r[k]), (k, None) ] for k in sorted(r.keys()) ]
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('rcs_bin_co'), '-p', '-q', '-r' + rev, safeRelativePath(self.root, name, prefs, 'rcs_cygwin') ], prefs, 'rcs_bash')
def _get_rcs_repo(path, prefs):
try:
for s in os.listdir(path):
if s.endswith(',v') and os.path.isfile(os.path.join(path, s)):
return _Rcs(path)
except OSError:
# the user specified an invalid folder name
pass
# Subversion support
# SVK support subclasses from this
class _Svn:
def __init__(self, root):
self.root = root
self.url = None
def _getVcs(self):
return 'svn'
def _getURLPrefix(self):
return 'URL: '
def _parseStatusLine(self, s):
if len(s) < 8 or s[0] not in 'ACDMR':
return
# subversion 1.6 adds a new column
k = 7
if k < len(s) and s[k] == ' ':
k += 1
return s[0], s[k:]
def _getPreviousRevision(self, rev):
if rev is None:
return 'BASE'
m = int(rev)
if m > 1:
return str(m - 1)
def _getURL(self, prefs):
if self.url is None:
vcs, prefix = self._getVcs(), self._getURLPrefix()
n = len(prefix)
args = [ prefs.getString(vcs + '_bin'), 'info' ]
for s in popenReadLines(self.root, args, prefs, vcs + '_bash'):
if s.startswith(prefix):
self.url = s[n:]
break
return self.url
def getFileTemplate(self, prefs, name):
# FIXME: verify this
# merge conflict
2016-07-09 03:11:43 +00:00
escaped_name = globEscape(name)
left = glob.glob(escaped_name + u'.merge-left.r*')
right = glob.glob(escaped_name + u'.merge-right.r*')
2015-11-21 17:57:32 +00:00
if len(left) > 0 and len(right) > 0:
return [ (left[-1], None), (name, None), (right[-1], None) ]
# update conflict
2016-07-09 03:11:43 +00:00
left = sorted(glob.glob(escaped_name + u'.r*'))
right = glob.glob(escaped_name + u'.mine')
right.extend(glob.glob(escaped_name + u'.working'))
2015-11-21 17:57:32 +00:00
if len(left) > 0 and len(right) > 0:
return [ (left[-1], None), (name, None), (right[0], None) ]
# default case
return [ (name, self._getPreviousRevision(None)), (name, None) ]
def _getCommitTemplate(self, prefs, rev, names):
result = []
try:
prev = self._getPreviousRevision(rev)
2016-07-03 17:14:12 +00:00
except ValueError as e:
2015-11-21 17:57:32 +00:00
logError(_('Error parsing revision %s.') % (rev, ))
return result
# build command
vcs = self._getVcs()
vcs_bin, vcs_bash = prefs.getString(vcs + '_bin'), vcs + '_bash'
if rev is None:
args = [ vcs_bin, 'status', '-q' ]
else:
args = [ vcs_bin, 'diff', '--summarize', '-c', rev ]
# build list of interesting files
pwd, isabs = os.path.abspath(os.curdir), False
for name in names:
isabs |= os.path.isabs(name)
if rev is None:
args.append(safeRelativePath(self.root, name, prefs, vcs + '_cygwin'))
# run command
fs = _VcsFolderSet(names)
modified, added, removed = {}, set(), set()
for s in popenReadLines(self.root, args, prefs, vcs_bash):
status = self._parseStatusLine(s)
if status is None:
continue
v, k = status
rel = prefs.convertToNativePath(k)
k = os.path.join(self.root, rel)
if fs.contains(k):
if v == 'D':
# deleted file or directory
# the contents of deleted folders are not reported
# by "svn diff --summarize -c <rev>"
removed.add(rel)
elif v == 'A':
# new file or directory
added.add(rel)
elif v == 'M':
# modified file or merge conflict
k = os.path.join(self.root, k)
if not isabs:
k = relpath(pwd, k)
modified[k] = [ (k, prev), (k, rev) ]
elif v == 'C':
# merge conflict
modified[k] = self.getFileTemplate(prefs, k)
elif v == 'R':
# replaced file
removed.add(rel)
added.add(rel)
# look for files in the added items
if rev is None:
m, added = added, {}
for k in m:
if not os.path.isdir(k):
# confirmed as added file
k = os.path.join(self.root, k)
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, None) ]
else:
m = {}
for k in added:
d, b = os.path.dirname(k), os.path.basename(k)
if d not in m:
m[d] = set()
m[d].add(b)
# remove items we can easily determine to be directories
for k in m.keys():
d = os.path.dirname(k)
if d in m:
m[d].discard(os.path.basename(k))
if not m[d]:
del m[d]
# determine which are directories
added = {}
for p, v in m.items():
for s in popenReadLines(self.root, [ vcs_bin, 'list', '-r', rev, '%s/%s' % (self._getURL(prefs), p.replace(os.sep, '/')) ], prefs, vcs_bash):
if s in v:
# confirmed as added file
k = os.path.join(self.root, os.path.join(p, s))
if not isabs:
k = relpath(pwd, k)
added[k] = [ (None, None), (k, rev) ]
# determine if removed items are files or directories
if prev == 'BASE':
m, removed = removed, {}
for k in m:
if not os.path.isdir(k):
# confirmed item as file
k = os.path.join(self.root, k)
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
else:
m = {}
for k in removed:
d, b = os.path.dirname(k), os.path.basename(k)
if d not in m:
m[d] = set()
m[d].add(b)
removed_dir, removed = set(), {}
for p, v in m.items():
for s in popenReadLines(self.root, [ vcs_bin, 'list', '-r', prev, '%s/%s' % (self._getURL(prefs), p.replace(os.sep, '/')) ], prefs, vcs_bash):
if s.endswith('/'):
s = s[:-1]
if s in v:
# confirmed item as directory
removed_dir.add(os.path.join(p, s))
else:
if s in v:
# confirmed item as file
k = os.path.join(self.root, os.path.join(p, s))
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
# recursively find all unreported removed files
while removed_dir:
tmp = removed_dir
removed_dir = set()
for p in tmp:
for s in popenReadLines(self.root, [ vcs_bin, 'list', '-r', prev, '%s/%s' % (self._getURL(prefs), p.replace(os.sep, '/')) ], prefs, vcs_bash):
if s.endswith('/'):
# confirmed item as directory
removed_dir.add(os.path.join(p, s[:-1]))
else:
# confirmed item as file
k = os.path.join(self.root, os.path.join(p, s))
if not isabs:
k = relpath(pwd, k)
removed[k] = [ (k, prev), (None, None) ]
# sort the results
r = set()
for m in removed, added, modified:
r.update(m.keys())
for k in sorted(r):
for m in removed, added, modified:
if k in m:
result.append(m[k])
return result
def getCommitTemplate(self, prefs, rev, names):
return self._getCommitTemplate(prefs, rev, names)
def getFolderTemplate(self, prefs, names):
return self._getCommitTemplate(prefs, None, names)
def getRevision(self, prefs, name, rev):
vcs_bin = prefs.getString('svn_bin')
if rev in [ 'BASE', 'COMMITTED', 'PREV' ]:
return popenRead(self.root, [ vcs_bin, 'cat', '%s@%s' % (safeRelativePath(self.root, name, prefs, 'svn_cygwin'), rev) ], prefs, 'svn_bash')
return popenRead(self.root, [ vcs_bin, 'cat', '%s/%s@%s' % (self._getURL(prefs), relpath(self.root, os.path.abspath(name)).replace(os.sep, '/'), rev) ], prefs, 'svn_bash')
def _get_svn_repo(path, prefs):
p = _find_parent_dir_with(path, '.svn')
if p:
return _Svn(p)
class _Svk(_Svn):
def __init__(self, root):
_Svn.__init__(self, root)
def _getVcs(self):
return 'svk'
def _getURLPrefix(self):
return 'Depot Path: '
def _parseStatusLine(self, s):
if len(s) < 4 or s[0] not in 'ACDMR':
return
return s[0], s[4:]
def _getPreviousRevision(self, rev):
if rev is None:
return 'HEAD'
if rev.endswith('@'):
return str(int(rev[:-1]) - 1) + '@'
return str(int(rev) - 1)
def getRevision(self, prefs, name, rev):
return popenRead(self.root, [ prefs.getString('svk_bin'), 'cat', '-r', rev, '%s/%s' % (self._getURL(prefs), relpath(self.root, os.path.abspath(name)).replace(os.sep, '/')) ], prefs, 'svk_bash')
def _get_svk_repo(path, prefs):
name = path
# parse the ~/.svk/config file to discover which directories are part of
# SVK repositories
if isWindows():
name = name.upper()
svkroot = os.environ.get('SVKROOT', None)
if svkroot is None:
svkroot = os.path.expanduser('~/.svk')
svkconfig = os.path.join(svkroot, 'config')
if os.path.isfile(svkconfig):
try:
# find working copies by parsing the config file
f = open(svkconfig, 'r')
ss = readlines(f)
f.close()
projs, sep = [], 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
i = 0
while i < len(ss):
s = ss[i]
i += 1
if s.startswith(' hash: '):
while i < len(ss) and ss[i].startswith(' '):
s = ss[i]
i += 1
if s.endswith(': ') and i < len(ss) and ss[i].startswith(' depotpath: '):
key = s[4:-2].replace(sep, os.sep)
# parse directory path
j, n, tt = 0, len(key), []
while j < n:
if key[j] == '"':
# quoted string
j += 1
while j < n:
if key[j] == '"':
j += 1
break
elif key[j] == '\\':
# escaped character
j += 1
if j < n:
tt.append(key[j])
j += 1
else:
tt.append(key[j])
j += 1
key = ''.join(tt).replace(sep, os.sep)
if isWindows():
key = key.upper()
projs.append(key)
break
# check if the file belongs to one of the project directories
if _VcsFolderSet(projs).contains(name):
return _Svk(path)
except IOError:
logError(_('Error parsing %s.') % (config, ))
class VCSs:
def __init__(self):
# initialise the VCS objects
self._get_repo = { 'bzr': _get_bzr_repo, 'cvs': _get_cvs_repo, 'darcs': _get_darcs_repo, 'git': _get_git_repo, 'hg': _get_hg_repo, 'mtn': _get_mtn_repo, 'rcs': _get_rcs_repo, 'svk': _get_svk_repo, 'svn': _get_svn_repo }
def setSearchOrder(self, ordering):
self._search_order = ordering
# determines which VCS to use for files in the named folder
def findByFolder(self, path, prefs):
path = os.path.abspath(path)
for vcs in prefs.getString('vcs_search_order').split():
2016-07-03 17:14:12 +00:00
if vcs in self._get_repo:
2015-11-21 17:57:32 +00:00
repo = self._get_repo[vcs](path, prefs)
if repo:
return repo
# determines which VCS to use for the named file
def findByFilename(self, name, prefs):
if name is not None:
return self.findByFolder(os.path.dirname(name), prefs)
theVCSs = VCSs()
# utility method to step advance an adjustment
def step_adjustment(adj, delta):
v = adj.get_value() + delta
# clamp to the allowed range
2016-07-04 00:22:54 +00:00
v = max(v, int(adj.get_lower()))
v = min(v, int(adj.get_upper() - adj.get_page_size()))
2015-11-21 17:57:32 +00:00
adj.set_value(v)
2016-07-09 21:38:29 +00:00
# This is a replacement for Gtk.ScrolledWindow as it forced expose events to be
2015-11-21 17:57:32 +00:00
# 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.
2016-07-09 21:38:29 +00:00
class ScrolledWindow(Gtk.Table):
scroll_directions = set((Gdk.ScrollDirection.UP,
Gdk.ScrollDirection.DOWN,
Gdk.ScrollDirection.LEFT,
Gdk.ScrollDirection.RIGHT))
2015-11-21 17:57:32 +00:00
def __init__(self, hadj, vadj):
2016-07-09 21:38:29 +00:00
Gtk.Table.__init__(self, 2, 2)
2015-11-21 17:57:32 +00:00
self.position = (0, 0)
self.scroll_count = 0
self.partial_redraw = False
self.hadj, self.vadj = hadj, vadj
2016-07-09 21:38:29 +00:00
vport = Gtk.Viewport.new()
darea = Gtk.DrawingArea.new()
darea.add_events(Gdk.EventMask.SCROLL_MASK)
2015-11-21 17:57:32 +00:00
self.darea = darea
# replace darea's queue_draw_area with our own so we can tell when
# to disable/enable our scrolling optimisation
self.darea_queue_draw_area = darea.queue_draw_area
darea.queue_draw_area = self.redraw_region
vport.add(darea)
darea.show()
self.attach(vport, 0, 1, 0, 1)
vport.show()
2016-07-09 21:38:29 +00:00
self.vbar = bar = Gtk.VScrollbar.new(vadj)
self.attach(bar, 1, 2, 0, 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
bar.show()
2016-07-09 21:38:29 +00:00
self.hbar = bar = Gtk.HScrollbar.new(hadj)
self.attach(bar, 0, 1, 1, 2, Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
bar.show()
# listen to our signals
2016-07-05 03:00:18 +00:00
hadj.connect('value-changed', self.value_changed_cb)
vadj.connect('value-changed', self.value_changed_cb)
darea.connect('configure-event', self.configure_cb)
darea.connect('scroll-event', self.scroll_cb)
2016-07-04 00:22:54 +00:00
darea.connect('draw', self.draw_cb)
2015-11-21 17:57:32 +00:00
# updates the adjustments to match the new widget size
def configure_cb(self, widget, event):
w, h = event.width, event.height
for adj, d in (self.hadj, w), (self.vadj, h):
v = adj.get_value()
2016-07-04 00:22:54 +00:00
if v + d > adj.get_upper():
adj.set_value(max(0, adj.get_upper() - d))
adj.set_page_size(d)
adj.set_page_increment(d)
2015-11-21 17:57:32 +00:00
# update the vertical adjustment when the mouse's scroll wheel is used
def scroll_cb(self, widget, event):
d = event.direction
if d in self.scroll_directions:
delta = 100
2016-07-09 21:38:29 +00:00
if d in (Gdk.ScrollDirection.UP, Gdk.ScrollDirection.LEFT):
2015-11-21 17:57:32 +00:00
delta = -delta
2016-07-09 21:38:29 +00:00
vertical = (d in (Gdk.ScrollDirection.UP, Gdk.ScrollDirection.DOWN))
if event.state & Gdk.ModifierType.SHIFT_MASK:
2015-11-21 17:57:32 +00:00
vertical = not vertical
if vertical:
adj = self.vadj
else:
adj = self.hadj
step_adjustment(adj, delta)
def value_changed_cb(self, widget):
old_x, old_y = self.position
pos_x = int(self.hadj.get_value())
pos_y = int(self.vadj.get_value())
self.position = (pos_x, pos_y)
2016-07-09 21:38:29 +00:00
if self.darea.get_window() is not None:
2015-11-21 17:57:32 +00:00
# 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 and not self.partial_redraw:
self.scroll_count += 1
2016-07-09 21:38:29 +00:00
self.darea.get_window().scroll(old_x - pos_x, old_y - pos_y)
2015-11-21 17:57:32 +00:00
else:
self.partial_redraw = False
self.darea.queue_draw()
2016-07-04 00:22:54 +00:00
def draw_cb(self, widget, cr):
2015-11-21 17:57:32 +00:00
self.scroll_count = 0
# replacement for darea.queue_draw_area that notifies us when a partial
# redraw happened
def redraw_region(self, x, y, w, h):
self.partial_redraw = True
self.darea_queue_draw_area(x, y, w, h)
# 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
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
2016-07-09 21:38:29 +00:00
# use Pango.SCALE instead of Pango.PIXELS to avoid overflow exception
2015-11-21 17:57:32 +00:00
def pixels(size):
2016-07-09 21:38:29 +00:00
return int(size / Pango.SCALE + 0.5)
2015-11-21 17:57:32 +00:00
# constructs a full URL for the named file
def path2url(path, proto='file'):
r = [ proto, ':///' ]
s = os.path.abspath(path)
i = 0
while i < len(s) and s[i] == os.sep:
i += 1
for c in s[i:]:
if c == os.sep:
c = '/'
elif c == ':' and isWindows():
c = '|'
else:
v = ord(c)
if v <= 0x20 or v >= 0x7b or c in '$&+,/:;=?@"<>#%\\^[]`':
c = '%%%02X' % (v, )
r.append(c)
return ''.join(r)
# returns a path to a file named by the file URL 's'
def url2path(s):
n = len(s)
if n > 7 and s.startswith('file://'):
ss, i = [], 7
if isWindows():
if s[i] == '/':
i += 1
else:
# share
ss.append('//')
while i < n:
c = s[i]
i += 1
if c == '%':
v = 0
for j in 0, 1:
if i < n:
p = ord(s[i])
if p >= 48 and p <= 57:
p -= 48
elif p >= 65 and p <= 70:
p -= 55
elif p >= 97 and p <= 102:
p -= 87
else:
p = None
if p is not None:
v = 16 * v + p
i += 1
c = chr(v)
elif c == '|' and isWindows():
c = ':'
ss.append(c)
s = ''.join(ss)
return s.replace('/', os.sep)
# 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
# mapping to column width of a character (tab will never be in this map)
char_width_cache = {}
ALPHANUMERIC_CLASS = 0
WHITESPACE_CLASS = 1
OTHER_CLASS = 2
# maps similar types of characters to a group
def getCharacterClass(c):
if c.isalnum() or c == u'_':
return ALPHANUMERIC_CLASS
elif c.isspace():
return WHITESPACE_CLASS
return OTHER_CLASS
# longest common subsequence of unique elements common to 'a' and 'b'
def __patience_subsequence(a, b):
# value unique lines by their order in each list
value_a, value_b = {}, {}
# find unique values in 'a'
for i, s in enumerate(a):
if s in value_a:
value_a[s] = -1
else:
value_a[s] = i
# find unique values in 'b'
for i, s in enumerate(b):
if s in value_b:
value_b[s] = -1
else:
value_b[s] = i
# lay down items in 'b' as if playing patience if the item is unique in
# 'a' and 'b'
pile, pointers, atob = [], {}, {}
get, append = value_a.get, pile.append
for s in b:
v = get(s, -1)
if v != -1:
vb = value_b[s]
if vb != -1:
atob[v] = vb
# find appropriate pile for v
start, end = 0, len(pile)
# optimisation as values usually increase
if end and v > pile[-1]:
start = end
else:
while start < end:
mid = (start + end) // 2
if v < pile[mid]:
end = mid
else:
start = mid + 1
if start < len(pile):
pile[start] = v
else:
append(v)
if start:
pointers[v] = pile[start-1]
# examine our piles to determine the longest common subsequence
result = []
if pile:
v, append = pile[-1], result.append
append((v, atob[v]))
while v in pointers:
v = pointers[v]
append((v, atob[v]))
result.reverse()
return result
# difflib-style approximation of the longest common subsequence
def __lcs_approx(a, b):
count1, lookup = {}, {}
# count occurances of each element in 'a'
for s in a:
count1[s] = count1.get(s, 0) + 1
# construct a mapping from a element to where it can be found in 'b'
for i, s in enumerate(b):
if s in lookup:
lookup[s].append(i)
else:
lookup[s] = [ i ]
if set(lookup).intersection(count1):
# we have some common elements
# identify popular entries
popular = {}
n = len(a)
if n > 200:
for k, v in count1.items():
if 100 * v > n:
popular[k] = 1
n = len(b)
if n > 200:
for k, v in lookup.items():
if 100 * len(v) > n:
popular[k] = 1
# while walk through entries in 'a', incrementally update the list of
# matching subsequences in 'b' and keep track of the longest match
# found
prev_matches, matches, max_length, max_indices = {}, {}, 0, []
for ai, s in enumerate(a):
if s in lookup:
if s in popular:
# we only extend existing previously found matches to avoid
# performance issues
for bi in prev_matches:
if bi + 1 < n and b[bi + 1] == s:
matches[bi] = v = prev_matches[bi] + 1
# check if this is now the longest match
if v >= max_length:
if v == max_length:
max_indices.append((ai, bi))
else:
max_length = v
max_indices = [ (ai, bi) ]
else:
prev_get = prev_matches.get
for bi in lookup[s]:
matches[bi] = v = prev_get(bi - 1, 0) + 1
# check if this is now the longest match
if v >= max_length:
if v == max_length:
max_indices.append((ai, bi))
else:
max_length = v
max_indices = [ (ai, bi) ]
prev_matches, matches = matches, {}
if max_indices:
# include any popular entries at the beginning
aidx, bidx, nidx = 0, 0, 0
for ai, bi in max_indices:
n = max_length
ai += 1 - n
bi += 1 - n
while ai and bi and a[ai - 1] == b[bi - 1]:
ai -= 1
bi -= 1
n += 1
if n > nidx:
aidx, bidx, nidx = ai, bi, n
return aidx, bidx, nidx
# patinence diff with difflib-style fallback
def patience_diff(a, b):
matches, len_a, len_b = [], len(a), len(b)
if len_a and len_b:
blocks = [ (0, len_a, 0, len_b, 0) ]
while blocks:
start_a, end_a, start_b, end_b, match_idx = blocks.pop()
aa, bb = a[start_a:end_a], b[start_b:end_b]
# try patience
pivots = __patience_subsequence(aa, bb)
if pivots:
offset_a, offset_b = start_a, start_b
for pivot_a, pivot_b in pivots:
pivot_a += offset_a
pivot_b += offset_b
if start_a <= pivot_a:
# extend before
idx_a, idx_b = pivot_a, pivot_b
while start_a < idx_a and start_b < idx_b and a[idx_a - 1] == b[idx_b - 1]:
idx_a -= 1
idx_b -= 1
# if anything is before recurse on the section
if start_a < idx_a and start_b < idx_b:
blocks.append((start_a, idx_a, start_b, idx_b, match_idx))
# extend after
start_a, start_b = pivot_a + 1, pivot_b + 1
while start_a < end_a and start_b < end_b and a[start_a] == b[start_b]:
start_a += 1
start_b += 1
# record match
matches.insert(match_idx, (idx_a, idx_b, start_a - idx_a))
match_idx += 1
# if anything is after recurse on the section
if start_a < end_a and start_b < end_b:
blocks.append((start_a, end_a, start_b, end_b, match_idx))
else:
# fallback if patience fails
pivots = __lcs_approx(aa, bb)
if pivots:
idx_a, idx_b, n = pivots
idx_a += start_a
idx_b += start_b
# if anything is before recurse on the section
if start_a < idx_a and start_b < idx_b:
blocks.append((start_a, idx_a, start_b, idx_b, match_idx))
# record match
matches.insert(match_idx, (idx_a, idx_b, n))
match_idx += 1
idx_a += n
idx_b += n
# if anything is after recurse on the section
if idx_a < end_a and idx_b < end_b:
blocks.append((idx_a, end_a, idx_b, end_b, match_idx))
# try matching from begining to first match block
if matches:
end_a, end_b = matches[0][:2]
else:
end_a, end_b = len_a, len_b
i = 0
while i < end_a and i < end_b and a[i] == b[i]:
i += 1
if i:
matches.insert(0, (0, 0, i))
# try matching from last match block to end
if matches:
start_a, start_b, n = matches[-1]
start_a += n
start_b += n
else:
start_a, start_b = 0, 0
end_a, end_b = len_a, len_b
while start_a < end_a and start_b < end_b and a[end_a - 1] == b[end_b - 1]:
end_a -= 1
end_b -= 1
if end_a < len_a:
matches.append((end_a, end_b, len_a - end_a))
# add a zero length block to the end
matches.append((len_a, len_b, 0))
return matches
# widget used to compare and merge text files
2016-07-09 21:38:29 +00:00
class FileDiffViewer(Gtk.Table):
2015-11-21 17:57:32 +00:00
# class describing a text pane
class Pane:
def __init__(self):
# list of lines displayed in this pane (including spacing lines)
self.lines = []
2016-07-09 21:38:29 +00:00
# high water mark for line length in Pango units (used to determine
2015-11-21 17:57:32 +00:00
# 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
# number of lines with edits
self.num_edits = 0
# 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):
# verify we have a valid number of panes
if n < 2:
raise ValueError('Invalid number of panes')
2016-07-09 21:38:29 +00:00
Gtk.Table.__init__(self, 3, n + 1)
2016-07-04 00:22:54 +00:00
self.set_can_focus(True)
2015-11-21 17:57:32 +00:00
self.prefs = prefs
self.string_width_cache = {}
self.options = {}
# diff blocks
self.blocks = []
# 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,
'clear_edits': self.clear_edits,
'isolate': self.isolate,
'first_difference': self.first_difference,
'previous_difference': self.previous_difference,
'next_difference': self.next_difference,
'last_difference': self.last_difference,
'copy_selection_right': self.copy_selection_right,
'copy_selection_left': self.copy_selection_left,
'copy_left_into_selection': self.copy_left_into_selection,
'copy_right_into_selection': self.copy_right_into_selection,
'merge_from_left_then_right': self.merge_from_left_then_right,
'merge_from_right_then_left': self.merge_from_right_then_left }
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 }
self._button_actions = {
'undo': self.undo,
'redo': self.redo,
'cut': self.cut,
'copy': self.copy,
'paste': self.paste,
'select_all': self.select_all,
'clear_edits': self.clear_edits,
'dismiss_all_edits': self.dismiss_all_edits,
'realign_all': self.realign_all,
'isolate': self.isolate,
'first_difference': self.first_difference,
'previous_difference': self.previous_difference,
'next_difference': self.next_difference,
'last_difference': self.last_difference,
'shift_pane_right': self.shift_pane_right,
'shift_pane_left': self.shift_pane_left,
'convert_to_upper_case': self.convert_to_upper_case,
'convert_to_lower_case': self.convert_to_lower_case,
'sort_lines_in_ascending_order': self.sort_lines_in_ascending_order,
'sort_lines_in_descending_order': self.sort_lines_in_descending_order,
'remove_trailing_white_space': self.remove_trailing_white_space,
'convert_tabs_to_spaces': self.convert_tabs_to_spaces,
'convert_leading_spaces_to_tabs': self.convert_leading_spaces_to_tabs,
'increase_indenting': self.increase_indenting,
'decrease_indenting': self.decrease_indenting,
'convert_to_dos': self.convert_to_dos,
'convert_to_mac': self.convert_to_mac,
'convert_to_unix': self.convert_to_unix,
'copy_selection_right': self.copy_selection_right,
'copy_selection_left': self.copy_selection_left,
'copy_left_into_selection': self.copy_left_into_selection,
'copy_right_into_selection': self.copy_right_into_selection,
'merge_from_left_then_right': self.merge_from_left_then_right,
'merge_from_right_then_left': self.merge_from_right_then_left }
# create panes
self.dareas = []
self.panes = []
2016-07-09 21:38:29 +00:00
self.hadj = Gtk.Adjustment.new(0, 0, 0, 0, 0, 0)
self.vadj = Gtk.Adjustment.new(0, 0, 0, 0, 0, 0)
2015-11-21 17:57:32 +00:00
for i in range(n):
pane = FileDiffViewer.Pane()
self.panes.append(pane)
# pane contents
sw = ScrolledWindow(self.hadj, self.vadj)
darea = sw.darea
2016-07-09 21:38:29 +00:00
darea.add_events(Gdk.EventMask.BUTTON_PRESS_MASK |
Gdk.EventMask.BUTTON1_MOTION_MASK)
2016-07-05 03:00:18 +00:00
darea.connect('button-press-event', self.darea_button_press_cb, i)
darea.connect('motion-notify-event', self.darea_motion_notify_cb, i)
2016-07-04 00:22:54 +00:00
darea.connect('draw', self.darea_draw_cb, i)
2015-11-21 17:57:32 +00:00
self.dareas.append(darea)
self.attach(sw, i, i + 1, 1, 2)
sw.show()
2016-07-05 03:00:18 +00:00
self.hadj.connect('value-changed', self.hadj_changed_cb)
self.vadj.connect('value-changed', self.vadj_changed_cb)
2015-11-21 17:57:32 +00:00
# add diff map
2016-07-09 21:38:29 +00:00
self.map = map = Gtk.DrawingArea.new()
map.add_events(Gdk.EventMask.BUTTON_PRESS_MASK |
Gdk.EventMask.BUTTON1_MOTION_MASK |
Gdk.EventMask.SCROLL_MASK)
2016-07-05 03:00:18 +00:00
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)
2016-07-04 00:22:54 +00:00
map.connect('draw', self.map_draw_cb)
2016-07-09 21:38:29 +00:00
self.attach(map, n, n + 1, 1, 2, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
map.show()
map.set_size_request(16 * n, 0)
2016-07-09 21:38:29 +00:00
self.add_events(Gdk.EventMask.KEY_PRESS_MASK |
Gdk.EventMask.FOCUS_CHANGE_MASK)
2016-07-05 03:00:18 +00:00
self.connect('focus-in-event', self.focus_in_cb)
self.connect('focus-out-event', self.focus_out_cb)
self.connect('key-press-event', self.key_press_cb)
2015-11-21 17:57:32 +00:00
# input method
2016-07-09 21:38:29 +00:00
self.im_context = im = Gtk.IMMulticontext.new()
2015-11-21 17:57:32 +00:00
im.connect('commit', self.im_commit_cb)
2016-07-05 03:00:18 +00:00
im.connect('preedit-changed', self.im_preedit_changed_cb)
im.connect('retrieve-surrounding', self.im_retrieve_surrounding_cb)
2015-11-21 17:57:32 +00:00
self.im_preedit = None
# Cache our keyboard focus state so we know when to change the
# input method's focus. We ensure the input method is given focus
# if and only if (self.has_focus and self.mode == CHAR_MODE).
self.has_focus = False
# font
2016-07-09 21:38:29 +00:00
self.setFont(Pango.FontDescription.from_string(prefs.getString('display_font')))
2015-11-21 17:57:32 +00:00
self.cursor_pos = (0, 0)
# scroll to first difference when realised
darea.connect_after('realize', self._realise_cb)
# 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):
2016-07-09 21:38:29 +00:00
self.im_context.set_client_window(self.get_window())
2015-11-21 17:57:32 +00:00
try:
self.go_to_line(self.options['line'])
except KeyError:
self.first_difference()
# callback for most menu items and buttons
def button_cb(self, widget, data):
self.openUndoBlock()
self._button_actions[data]()
self.closeUndoBlock()
# set startup options
def setOptions(self, options):
self.options = options
# 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
# This is an inline loop over self.characterWidth() for performance reasons.
def stringWidth(self, s):
if not self.prefs.getBool('display_show_whitespace'):
s = strip_eol(s)
col = 0
for c in s:
try:
w = char_width_cache[c]
except KeyError:
v = ord(c)
if v < 32:
if c == '\t':
tab_width = self.prefs.getInt('display_tab_width')
w = tab_width - col % tab_width
elif c == '\n':
w = 1
char_width_cache[c] = w
else:
w = 2
char_width_cache[c] = w
else:
if unicodedata.east_asian_width(c) in 'WF':
w = 2
else:
w = 1
char_width_cache[c] = w
col += w
return col
# returns the 'column width' for a single character created at column 'i'
def characterWidth(self, i, c):
try:
return char_width_cache[c]
except KeyError:
v = ord(c)
if v < 32:
if c == '\t':
tab_width = self.prefs.getInt('display_tab_width')
return tab_width - i % tab_width
elif c == '\n':
w = 1
else:
w = 2
elif unicodedata.east_asian_width(c) in 'WF':
w = 2
else:
w = 1
char_width_cache[c] = w
return w
# translates a string into an array of the printable representation for
# each character
def expand(self, s):
visible = self.prefs.getBool('display_show_whitespace')
if not visible:
s = strip_eol(s)
tab_width = self.prefs.getInt('display_tab_width')
col = 0
result = []
for c in s:
v = ord(c)
if v <= 32:
if c == ' ':
if visible:
# show spaces using a centre-dot
result.append(u'\u00b7');
else:
result.append(c);
elif c == '\t':
width = tab_width - col % tab_width
if visible:
# show tabs using a right double angle quote
result.append(u'\u00bb' + (width - 1) * u' ')
else:
result.append(width * u' ')
elif c == '\n' and visible:
# show newlines using a pilcrow
result.append(u'\u00b6')
else:
# prefix with a ^
result.append(u'^' + 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 != LINE_MODE:
if self.mode == CHAR_MODE:
self._im_focus_out()
self.im_context.reset()
self._im_set_preedit(None)
self.current_char = 0
self.selection_char = 0
self.dareas[self.current_pane].queue_draw()
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.emit('cursor_changed')
self.emit('mode_changed')
# changes the viewer's mode to CHAR_MODE
def setCharMode(self):
if self.mode != CHAR_MODE:
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._im_focus_in()
self.im_context.reset()
self.mode = CHAR_MODE
self.emit('cursor_changed')
self.emit('mode_changed')
# sets the syntax hightlighting rules
def setSyntax(self, s):
if self.syntax is not s:
self.syntax = s
# invalidate the syntax caches
for pane in self.panes:
pane.syntax_cache = []
self.emit('syntax_changed', s)
# force all panes to redraw
for darea in self.dareas:
darea.queue_draw()
# gets the syntax
def getSyntax(self):
return self.syntax
# returns True if any pane contains edits
def hasEdits(self):
for pane in self.panes:
if pane.num_edits > 0:
return True
return False
# 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' action
def undo(self):
self.undoblock, old_block = None, self.undoblock
if self.mode == CHAR_MODE:
# avoid implicit preedit commit when an undo changes the mode
self.im_context.reset()
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)
self.undoblock = old_block
# 'redo' action
def redo(self):
self.undoblock, old_block = None, self.undoblock
if self.mode == LINE_MODE or self.mode == CHAR_MODE:
if self.mode == CHAR_MODE:
# avoid implicit preedit commit when an redo changes the mode
self.im_context.reset()
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)
self.undoblock = old_block
2016-07-09 21:38:29 +00:00
# returns the width of the viewport's line number column in Pango units
2015-11-21 17:57:32 +00:00
def getLineNumberWidth(self):
# find the maximum number of digits for a line number from all panes
n = 0
if self.prefs.getBool('display_show_line_numbers'):
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
n = (n + 2) * self.digit_width
return n
2016-07-09 21:38:29 +00:00
# returns the width of a string in Pango units
2015-11-21 17:57:32 +00:00
def getTextWidth(self, text):
if len(text) == 0:
return 0
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):
digit_width, stringWidth = self.digit_width, self.stringWidth
string_width_cache = self.string_width_cache
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:
w = string_width_cache.get(s, None)
if w is None:
string_width_cache[s] = w = stringWidth(s)
pane.line_lengths = max(pane.line_lengths, digit_width * w)
# 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)
# account for any preedit text
if self.im_preedit is not None:
w = self._preedit_layout().get_size()[0]
s = self.getLineText(self.current_pane, self.current_line)
if s is not None:
w += digit_width * stringWidth(s)
line_lengths = max(line_lengths, w)
# 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() + digit_width + line_lengths
width = pixels(width)
height = self.font_height * num_lines
# update the adjustments
2016-07-04 00:22:54 +00:00
self.hadj.set_upper(width)
2015-11-21 17:57:32 +00:00
self.hadj.step_increment = self.font_height
2016-07-04 00:22:54 +00:00
self.vadj.set_upper(height)
2015-11-21 17:57:32 +00:00
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
self.emit('format_changed', f, 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))
old_num_edits = pane.num_edits
if is_modified and not line.is_modified:
pane.num_edits += 1
elif not is_modified and line.is_modified:
pane.num_edits -= 1
if pane.num_edits != old_num_edits:
self.emit('num_edits_changed', f)
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.stringWidth(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
self._queue_draw_lines(fn, i)
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.map_cache = None
self.map.queue_draw()
# Undo for alignment changes
class AlignmentChangeUndo:
def __init__(self, finished):
self.data = finished
def undo(self, viewer):
finished = self.data
viewer.alignmentChange(not finished)
def redo(self, viewer):
finished = self.data
viewer.alignmentChange(finished)
# update viewer in response to alignment changes
def alignmentChange(self, finished):
if self.undoblock is not None:
# create an Undo object for the action
self.addUndo(FileDiffViewer.AlignmentChangeUndo(finished))
if finished:
self.updateSize(False)
# updates the alignment of 'n' lines starting from 'i'
def updateAlignment(self, i, n, lines):
self.alignmentChange(False)
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)
self.alignmentChange(True)
# 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()
# remove a line
def removeSpacerLines(self, i, n, skip=-1):
npanes, removed = len(self.panes), []
for j in range(i, i + n):
for f in range(npanes):
line = self.getLine(f, j)
if line is not None:
2016-07-05 02:30:57 +00:00
if line.line_number is not None or (f != skip and line.getText() is not None):
2015-11-21 17:57:32 +00:00
break
else:
# remove line
for f in range(npanes):
line = self.getLine(f, j)
if line is not None:
# create undo to record any edits to the line before
# removing it
self.updateLineText(f, j, False, None)
# remove the line so it doesn't persist as a spacer
self.instanceLine(f, j, True)
removed.append(j)
nremoved = len(removed)
if nremoved > 0:
# update blocks
bi, bii, blocks = 0, 0, self.blocks[:]
for j in removed:
while bii + blocks[bi] < j:
bii += blocks[bi]
bi += 1
if blocks[bi] == 1:
del blocks[bi]
else:
blocks[bi] -= 1
bii += 1
self.updateBlocks(blocks)
self.alignmentChange(False)
removed.reverse()
for j in removed:
for f in range(npanes):
self.insertNull(f, j, True)
# 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, n - nremoved)
self.alignmentChange(True)
# queue redraws
self.updateSize(False)
self.map_cache = None
self.map.queue_draw()
return nremoved
# 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
old_num_edits = pane.num_edits
pane.num_edits = 0
for line in new_lines:
if line is not None and line.is_modified:
pane.num_edits += 1
if pane.num_edits != old_num_edits:
self.emit('num_edits_changed', f)
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 None
pref = self.prefs.getBool
if pref('align_ignore_whitespace'):
# strip all white space from the string
for c in whitespace:
text = text.replace(c, '')
else:
# hashes for non-null lines should start with '+' to distinguish
# them from blank lines
if pref('align_ignore_endofline'):
text = strip_eol(text)
if pref('align_ignore_blanklines') and isBlank(text):
# consider all lines containing only white space as the same
return ''
if pref('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 pref('align_ignore_case'):
# convert everything to upper case
text = text.upper()
return text
# align sets of lines by inserting 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
a = self._alignmentHash
t1 = [ a(s) for s in s1 ]
t2 = [ a(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 patience_diff(t1, t2):
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 replaceContents(self, f, ss):
self.alignmentChange(False)
# 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
Line = FileDiffViewer.Line
mid = [ [ 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
insertNull, getLine = self.insertNull, self.getLine
for f_idx in range(len(self.panes)):
if f_idx != f:
for j in range(old_n-1, -1, -1):
if getLine(f_idx, j) is None:
insertNull(f_idx, j, True)
temp = mid[f_idx]
for j in range(new_n):
if temp[j] is None:
insertNull(f_idx, j, False)
# update the blocks
self.invalidateLineMatching(0, old_n, new_n)
self.updateBlocks(blocks)
self.alignmentChange(True)
# update cursor
self.setLineMode()
self.setCurrentLine(self.current_pane, min(self.current_line, len(pane.lines) + 1))
# refresh the lines to contain new objects with updated line numbers and
# no local edits
def bakeEdits(self, f):
pane, lines, line_num = self.panes[f], [], 0
for i in range(len(pane.lines)):
s = self.getLineText(f, i)
if s is None:
lines.append(None)
else:
line_num += 1
lines.append(FileDiffViewer.Line(line_num, s))
# update loaded pane
self.replaceLines(f, pane.lines, lines, pane.max_line_number, line_num)
# 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]
nlines = len(pane.lines)
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 < nlines:
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 = splitlines(text)
if len(ss) == 0 or len(ss[-1]) != len_minus_line_ending(ss[-1]):
ss.append('')
# 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
lastcol = 0
if len(ss) > 0:
last = ss[-1]
if len(last) == len_minus_line_ending(last):
del ss[-1]
lastcol = len(last)
cur_line = line0 + len(ss)
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 < nlines:
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
npanes = len(self.panes)
# remove blank rows if possible
n_need = len(ss)
n_have = line1 - line0
n_have -= self.removeSpacerLines(line0, n_have, f)
delta = n_have - n_need
if delta < 0:
self.insertLines(line0 + n_have, -delta)
delta = 0
# update the text
for i, s in enumerate(ss):
self.updateText(f, line0 + i, s)
# clear all unused lines
for i in range(delta):
self.updateText(f, line0 + n_need + i, None)
# update selection
if self.mode == LINE_MODE:
self.setCurrentLine(f, line0 + max(n_need, 1) - 1, line0)
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)
i = len(lines_s[0][0][0])
self.removeSpacerLines(start + i, len(finallines[0]) - i)
i -= min(self.removeSpacerLines(start, i), i - 1)
# update selection
self.setCurrentLine(self.current_pane, start + i)
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):
if self.undoblock is not None:
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):
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, self.selection_line)
self.emit('cursor_changed')
self.emit('mode_changed')
# queue a redraw to show the updated selection
self.dareas[old_f].queue_draw()
# queue a range of lines for redrawing
def _queue_draw_lines(self, f, line0, line1=None):
if line1 is None:
line1 = line0
elif line0 > line1:
line0, line1 = line1, line0
darea = self.dareas[f]
w, h = darea.get_allocation().width, self.font_height
darea.queue_draw_area(0, line0 * h - int(self.vadj.get_value()), w, (line1 - line0 + 1) * h)
# scroll vertically to ensure the current line is visible
def _ensure_line_is_visible(self, i):
h = self.font_height
lower = i * h
upper = lower + h
vadj = self.vadj
v = vadj.get_value()
2016-07-04 00:22:54 +00:00
ps = vadj.get_page_size()
2015-11-21 17:57:32 +00:00
if lower < v:
vadj.set_value(lower)
elif upper > v + ps:
vadj.set_value(upper - ps)
# change the current selection in LINE_MODE
# use extend=True to extend the selection
def setCurrentLine(self, f, i, selection=None):
# remember old cursor position so we can just redraw what is necessary
old_f = self.current_pane
line0, line1 = self.current_line, self.selection_line
# clamp input values
f = max(min(f, len(self.panes) - 1), 0)
i = max(min(i, len(self.panes[f].lines)), 0)
# update cursor
self.current_pane = f
self.current_line = i
if selection is None:
self.selection_line = i
else:
self.selection_line = selection
self.emit('cursor_changed')
# invalidate old selection area
self._queue_draw_lines(old_f, line0, line1)
# invalidate new selection area
self._queue_draw_lines(f, i, self.selection_line)
# ensure the new cursor position is visible
self._ensure_line_is_visible(i)
# returns True if the line has preedit text
def hasPreedit(self, f, i):
return self.mode == CHAR_MODE and self.current_pane == f and self.current_line == i and self.im_preedit is not None
# create a layout for the existing preedit text
def _preedit_layout(self, partial=False):
s, a, c = self.im_preedit
if partial:
s = s[:c]
layout = self.create_pango_layout(s)
layout.set_font_description(self.font)
layout.set_attributes(a)
return layout
# inform input method about cursor motion
def _cursor_position_changed(self, recompute):
if self.mode == CHAR_MODE:
# update input method
h = self.font_height
if recompute:
self.cursor_pos = (pixels(self._get_cursor_x_offset()), self.current_line * h)
x, y = self.cursor_pos
x -= int(self.hadj.get_value())
y -= int(self.vadj.get_value())
# translate to a position relative to the window
x, y = self.dareas[self.current_pane].translate_coordinates(self.get_toplevel(), x, y)
# input methods support widgets are centred horizontally about the
# cursor, a width of 50 seems to give a better widget positions
2016-07-09 21:38:29 +00:00
rect = Gdk.Rectangle()
2016-07-04 00:22:54 +00:00
rect.x = x
rect.y = y
rect.width = 50
rect.height = h
self.im_context.set_cursor_location(rect)
2015-11-21 17:57:32 +00:00
2016-07-09 21:38:29 +00:00
# get the position of the cursor in Pango units
2015-11-21 17:57:32 +00:00
def _get_cursor_x_offset(self):
j = self.current_char
if j > 0:
text = self.getLineText(self.current_pane, self.current_line)[:j]
return self.getTextWidth(''.join(self.expand(text)))
return 0
# scroll to ensure the current cursor position is visible
def _ensure_cursor_is_visible(self):
f, i, j = self.current_pane, self.current_line, self.current_char
h = self.font_height
# find the cursor's horizontal range
lower = self._get_cursor_x_offset()
if self.im_preedit is not None:
lower += self._preedit_layout(True).get_size()[0]
upper = lower + self.getLineNumberWidth() + self.digit_width
lower, upper = pixels(lower), pixels(upper)
# scroll horizontally
hadj = self.hadj
v = hadj.get_value()
2016-07-04 00:22:54 +00:00
ps = hadj.get_page_size()
2015-11-21 17:57:32 +00:00
if lower < v:
hadj.set_value(lower)
elif upper > v + ps:
hadj.set_value(upper - ps)
# scroll vertically to current line
self._ensure_line_is_visible(i)
def __set_clipboard_text(self, clipboard, s):
# remove embedded nulls as the clipboard cannot handle them
2016-07-09 21:38:29 +00:00
Gtk.Clipboard.get(clipboard).set_text(s.replace('\0', ''), -1)
2015-11-21 17:57:32 +00:00
# change the current selection in CHAR_MODE
# use extend=True to extend the selection
def setCurrentChar(self, i, j, si=None, sj=None):
f = self.current_pane
# remember old cursor position so we can just redraw what is necessary
line0, line1 = self.current_line, self.selection_line
# clear remembered cursor column
self.cursor_column = -1
# update cursor and selection
extend = (si is not None and sj is not None)
if not extend:
si, sj = i, j
self.current_line = i
self.current_char = j
self.selection_line = si
self.selection_char = sj
if extend:
2016-07-09 21:38:29 +00:00
self.__set_clipboard_text(Gdk.SELECTION_PRIMARY, self.getSelectedText())
2015-11-21 17:57:32 +00:00
self._cursor_position_changed(True)
self.emit('cursor_changed')
# invalidate old selection area
self._queue_draw_lines(f, line0, line1)
# invalidate new selection area
self._queue_draw_lines(f, i, self.selection_line)
# ensure the new cursor position is visible
self._ensure_cursor_is_visible()
# 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 select_all(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, text, x, partial):
if text is None:
return 0
n = len(text)
w = self.getLineNumberWidth()
for i, s in enumerate(self.expand(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):
if y < 0:
x, y = -1, 0
i = min(y // self.font_height, len(self.panes[f].lines))
if self.mode == CHAR_MODE and f == self.current_pane:
text = strip_eol(self.getLineText(f, i))
j = self._getPickedCharacter(text, x, True)
if extend:
si, sj = self.selection_line, self.selection_char
else:
si, sj = None, None
self.setCurrentChar(i, j, si, sj)
else:
if self.mode == ALIGN_MODE:
extend = False
elif self.mode == CHAR_MODE:
self.setLineMode()
if extend and f == self.current_pane:
selection = self.selection_line
else:
selection = None
self.setCurrentLine(f, i, selection)
# 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
2016-07-09 21:38:29 +00:00
if event.type == Gdk.EventType._2BUTTON_PRESS:
2015-11-21 17:57:32 +00:00
# double click
if self.mode == ALIGN_MODE:
self.setLineMode()
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._im_focus_in()
self.button_press(f, x, y, False)
self.emit('mode_changed')
elif self.mode == CHAR_MODE and self.current_pane == f:
# select word
text = strip_eol(self.getLineText(f, i))
if text is not None:
n = len(text)
j = self._getPickedCharacter(text, x, False)
if j < n:
ss = self.expand(text[:n])
c = getCharacterClass(text[j])
k = j
while k > 0 and getCharacterClass(text[k - 1]) == c:
k -= 1
while j < n and getCharacterClass(text[j]) == c:
j += 1
self.setCurrentChar(i, j, i, k)
2016-07-09 21:38:29 +00:00
elif event.type == Gdk.EventType._3BUTTON_PRESS:
2015-11-21 17:57:32 +00:00
# triple click, select a whole line
if self.mode == CHAR_MODE and self.current_pane == f:
i2 = min(i + 1, nlines)
self.setCurrentChar(i2, 0, i, 0)
else:
# update the selection
2016-07-09 21:38:29 +00:00
is_shifted = event.state & Gdk.ModifierType.SHIFT_MASK
2015-11-21 17:57:32 +00:00
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)
self.openUndoBlock()
2016-07-09 21:38:29 +00:00
Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY).request_text(self.receive_clipboard_text_cb, None)
2015-11-21 17:57:32 +00:00
self.closeUndoBlock()
elif event.button == 3:
# right mouse button, raise context sensitive menu
can_align = (self.mode == LINE_MODE and (f == self.current_pane + 1 or f == self.current_pane - 1))
can_isolate = (self.mode == LINE_MODE and f == self.current_pane)
can_merge = (self.mode == LINE_MODE and f != self.current_pane)
can_select = ((self.mode == LINE_MODE or self.mode == CHAR_MODE) and f == self.current_pane)
can_swap = (f != self.current_pane)
menu = createMenu(
2016-07-09 21:38:29 +00:00
[ [_('Align with Selection'), self.align_with_selection_cb, [f, i], Gtk.STOCK_EXECUTE, None, can_align],
2015-11-21 17:57:32 +00:00
[_('Isolate'), self.button_cb, 'isolate', None, None, can_isolate ],
[_('Merge Selection'), self.merge_lines_cb, f, None, None, can_merge],
[],
2016-07-09 21:38:29 +00:00
[_('Cut'), self.button_cb, 'cut', Gtk.STOCK_CUT, None, can_select],
[_('Copy'), self.button_cb, 'copy', Gtk.STOCK_COPY, None, can_select],
[_('Paste'), self.button_cb, 'paste', Gtk.STOCK_PASTE, None, can_select],
2015-11-21 17:57:32 +00:00
[],
[_('Select All'), self.button_cb, 'select_all', None, None, can_select],
2016-07-09 21:38:29 +00:00
[_('Clear Edits'), self.button_cb, 'clear_edits', Gtk.STOCK_CLEAR, None, can_isolate],
2015-11-21 17:57:32 +00:00
[],
[_('Swap with Selected Pane'), self.swap_panes_cb, f, None, None, can_swap] ])
2016-07-04 00:22:54 +00:00
menu.attach_to_widget(self)
menu.popup(None, None, None, None, event.button, event.time)
2015-11-21 17:57:32 +00:00
# callback used to notify us about click and drag motion
def darea_motion_notify_cb(self, widget, event, f):
2016-07-09 21:38:29 +00:00
if event.state & Gdk.ModifierType.BUTTON1_MASK:
2015-11-21 17:57:32 +00:00
# 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
if self.prefs.getBool('display_ignore_endofline'):
s = strip_eol(s)
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_endofline'):
s = strip_eol(s)
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
2016-07-04 00:22:54 +00:00
def darea_draw_cb(self, widget, cr, f):
2015-11-21 17:57:32 +00:00
pane = self.panes[f]
syntax = theResources.getSyntax(self.syntax)
2016-07-04 00:22:54 +00:00
rect = widget.get_allocation()
x = rect.x + int(self.hadj.get_value())
y = rect.y + int(self.vadj.get_value())
2015-11-21 17:57:32 +00:00
cr.translate(-x, -y)
2016-07-04 00:22:54 +00:00
maxx = x + rect.width
maxy = y + rect.height
2015-11-21 17:57:32 +00:00
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 line_number_width > 0 and 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()
2016-07-09 21:38:29 +00:00
# draw the line number
2016-07-05 02:30:57 +00:00
if line is not None and line.line_number is not None:
2015-11-21 17:57:32 +00:00
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)
2016-07-04 00:22:54 +00:00
PangoCairo.show_layout(cr, layout)
2015-11-21 17:57:32 +00:00
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(text)
2016-07-09 21:38:29 +00:00
# find the size of each region in Pango units
2015-11-21 17:57:32 +00:00
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((start, end, 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]
# account for preedit changes
if f > 0 and self.hasPreedit(f - 1, i):
flags |= 1
if f + 1 < len(self.panes) and self.hasPreedit(f + 1, i):
flags |= 2
has_preedit = self.hasPreedit(f, i)
if has_preedit:
# we have preedit text
preeditlayout = self._preedit_layout()
preeditwidth = preeditlayout.get_size()[0]
if f > 0:
flags |= 1
if f + 1 < len(self.panes):
flags |= 2
else:
preeditwidth = 0
# draw background
colour = theResources.getColour('text_background')
alpha = theResources.getFloat('character_difference_opacity')
if flags != 0:
colour = (diffcolours[flags - 1] * theResources.getFloat('line_difference_opacity')).over(colour)
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.paint()
# make preedit text appear as a modified line that differs from
# both neighbours
preedit_bg_colour = (diffcolours[flags - 1] * alpha).over(colour)
if text is not None:
# draw char diffs
for starti, endi, start, w, colour in chardiff:
if has_preedit:
# make space for preedit text
if self.current_char <= starti:
start += preeditwidth
elif self.current_char < endi:
w += preeditwidth
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 has_preedit or (line is not None and line.is_modified):
# draw modified
colour = theResources.getColour('edited')
alpha = theResources.getFloat('edited_opacity')
preedit_bg_colour = (colour * alpha).over(preedit_bg_colour)
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('alignment')
alpha = theResources.getFloat('alignment_opacity')
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_opacity')
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(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('character_selection')
alpha = theResources.getFloat('character_selection_opacity')
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 self.prefs.getBool('display_show_right_margin'):
# draw margin
x_temp = line_number_width + pixels(self.prefs.getInt('display_right_margin') * self.digit_width)
if x_temp >= x and x_temp < maxx:
colour = theResources.getColour('margin')
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.set_line_width(1)
cr.move_to(x_temp, y_start)
cr.rel_line_to(0, h)
cr.stroke()
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 syntax highlighting cache until
# line 'i' is included
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(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((start, end, x_temp, layout, colour))
x_temp += layout.get_size()[0]
pane.syntax_cache[i][3] = blocks
# draw text
for starti, endi, start, layout, colour in blocks:
cr.set_source_rgb(colour.red, colour.green, colour.blue)
if has_preedit:
# make space for preedit text
if self.current_char <= starti:
start += preeditwidth
elif self.current_char < endi:
# divide text into 2 segments
ss = self.expand(text)
layout = self.create_pango_layout(''.join(ss[starti:self.current_char]))
layout.set_font_description(self.font)
cr.move_to(x_start + pixels(start), y_start)
2016-07-04 00:22:54 +00:00
PangoCairo.show_layout(cr, layout)
2015-11-21 17:57:32 +00:00
start += layout.get_size()[0] + preeditwidth
layout = self.create_pango_layout(''.join(ss[self.current_char:endi]))
layout.set_font_description(self.font)
cr.move_to(x_start + pixels(start), y_start)
2016-07-04 00:22:54 +00:00
PangoCairo.show_layout(cr, layout)
2015-11-21 17:57:32 +00:00
if self.current_pane == f and self.current_line == i:
# draw the cursor and preedit text
if self.mode == CHAR_MODE:
x_pos = x_start + pixels(self._get_cursor_x_offset())
if has_preedit:
# we have preedit text
layout = self._preedit_layout()
w = pixels(layout.get_size()[0])
# clear the background
colour = preedit_bg_colour
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.rectangle(x_pos, y_start, w, h)
cr.fill()
# draw the preedit text
colour = theResources.getColour('preedit')
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.move_to(x_pos, y_start)
2016-07-04 00:22:54 +00:00
PangoCairo.show_layout(cr, layout)
2015-11-21 17:57:32 +00:00
# advance to the preedit's cursor position
x_pos += pixels(self._preedit_layout(True).get_size()[0])
# draw the character editing cursor
colour = theResources.getColour('cursor')
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.set_line_width(1)
cr.move_to(x_pos + 0.5, y_start)
cr.rel_line_to(0, h)
cr.stroke()
elif self.mode == LINE_MODE or self.mode == ALIGN_MODE:
# draw the line editing cursor
colour = theResources.getColour('cursor')
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.set_line_width(1)
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
# callback used when panes are scrolled horizontally
def hadj_changed_cb(self, adj):
self._cursor_position_changed(False)
# callback used when panes are scrolled vertically
def vadj_changed_cb(self, adj):
self._cursor_position_changed(False)
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
2016-07-04 00:22:54 +00:00
hmax = max(int(vadj.get_upper()), h)
2015-11-21 17:57:32 +00:00
# centre view about picked location
y = event.y * hmax // h
2016-07-04 00:22:54 +00:00
v = y - int(vadj.get_page_size() / 2)
v = max(v, int(vadj.get_lower()))
v = min(v, int(vadj.get_upper() - vadj.get_page_size()))
2015-11-21 17:57:32 +00:00
vadj.set_value(v)
# callback to handle mouse scrollwheel events
def map_scroll_cb(self, widget, event):
delta = 100
2016-07-09 21:38:29 +00:00
if event.direction == Gdk.ScrollDirection.UP:
2015-11-21 17:57:32 +00:00
step_adjustment(self.vadj, -delta)
2016-07-09 21:38:29 +00:00
elif event.direction == Gdk.ScrollDirection.DOWN:
2015-11-21 17:57:32 +00:00
step_adjustment(self.vadj, delta)
# redraws the overview map when a portion is exposed
2016-07-04 00:22:54 +00:00
def map_draw_cb(self, widget, cr):
2015-11-21 17:57:32 +00:00
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 line is None or line.getText() 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]])
# clear
colour = theResources.getColour('map_background')
cr.set_source_rgb(colour.red, colour.green, colour.blue)
cr.paint()
bg_colour = theResources.getColour('text_background')
edited_colour = theResources.getColour('edited')
2016-07-04 00:22:54 +00:00
rect = widget.get_allocation()
2015-11-21 17:57:32 +00:00
# get scroll position and total size
vadj = self.vadj
2016-07-04 00:22:54 +00:00
hmax = max(vadj.get_upper(), rect.height)
2015-11-21 17:57:32 +00:00
# draw diff blocks
2016-07-04 00:22:54 +00:00
wn = rect.width / n
2015-11-21 17:57:32 +00:00
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 = edited_colour
else:
colour = diffcolours[(flag & 3) - 1]
else:
continue
# ensure the line is visible in the map
2016-07-04 00:22:54 +00:00
ymin = rect.height * self.font_height * start // hmax
if ymin >= rect.y + rect.height:
2015-11-21 17:57:32 +00:00
break
2016-07-04 00:22:54 +00:00
yh = max(rect.height * self.font_height * end // hmax - ymin, 1)
#if ymin + yh <= rect.y:
# continue
2015-11-21 17:57:32 +00:00
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())
2016-07-04 00:22:54 +00:00
vmax = vmin + vadj.get_page_size()
ymin = rect.height * vmin // hmax
if ymin < rect.y + rect.height:
yh = rect.height * vmax // hmax - ymin
2015-11-21 17:57:32 +00:00
if yh > 1:
yh -= 1
2016-07-04 00:22:54 +00:00
#if ymin + yh > rect.y:
colour = theResources.getColour('line_selection')
alpha = theResources.getFloat('line_selection_opacity')
cr.set_source_rgba(colour.red, colour.green, colour.blue, alpha)
cr.rectangle(0.5, ymin + 0.5, rect.width - 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, rect.width - 1, yh - 1)
cr.stroke()
2015-11-21 17:57:32 +00:00
# returns the maximum valid offset for a cursor position
# cursors cannot be moved to the right of line ending characters
def getMaxCharPosition(self, i):
return len_minus_line_ending(self.getLineText(self.current_pane, i))
# 'enter_align_mode' keybinding action
def _line_mode_enter_align_mode(self):
if self.mode == CHAR_MODE:
self._im_focus_out()
self.im_context.reset()
self._im_set_preedit(None)
self.mode = ALIGN_MODE
self.selection_line = self.current_line
self.align_pane = self.current_pane
self.align_line = self.current_line
self.emit('mode_changed')
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, self.selection_line)
# '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), self.selection_line)
# 'up' keybinding action
def _line_mode_up(self, selection=None):
self.setCurrentLine(self.current_pane, self.current_line - 1, selection)
# 'extend_up' keybinding action
def _line_mode_extend_up(self):
self._line_mode_up(self.selection_line)
# 'down' keybinding action
def _line_mode_down(self, selection=None):
self.setCurrentLine(self.current_pane, self.current_line + 1, selection)
# 'extend_down' keybinding action
def _line_mode_extend_down(self):
self._line_mode_down(self.selection_line)
# 'left' keybinding action
def _line_mode_left(self, selection=None):
self.setCurrentLine(self.current_pane - 1, self.current_line, selection)
# 'extend_left' keybinding action
def _line_mode_extend_left(self):
self._line_mode_left(self.selection_line)
# 'right' keybinding action
def _line_mode_right(self, selection=None):
self.setCurrentLine(self.current_pane + 1, self.current_line, selection)
# 'extend_right' keybinding action
def _line_mode_extend_right(self):
self._line_mode_right(self.selection_line)
# 'page_up' keybinding action
def _line_mode_page_up(self, selection=None):
2016-07-04 00:22:54 +00:00
delta = int(self.vadj.get_page_size() // self.font_height)
2015-11-21 17:57:32 +00:00
self.setCurrentLine(self.current_pane, self.current_line - delta, selection)
# 'extend_page_up' keybinding action
def _line_mode_extend_page_up(self):
self._line_mode_page_up(self.selection_line)
# 'page_down' keybinding action
def _line_mode_page_down(self, selection=None):
2016-07-04 00:22:54 +00:00
delta = int(self.vadj.get_page_size() // self.font_height)
2015-11-21 17:57:32 +00:00
self.setCurrentLine(self.current_pane, self.current_line + delta, selection)
# 'extend_page_down' keybinding action
def _line_mode_extend_page_down(self):
self._line_mode_page_down(self.selection_line)
# '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)
# give the input method focus
def _im_focus_in(self):
if self.has_focus:
self.im_context.focus_in()
# remove input method focus
def _im_focus_out(self):
if self.has_focus:
self.im_context.focus_out()
# input method callback for committed text
def im_commit_cb(self, im, s):
if self.mode == CHAR_MODE:
self.openUndoBlock()
2016-07-04 00:22:54 +00:00
self.replaceText(s)
2015-11-21 17:57:32 +00:00
self.closeUndoBlock()
# update the cached preedit text
def _im_set_preedit(self, p):
self.im_preedit = p
if self.mode == CHAR_MODE:
f, i = self.current_pane, self.current_line
self._queue_draw_lines(f, i)
if f > 0:
self._queue_draw_lines(f - 1, i)
if f + 1 < len(self.panes):
self._queue_draw_lines(f + 1, i)
self.updateSize(False)
# queue a redraw for location of preedit text
def im_preedit_changed_cb(self, im):
if self.mode == CHAR_MODE:
s, a, c = self.im_context.get_preedit_string()
if len(s) > 0:
# we have preedit text, draw that instead
p = (s, a, c)
else:
p = None
self._im_set_preedit(p)
self._ensure_cursor_is_visible()
# callback to respond to retrieve_surrounding signals from input methods
def im_retrieve_surrounding_cb(self, im):
if self.mode == CHAR_MODE:
# notify input method of text surrounding the cursor
s = nullToEmpty(self.getLineText(self.current_pane, self.current_line))
s, n = codecs.encode(s, 'utf_8'), len(codecs.encode(s[:self.current_char], 'utf_8'))
im.set_surrounding(s, len(s), n)
# callback for 'focus_in_event'
def focus_in_cb(self, widget, event):
self.has_focus = True
if self.mode == CHAR_MODE:
# notify the input method of the focus change
self._im_focus_in()
# callback for 'focus_out_event'
def focus_out_cb(self, widget, event):
if self.mode == CHAR_MODE:
# notify the input method of the focus change
self._im_focus_out()
self.has_focus = False
# callback for keyboard events
# only keypresses that are not handled by menu item accelerators reach here
def key_press_cb(self, widget, event):
if self.mode == CHAR_MODE:
# update input method
if self.im_context.filter_keypress(event):
return True
retval = False
# determine the modified keys used
2016-07-09 21:38:29 +00:00
mask = event.state & (Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK)
if event.state & Gdk.ModifierType.LOCK_MASK:
mask ^= Gdk.ModifierType.SHIFT_MASK
2015-11-21 17:57:32 +00:00
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 action in self._line_mode_actions:
self._line_mode_actions[action]()
retval = True
elif self.mode == CHAR_MODE:
f = self.current_pane
2016-07-09 21:38:29 +00:00
if event.state & Gdk.ModifierType.SHIFT_MASK:
2015-11-21 17:57:32 +00:00
si, sj = self.selection_line, self.selection_char
else:
si, sj = None, None
2016-07-09 21:38:29 +00:00
is_ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
2015-11-21 17:57:32 +00:00
retval = True
# check if the keyval matches a character mode action
action = theResources.getActionForKey('character_mode', event.keyval, mask)
if action in self._character_mode_actions:
self._character_mode_actions[action]()
# allow CTRL-Tab for widget navigation
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_Tab and event.state & Gdk.ModifierType.CONTROL_MASK:
2015-11-21 17:57:32 +00:00
retval = False
# up/down cursor navigation
2016-07-09 21:38:29 +00:00
elif event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down ]:
2015-11-21 17:57:32 +00:00
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.stringWidth(s)
2016-07-09 21:38:29 +00:00
if event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Down ]:
2015-11-21 17:57:32 +00:00
delta = 1
else:
2016-07-04 00:22:54 +00:00
delta = int(self.vadj.get_page_size() // self.font_height)
2016-07-09 21:38:29 +00:00
if event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Page_Up ]:
2015-11-21 17:57:32 +00:00
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_eol(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, si, sj)
self.cursor_column = col
# home key
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_Home:
2015-11-21 17:57:32 +00:00
if is_ctrl:
i = 0
else:
i = self.current_line
self.setCurrentChar(i, 0, si, sj)
# end key
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_End:
2015-11-21 17:57:32 +00:00
if is_ctrl:
i = len(self.panes[f].lines)
j = 0
else:
i = self.current_line
j = self.getMaxCharPosition(i)
self.setCurrentChar(i, j, si, sj)
# cursor left and cursor right navigation
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_Left or event.keyval == Gdk.KEY_Right:
2015-11-21 17:57:32 +00:00
i = self.current_line
j = self.current_char
while True:
2016-07-09 21:38:29 +00:00
if event.keyval == Gdk.KEY_Left:
2015-11-21 17:57:32 +00:00
if j > 0:
j -= 1
elif i > 0:
i -= 1
j = self.getMaxCharPosition(i)
else:
break
else:
if j < self.getMaxCharPosition(i):
j += 1
elif i < len(self.panes[f].lines):
i += 1
j = 0
else:
break
2016-07-09 21:38:29 +00:00
if event.state & Gdk.ModifierType.CONTROL_MASK == 0:
2015-11-21 17:57:32 +00:00
break
# break if we are at the beginning of a word
text = self.getLineText(f, i)
if text is not None and j < len(text):
c = getCharacterClass(text[j])
if c != WHITESPACE_CLASS and (j < 1 or j - 1 >= len(text) or getCharacterClass(text[j - 1]) != c):
break
self.setCurrentChar(i, j, si, sj)
# backspace
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_BackSpace:
2015-11-21 17:57:32 +00:00
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(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
2016-07-09 21:38:29 +00:00
elif event.keyval == Gdk.KEY_Delete:
2015-11-21 17:57:32 +00:00
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
2016-07-09 21:38:29 +00:00
elif event.keyval in [ Gdk.KEY_Return, Gdk.KEY_KP_Enter ]:
2016-07-04 00:22:54 +00:00
s = os.linesep
2015-11-21 17:57:32 +00:00
if self.prefs.getBool('editor_auto_indent'):
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
if start_j > 0:
j, text = 0, self.getLineText(f, start_i)
while j < start_j and text[j] in ' \t':
j += 1
w = self.stringWidth(text[:j])
if self.prefs.getBool('editor_expand_tabs'):
# convert to spaces
2016-07-04 00:22:54 +00:00
s += ' ' * w
2015-11-21 17:57:32 +00:00
else:
tab_width = self.prefs.getInt('display_tab_width')
# replace with tab characters where possible
2016-07-04 00:22:54 +00:00
s += '\t' * (w // tab_width)
s += ' ' * (w % tab_width)
2015-11-21 17:57:32 +00:00
self.replaceText(s)
# insert key
2016-07-09 21:38:29 +00:00
elif event.keyval in [ Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab ]:
2015-11-21 17:57:32 +00:00
start_i, start_j = self.selection_line, self.selection_char
end_i, end_j = self.current_line, self.current_char
2016-07-09 21:38:29 +00:00
if start_i != end_i or start_j != end_j or event.keyval == Gdk.KEY_ISO_Left_Tab:
2015-11-21 17:57:32 +00:00
# find range of lines to operate upon
start, end, offset = start_i, end_i, 1
if end < start:
start, end = end, start
2016-07-09 21:38:29 +00:00
if event.keyval == Gdk.KEY_ISO_Left_Tab:
2015-11-21 17:57:32 +00:00
offset = -1
self.recordEditMode()
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None and len_minus_line_ending(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) + u' ' * (ws % tab_width)
if i == start_i:
start_j = len(s) + max(0, start_j - j)
if i == end_i:
end_j = len(s) + max(0, end_j - j)
self.updateText(f, i, s + text[j:])
self.setCurrentChar(end_i, end_j, start_i, start_j)
self.recordEditMode()
else:
# insert soft-tabs if there are only spaces and tabs from the
# beginning of the line to the cursor location
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(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 action in self._align_mode_actions:
self._align_mode_actions[action]()
retval = True
self.closeUndoBlock()
return retval
# 'copy' action
def copy(self):
if self.mode == LINE_MODE or self.mode == CHAR_MODE:
2016-07-09 21:38:29 +00:00
self.__set_clipboard_text(Gdk.SELECTION_CLIPBOARD, self.getSelectedText())
2015-11-21 17:57:32 +00:00
# 'cut' action
def cut(self):
if self.mode == LINE_MODE or self.mode == CHAR_MODE:
self.copy()
self.replaceText('')
# 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:
# there is no guarantee this will be called before finishing
2016-07-09 21:38:29 +00:00
# Gtk.Clipboard.get so we may need to create our own undo block
2015-11-21 17:57:32 +00:00
needs_block = (self.undoblock is None)
if needs_block:
self.openUndoBlock()
2016-07-04 00:22:54 +00:00
self.replaceText(nullToEmpty(text))
2015-11-21 17:57:32 +00:00
if needs_block:
self.closeUndoBlock()
# 'paste' action
def paste(self):
2016-07-09 21:38:29 +00:00
Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).request_text(self.receive_clipboard_text_cb, None)
2015-11-21 17:57:32 +00:00
# 'clear_edits' action
def clear_edits(self):
self.setLineMode()
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()
# 'dismiss_all_edits' action
def dismiss_all_edits(self):
if self.mode == LINE_MODE or self.mode == CHAR_MODE:
self.bakeEdits(self.current_pane)
# callback for find menu item
def find(self, pattern, match_case, backwards, from_start):
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 from_start:
i, j = nlines, 0
elif si < i or (i == si and sj < j):
i, j = si, sj
else:
if from_start:
i, j = 0, 0
elif i < si or (i == si and j < sj):
i, j = si, sj
if not match_case:
pattern = pattern.upper()
# 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, end, i, idx)
return True
# 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 without finding a match
return False
# move cursor to a given line
def go_to_line(self, i):
f, idx = self.current_pane, 0
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)
# recompute viewport size and redraw as the display preferences may have
# changed
def prefsUpdated(self):
# clear cache as tab width may have changed
self.string_width_cache = {}
2016-07-09 21:38:29 +00:00
self.setFont(Pango.FontDescription.from_string(self.prefs.getString('display_font')))
2015-11-21 17:57:32 +00:00
# update preedit text
self._cursor_position_changed(True)
for pane in self.panes:
del pane.diff_cache[:]
# tab width may have changed
self.emit('cursor_changed')
for darea in self.dareas:
darea.queue_draw()
self.map_cache = None
self.map.queue_draw()
# 'realign_all' action
def realign_all(self):
self.setLineMode()
f = self.current_pane
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()
# callback for the align with selection menu item
def align_with_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()
# 'isolate' action
def isolate(self):
self.setLineMode()
self.recordEditMode()
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
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)
self.removeSpacerLines(end, sum(middle))
end -= self.removeSpacerLines(start, sum(b))
self.setCurrentLine(f, end, start)
self.recordEditMode()
# 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
2016-07-04 00:22:54 +00:00
y = min(max(0, y - vadj.get_page_size() / 2), vadj.get_upper() - vadj.get_page_size())
2015-11-21 17:57:32 +00:00
vadj.set_value(y)
# move the cursor from line 'i' to the next difference in direction 'delta'
def go_to_difference(self, i, delta):
f = self.current_pane
nlines = len(self.panes[f].lines)
# back up to beginning of difference
if i >= 0 and i <= nlines:
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.setCurrentLine(f, start, i)
# 'first_difference' action
def first_difference(self):
self.setLineMode()
self.go_to_difference(0, 1)
# 'previous_difference' action
def previous_difference(self):
self.setLineMode()
i = min(self.current_line, self.selection_line) - 1
self.go_to_difference(i, -1)
# 'next_difference' action
def next_difference(self):
self.setLineMode()
i = max(self.current_line, self.selection_line) + 1
self.go_to_difference(i, 1)
# 'last_difference' action
def last_difference(self):
self.setLineMode()
i = len(self.panes[self.current_pane].lines)
self.go_to_difference(i, -1)
# 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
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()
self.emit('swapped_panes', f_dst, f_src)
# 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.recordEditMode()
self.swapPanes(f_dst, f_src)
self.recordEditMode()
# callback for swap panes menu item
def swap_panes_cb(self, widget, data):
self.openUndoBlock()
self.swap_panes(data, self.current_pane)
self.closeUndoBlock()
# 'shift_pane_left' action
def shift_pane_left(self):
f = self.current_pane
self.swap_panes(f - 1, f)
# 'shift_pane_right' action
def shift_pane_right(self):
f = self.current_pane
self.swap_panes(f + 1, f)
# 'convert_to_upper_case' action
def _convert_case(self, to_upper):
# find range of characters to operate upon
if self.mode == CHAR_MODE:
start, end = self.current_line, self.selection_line
j0, j1 = self.current_char, self.selection_char
if end < start or (start == end and j1 < j0):
start, j0, end, j1 = end, j1, start, j0
else:
self.setLineMode()
start, end = self.current_line, self.selection_line
if end < start:
start, end = end, start
end += 1
j0, j1 = 0, 0
self.recordEditMode()
f = self.current_pane
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None:
s = text
# skip characters after the selection
if i == end:
s, post = s[:j1], s[j1:]
else:
post = ''
# skip characters before the selection
if i == start:
pre, s = s[:j0], s[j0:]
else:
pre = ''
# change the case
if to_upper:
s = s.upper()
else:
s = s.lower()
s = ''.join([pre, s, post])
# only update the line if it changed
if s != text:
self.updateText(f, i, s)
# 'convert_to_upper_case' action
def convert_to_upper_case(self):
self._convert_case(True)
# 'convert_to_lower_case' action
def convert_to_lower_case(self):
self._convert_case(False)
# sort lines
def _sort_lines(self, descending):
if self.mode != CHAR_MODE:
self.setLineMode()
self.recordEditMode()
f = self.current_pane
# find cursor range
start, end = self.selection_line, self.current_line
if end < start:
start, end = end, start
# get set of lines
ss = [ self.getLineText(f, i) for i in range(start, end + 1) ]
# create sorted list, removing any nulls
temp = [ s for s in ss if s is not None ]
temp.sort()
if descending:
temp.reverse()
# add back in the nulls
temp.extend((len(ss) - len(temp)) * [ None ])
for i, s in enumerate(temp):
# update line if it changed
if ss[i] != s:
self.updateText(f, start + i, s)
if self.mode == CHAR_MODE:
# ensure the cursor position is valid
self.setCurrentChar(self.current_line, 0, self.selection_line, 0)
self.recordEditMode()
# 'sort_lines_in_ascending_order' action
def sort_lines_in_ascending_order(self):
self._sort_lines(False)
# 'sort_lines_in_descending_order' action
def sort_lines_in_descending_order(self):
self._sort_lines(True)
# 'remove_trailing_white_space' action
def remove_trailing_white_space(self):
if self.mode != CHAR_MODE:
self.setLineMode()
self.recordEditMode()
f = self.current_pane
# find cursor range
start, end = self.selection_line, self.current_line
if end < start:
start, end = end, start
# get set of lines
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None:
# locate trailing whitespace
old_n = n = len_minus_line_ending(text)
while n > 0 and text[n - 1] in whitespace:
n -= 1
# update line if it changed
if n < old_n:
self.updateText(f, i, text[:n] + text[old_n:])
if self.mode == CHAR_MODE:
# ensure the cursor position is valid
self.setCurrentChar(self.current_line, 0, self.selection_line, 0)
self.recordEditMode()
# 'convert_tabs_to_spaces' action
def convert_tabs_to_spaces(self):
# find range of characters to operate upon
if self.mode == CHAR_MODE:
start, end = self.current_line, self.selection_line
j0, j1 = self.current_char, self.selection_char
if end < start or (start == end and j1 < j0):
start, j0, end, j1 = end, j1, start, j0
else:
self.setLineMode()
start, end = self.current_line, self.selection_line
if end < start:
start, end = end, start
end += 1
j0, j1 = 0, 0
self.recordEditMode()
f = self.current_pane
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None:
# expand tabs
ss, col = [], 0
for c in text:
w = self.characterWidth(col, c)
# replace tab with spaces
if c == '\t':
c = w * u' '
ss.append(c)
col += w
# determine the range of interest
if i == start:
k0 = j0
else:
k0 = 0
if i == end:
k1 = j1
else:
k1 = len(ss)
# compute leading and converted text
s = text[:k0] + ''.join(ss[k0:k1])
if i == end:
# update the end cursor location
j1 = len(s)
# append the trailing text
s += text[k1:]
# update line only if it changed
if text != s:
self.updateText(f, i, s)
if self.mode == CHAR_MODE:
# ensure the cursor position is valid
self.setCurrentChar(end, j1, start, j0)
self.recordEditMode()
# 'convert_leading_spaces_to_tabs' action
def convert_leading_spaces_to_tabs(self):
if self.mode != CHAR_MODE:
self.setLineMode()
self.recordEditMode()
f = self.current_pane
tab_width = self.prefs.getInt('display_tab_width')
# find cursor range
start, end = self.selection_line, self.current_line
if end < start:
start, end = end, start
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None:
# find leading white space
j, col = 0, 0
while j < len(text) and text[j] in ' \t':
col += self.characterWidth(col, text[j])
j += 1
if col >= tab_width:
# convert to tabs
s = ''.join([ u'\t' * (col // tab_width), u' ' * (col % tab_width), text[j:] ])
# update line only if it changed
if text != s:
self.updateText(f, i, s)
if self.mode == CHAR_MODE:
# ensure the cursor position is valid
self.setCurrentChar(self.current_line, 0, self.selection_line, 0)
self.recordEditMode()
# adjust indenting of the selected lines by 'offset' soft tabs
def _adjust_indenting(self, offset):
if self.mode != CHAR_MODE:
self.setLineMode()
# 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
self.recordEditMode()
for i in range(start, end + 1):
text = self.getLineText(f, i)
if text is not None and len_minus_line_ending(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) + u' ' * (ws % tab_width)
self.updateText(f, i, s + text[j:])
if self.mode == CHAR_MODE:
# ensure the cursor position is valid
self.setCurrentChar(self.current_line, 0, self.selection_line, 0)
self.recordEditMode()
# 'increase_indenting' action
def increase_indenting(self):
self._adjust_indenting(1)
# 'decrease_indenting' action
def decrease_indenting(self):
self._adjust_indenting(-1)
def convert_format(self, format):
self.setLineMode()
self.recordEditMode()
f = self.current_pane
for i in range(len(self.panes[f].lines)):
text = self.getLineText(f, i)
s = convert_to_format(text, format)
# only modify lines that actually change
if s != text:
self.updateText(f, i, s)
self.setFormat(f, format)
# 'convert_to_dos' action
def convert_to_dos(self):
self.convert_format(DOS_FORMAT)
# 'convert_to_mac' action
def convert_to_mac(self):
self.convert_format(MAC_FORMAT)
# 'convert_to_unix' action
def convert_to_unix(self):
self.convert_format(UNIX_FORMAT)
# copies the selected range of lines from pane 'f_src' to 'f_dst'
def merge_lines(self, f_dst, f_src):
self.recordEditMode()
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))
n = len(ss)
delta = min(self.removeSpacerLines(start, n), n - 1)
if self.selection_line > start:
self.selection_line -= delta
if self.current_line > start:
self.current_line -= delta
self.recordEditMode()
# callback for merge lines menu item
def merge_lines_cb(self, widget, data):
self.openUndoBlock()
self.merge_lines(data, self.current_pane)
self.closeUndoBlock()
# 'copy_selection_right' action
def copy_selection_right(self):
f = self.current_pane + 1
if f > 0 and f < len(self.panes):
self.merge_lines(f, f - 1)
# 'copy_selection_left' action
def copy_selection_left(self):
f = self.current_pane - 1
if f >= 0 and f + 1 < len(self.panes):
self.merge_lines(f, f + 1)
# 'copy_left_into_selection' action
def copy_left_into_selection(self):
f = self.current_pane
if f > 0 and f < len(self.panes):
self.merge_lines(f, f - 1)
# 'copy_right_into_selection' action
def copy_right_into_selection(self):
f = self.current_pane
if f >= 0 and f + 1 < len(self.panes):
self.merge_lines(f, f + 1)
# merge from both left and right into the current pane
def _mergeBoth(self, right_first):
self.recordEditMode()
self.setLineMode()
f = self.current_pane
start, end = self.selection_line, self.current_line
if end < start:
start, end = end, start
end += 1
npanes = len(self.panes)
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 ]
spaces = [ n * [ None ] for pane in self.panes ]
old_content = [ line for line in lines[f] if line is not None ]
for i in range(f + 1, npanes):
lines[i], spaces[i] = spaces[i], lines[i]
# replace f's lines with merge content
if f > 0:
lines[f] = lines[f - 1][:]
else:
lines[f] = n * [ None ]
if f + 1 < npanes:
spaces[f] = spaces[f + 1][:]
else:
spaces[f] = n * [ None ]
if right_first:
lines, spaces = spaces, lines
pre, post = cutBlocks(end, self.blocks)
pre, b = cutBlocks(start, pre)
# join and remove null lines
b.extend(b)
for l, s in zip(lines, spaces):
l.extend(s)
removeNullLines(b, lines)
# replace f's lines with original, growing if necessary
new_content = lines[f]
new_n = len(new_content)
lines[f] = old_content
min_n = len(old_content)
delta = new_n - min_n
if delta < 0:
delta = -delta
for i in range(npanes):
if i != f:
lines[i].extend(delta * [ None ])
# grow last block
if len(b) > 0:
b[-1] += delta
else:
b = createBlock(delta)
elif delta > 0:
old_content.extend(delta * [ None ])
new_n = len(old_content)
# update lines and blocks
self.updateAlignment(start, n, lines)
pre.extend(b)
pre.extend(post)
self.updateBlocks(pre)
for i in range(new_n):
s = None
if i < len(new_content):
line = new_content[i]
if line is not None:
s = line.getText()
self.updateText(f, start + i, s)
# update selection
end = start + new_n
if end > start:
end -= 1
self.setCurrentLine(f, end, start)
self.recordEditMode()
# 'merge_from_left_then_right' keybinding action
def merge_from_left_then_right(self):
self._mergeBoth(False)
# 'merge_from_right_then_left' keybinding action
def merge_from_right_then_left(self):
self._mergeBoth(True)
# create 'title_changed' signal for FileDiffViewer
2016-07-09 21:38:29 +00:00
GObject.signal_new('swapped-panes', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (int, int))
GObject.signal_new('num-edits-changed', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (int, ))
GObject.signal_new('mode-changed', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('cursor-changed', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('syntax-changed', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (str, ))
GObject.signal_new('format-changed', FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (int, int))
2015-11-21 17:57:32 +00:00
# dialogue used to search for text
2016-07-09 21:38:29 +00:00
class SearchDialog(Gtk.Dialog):
2015-11-21 17:57:32 +00:00
def __init__(self, parent, pattern=None, history=None):
2016-07-09 21:38:29 +00:00
Gtk.Dialog.__init__(self, _('Find...'), parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, (Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT))
2015-11-21 17:57:32 +00:00
2016-07-09 21:38:29 +00:00
vbox = Gtk.VBox.new(False, 0)
2015-11-21 17:57:32 +00:00
vbox.set_border_width(10)
2016-07-09 21:38:29 +00:00
hbox = Gtk.HBox.new(False, 0)
label = Gtk.Label.new(_('Search For: '))
2015-11-21 17:57:32 +00:00
hbox.pack_start(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
combo = Gtk.ComboBoxText.new_with_entry()
2016-07-05 02:48:45 +00:00
self.entry = combo.get_child()
2015-11-21 17:57:32 +00:00
self.entry.connect('activate', self.entry_cb)
if pattern is not None:
self.entry.set_text(pattern)
if history is not None:
2016-07-09 21:38:29 +00:00
completion = Gtk.EntryCompletion.new()
liststore = Gtk.ListStore(GObject.TYPE_STRING)
2015-11-21 17:57:32 +00:00
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()
2016-07-09 21:38:29 +00:00
button = Gtk.CheckButton.new_with_mnemonic(_('Match Case'))
2015-11-21 17:57:32 +00:00
self.match_case_button = button
vbox.pack_start(button, False, False, 0)
button.show()
2016-07-09 21:38:29 +00:00
button = Gtk.CheckButton.new_with_mnemonic(_('Search Backwards'))
2015-11-21 17:57:32 +00:00
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):
2016-07-09 21:38:29 +00:00
self.response(Gtk.ResponseType.ACCEPT)
2015-11-21 17:57:32 +00:00
# convenience method to request confirmation when closing the last tab
def confirmTabClose(parent):
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(parent, Gtk.MessageType.WARNING, _('Closing this tab will quit %s.') % (APP_NAME, ))
end = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
dialog.destroy()
return end
# custom dialogue for picking files with widgets for specifying the encoding
# and revision
2016-07-09 21:38:29 +00:00
class FileChooserDialog(Gtk.FileChooserDialog):
2015-11-21 17:57:32 +00:00
# record last chosen folder so the file chooser can start at a more useful
# location for empty panes
last_chosen_folder = os.path.realpath(os.curdir)
def __current_folder_changed_cb(self, widget):
FileChooserDialog.last_chosen_folder = widget.get_current_folder()
def __init__(self, title, parent, prefs, action, accept, rev=False):
2016-07-09 21:38:29 +00:00
Gtk.FileChooserDialog.__init__(self, title, parent, action, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, accept, Gtk.ResponseType.OK))
2015-11-21 17:57:32 +00:00
self.prefs = prefs
2016-07-09 21:38:29 +00:00
hbox = Gtk.HBox.new(False, 0)
2016-07-05 01:56:17 +00:00
hbox.set_border_width(5)
2016-07-09 21:38:29 +00:00
label = Gtk.Label.new(_('Encoding: '))
2015-11-21 17:57:32 +00:00
hbox.pack_start(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
self.encoding = entry = EncodingMenu(prefs, action in [ Gtk.FileChooserAction.OPEN, Gtk.FileChooserAction.SELECT_FOLDER ])
2015-11-21 17:57:32 +00:00
hbox.pack_start(entry, False, False, 5)
entry.show()
if rev:
2016-07-09 21:38:29 +00:00
self.revision = entry = Gtk.Entry.new()
2015-11-21 17:57:32 +00:00
hbox.pack_end(entry, False, False, 0)
entry.show()
2016-07-09 21:38:29 +00:00
label = Gtk.Label.new(_('Revision: '))
2015-11-21 17:57:32 +00:00
hbox.pack_end(label, False, False, 0)
label.show()
self.vbox.pack_start(hbox, False, False, 0)
hbox.show()
2016-07-04 00:22:54 +00:00
self.set_current_folder(self.last_chosen_folder)
2015-11-21 17:57:32 +00:00
self.connect('current-folder-changed', self.__current_folder_changed_cb)
def set_encoding(self, encoding):
self.encoding.set_text(encoding)
def get_encoding(self):
return self.encoding.get_text()
def get_revision(self):
return self.revision.get_text()
def get_filename(self):
# convert from UTF-8 string to unicode
2016-07-09 21:38:29 +00:00
return Gtk.FileChooserDialog.get_filename(self)
2015-11-21 17:57:32 +00:00
# dialogue used to search for text
2016-07-09 21:38:29 +00:00
class NumericDialog(Gtk.Dialog):
2015-11-21 17:57:32 +00:00
def __init__(self, parent, title, text, val, lower, upper, step=1, page=0):
2016-07-09 21:38:29 +00:00
Gtk.Dialog.__init__(self, title, parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, (Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT, Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT))
2015-11-21 17:57:32 +00:00
2016-07-09 21:38:29 +00:00
vbox = Gtk.VBox.new(False, 0)
2015-11-21 17:57:32 +00:00
vbox.set_border_width(10)
2016-07-09 21:38:29 +00:00
hbox = Gtk.HBox.new(False, 0)
label = Gtk.Label.new(text)
2015-11-21 17:57:32 +00:00
hbox.pack_start(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
adj = Gtk.Adjustment.new(val, lower, upper, step, page, 0)
self.button = button = Gtk.SpinButton.new(adj, 1.0, 0)
2015-11-21 17:57:32 +00:00
button.connect('activate', self.button_cb)
hbox.pack_start(button, True, True, 0)
button.show()
vbox.pack_start(hbox, True, True, 0)
hbox.show()
self.vbox.pack_start(vbox, False, False, 0)
vbox.show()
def button_cb(self, widget):
2016-07-09 21:38:29 +00:00
self.response(Gtk.ResponseType.ACCEPT)
2015-11-21 17:57:32 +00:00
# establish callback for the about dialog's link to Diffuse's web site
def url_hook(dialog, link, userdata):
webbrowser.open(link)
# the about dialogue
2016-07-09 21:38:29 +00:00
class AboutDialog(Gtk.AboutDialog):
2015-11-21 17:57:32 +00:00
def __init__(self):
2016-07-09 21:38:29 +00:00
Gtk.AboutDialog.__init__(self)
2015-11-21 17:57:32 +00:00
self.set_logo_icon_name('diffuse')
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(_('Diffuse is a graphical tool for merging and comparing text files.'))
self.set_copyright(COPYRIGHT)
self.set_website(WEBSITE)
2016-07-21 03:31:47 +00:00
self.set_authors([ _('Derrick Moser <derrick_moser@yahoo.com>'),
_('Romain Failliot <romain.failliot@foolstep.com>') ])
2015-11-21 17:57:32 +00:00
self.set_translator_credits(_('translator-credits'))
ss = [ APP_NAME + ' ' + VERSION + '\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)
# widget classed to create notebook tabs with labels and a close button
# use notebooktab.button.connect() to be notified when the button is pressed
2016-07-09 21:38:29 +00:00
# make this a Gtk.EventBox so signals can be connected for MMB and RMB button
2015-11-21 17:57:32 +00:00
# presses.
2016-07-09 21:38:29 +00:00
class NotebookTab(Gtk.EventBox):
2015-11-21 17:57:32 +00:00
def __init__(self, name, stock):
2016-07-09 21:38:29 +00:00
Gtk.EventBox.__init__(self)
2015-11-21 17:57:32 +00:00
self.set_visible_window(False)
2016-07-09 21:38:29 +00:00
hbox = Gtk.HBox.new(False, 0)
2015-11-21 17:57:32 +00:00
if stock is not None:
2016-07-09 21:38:29 +00:00
image = Gtk.Image.new()
image.set_from_stock(stock, Gtk.IconSize.MENU)
2015-11-21 17:57:32 +00:00
hbox.pack_start(image, False, False, 5)
image.show()
2016-07-09 21:38:29 +00:00
self.label = label = Gtk.Label.new(name)
2015-11-21 17:57:32 +00:00
# left justify the widget
label.set_alignment(0, 0.5)
hbox.pack_start(label, True, True, 0)
label.show()
2016-07-09 21:38:29 +00:00
self.button = button = Gtk.Button.new()
button.set_relief(Gtk.ReliefStyle.NONE)
image = Gtk.Image.new()
image.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU)
2015-11-21 17:57:32 +00:00
button.add(image)
image.show()
2016-07-09 21:38:29 +00:00
button.set_tooltip_text(_('Close Tab'))
2015-11-21 17:57:32 +00:00
hbox.pack_start(button, False, False, 0)
button.show()
self.add(hbox)
hbox.show()
def get_text(self):
return self.label.get_text()
def set_text(self, s):
self.label.set_text(s)
# contains information about a file
class FileInfo:
def __init__(self, name=None, encoding=None, vcs=None, revision=None, label=None):
# file name
self.name = name
# name of codec used to translate the file contents to unicode text
self.encoding = encoding
# the VCS object
self.vcs = vcs
# revision used to retrieve file from the VCS
self.revision = revision
# alternate text to display instead of the actual file name
self.label = label
# '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
# assign user specified labels to the corresponding files
def assign_file_labels(items, labels):
new_items = []
ss = labels[::-1]
for name, data in items:
if ss:
s = ss.pop()
else:
s = None
new_items.append((name, data, s))
return new_items
# 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
2016-07-09 21:38:29 +00:00
class Diffuse(Gtk.Window):
2015-11-21 17:57:32 +00:00
# specialisation of FileDiffViewer for Diffuse
class FileDiffViewer(FileDiffViewer):
# pane header
2016-07-09 21:38:29 +00:00
class PaneHeader(Gtk.HBox):
2015-11-21 17:57:32 +00:00
def __init__(self):
2016-07-09 21:38:29 +00:00
Gtk.HBox.__init__(self)
appendButtons(self, Gtk.IconSize.MENU, [
[ Gtk.STOCK_OPEN, self.button_cb, 'open', _('Open File...') ],
[ Gtk.STOCK_REFRESH, self.button_cb, 'reload', _('Reload File') ],
[ Gtk.STOCK_SAVE, self.button_cb, 'save', _('Save File') ],
[ Gtk.STOCK_SAVE_AS, self.button_cb, 'save_as', _('Save File As...') ] ])
self.label = label = Gtk.Label.new()
2015-11-21 17:57:32 +00:00
label.set_size_request(0, label.get_size_request()[1])
self.pack_start(label, True, True, 0)
label.show()
# file's name and information about how to retrieve it from a
# VCS
self.info = FileInfo()
self.has_edits = False
self.updateTitle()
# callback for buttons
def button_cb(self, widget, s):
self.emit(s)
# creates an appropriate title for the pane header
def updateTitle(self):
ss = []
info = self.info
if info.label is not None:
# show the provided label instead of the file name
ss.append(info.label)
else:
if info.name is not None:
ss.append(info.name)
if info.revision is not None:
ss.append('(' + info.revision + ')')
if self.has_edits:
ss.append('*')
s = ' '.join(ss)
self.label.set_text(s)
2016-07-09 21:38:29 +00:00
self.label.set_tooltip_text(s)
2015-11-21 17:57:32 +00:00
self.emit('title_changed')
# set num edits
def setEdits(self, has_edits):
if self.has_edits != has_edits:
self.has_edits = has_edits
self.updateTitle()
# pane footer
2016-07-09 21:38:29 +00:00
class PaneFooter(Gtk.HBox):
2015-11-21 17:57:32 +00:00
def __init__(self):
2016-07-09 21:38:29 +00:00
Gtk.HBox.__init__(self)
2015-11-21 17:57:32 +00:00
2016-07-09 21:38:29 +00:00
self.cursor = label = Gtk.Label.new()
2015-11-21 17:57:32 +00:00
self.pack_start(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
separator = Gtk.VSeparator.new()
2015-11-21 17:57:32 +00:00
self.pack_end(separator, False, False, 10)
separator.show()
2016-07-09 21:38:29 +00:00
self.encoding = label = Gtk.Label.new()
2015-11-21 17:57:32 +00:00
self.pack_end(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
separator = Gtk.VSeparator.new()
2015-11-21 17:57:32 +00:00
self.pack_end(separator, False, False, 10)
separator.show()
2016-07-09 21:38:29 +00:00
self.format = label = Gtk.Label.new()
2015-11-21 17:57:32 +00:00
self.pack_end(label, False, False, 0)
label.show()
2016-07-09 21:38:29 +00:00
separator = Gtk.VSeparator.new()
2015-11-21 17:57:32 +00:00
self.pack_end(separator, False, False, 10)
separator.show()
self.set_size_request(0, self.get_size_request()[1])
# set the cursor label
def updateCursor(self, viewer, f):
if viewer.mode == CHAR_MODE and viewer.current_pane == f:
j = viewer.current_char
if j > 0:
text = viewer.getLineText(viewer.current_pane, viewer.current_line)[:j]
j = viewer.stringWidth(text)
s = _('Column %d') % (j, )
else:
s = ''
self.cursor.set_text(s)
# set the format label
def setFormat(self, s):
v = []
if s & DOS_FORMAT:
v.append('DOS')
if s & MAC_FORMAT:
v.append('Mac')
if s & UNIX_FORMAT:
v.append('Unix')
self.format.set_text('/'.join(v))
# set the format label
def setEncoding(self, s):
if s is None:
s = ''
self.encoding.set_text(s)
def __init__(self, n, prefs, title):
FileDiffViewer.__init__(self, n, prefs)
self.title = title
self.status = ''
self.headers = []
self.footers = []
for i in range(n):
# pane header
w = Diffuse.FileDiffViewer.PaneHeader()
self.headers.append(w)
2016-07-09 21:38:29 +00:00
self.attach(w, i, i + 1, 0, 1, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2016-07-05 03:00:18 +00:00
w.connect('title-changed', self.title_changed_cb)
2015-11-21 17:57:32 +00:00
w.connect('open', self.open_file_button_cb, i)
w.connect('reload', self.reload_file_button_cb, i)
w.connect('save', self.save_file_button_cb, i)
2016-07-05 03:00:18 +00:00
w.connect('save-as', self.save_file_as_button_cb, i)
2015-11-21 17:57:32 +00:00
w.show()
# pane footer
w = Diffuse.FileDiffViewer.PaneFooter()
self.footers.append(w)
2016-07-09 21:38:29 +00:00
self.attach(w, i, i + 1, 2, 3, Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL)
2015-11-21 17:57:32 +00:00
w.show()
2016-07-05 03:00:18 +00:00
self.connect('swapped-panes', self.swapped_panes_cb)
self.connect('num-edits-changed', self.num_edits_changed_cb)
self.connect('mode-changed', self.mode_changed_cb)
self.connect('cursor-changed', self.cursor_changed_cb)
self.connect('format-changed', self.format_changed_cb)
2015-11-21 17:57:32 +00:00
for i, darea in enumerate(self.dareas):
2016-07-09 21:38:29 +00:00
darea.drag_dest_set(Gtk.DestDefaults.ALL, [ Gtk.TargetEntry.new('text/uri-list', 0, 0) ], Gdk.DragAction.COPY)
2016-07-05 03:00:18 +00:00
darea.connect('drag-data-received', self.drag_data_received_cb, i)
2015-11-21 17:57:32 +00:00
# initialise status
self.updateStatus()
# convenience method to request confirmation before loading a file if
# it will cause existing edits to be lost
def loadFromInfo(self, f, info):
if self.headers[f].has_edits:
# warn users of any unsaved changes they might lose
2016-07-09 21:38:29 +00:00
dialog = Gtk.MessageDialog(self.get_toplevel(), Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, _('Save changes before loading the new file?'))
2015-11-21 17:57:32 +00:00
dialog.set_title(APP_NAME)
2016-07-09 21:38:29 +00:00
dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
dialog.add_button(Gtk.STOCK_NO, Gtk.ResponseType.REJECT)
dialog.add_button(Gtk.STOCK_YES, Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
2015-11-21 17:57:32 +00:00
response = dialog.run()
dialog.destroy()
2016-07-09 21:38:29 +00:00
if response == Gtk.ResponseType.OK:
2015-11-21 17:57:32 +00:00
# save the current pane contents
if not self.save_file(f):
# cancel if the save failed
return
2016-07-09 21:38:29 +00:00
elif response != Gtk.ResponseType.REJECT:
2015-11-21 17:57:32 +00:00
# cancel if the user did not choose 'yes' or 'no'
return
self.openUndoBlock()
self.recordEditMode()
self.load(f, info)
self.recordEditMode()
self.closeUndoBlock()
# callback used when receiving drag-n-drop data
def drag_data_received_cb(self, widget, context, x, y, selection, targettype, eventtime, f):
# parse uri-list
s = selection.data
ss, i, n = [], 0, len(s)
while i < n:
# skip separators
while i < n:
v = ord(s[i])
if v > 32 and v < 127:
break
i += 1
start = i
# find end of item
while i < n:
v = ord(s[i])
if v <= 32 or v >= 127:
break
i += 1
if i > start:
p = url2path(s[start:i])
if p is not None:
2016-07-04 00:22:54 +00:00
ss.append(p)
2015-11-21 17:57:32 +00:00
# load the first valid file
for s in ss:
if os.path.isfile(s):
self.loadFromInfo(f, FileInfo(s))
break
# change the file info for pane 'f' to 'info'
def setFileInfo(self, f, info):
h, footer = self.headers[f], self.footers[f]
h.info = info
h.updateTitle()
footer.setFormat(self.panes[f].format)
footer.setEncoding(info.encoding)
footer.updateCursor(self, f)
# callback used when a pane header's title changes
def title_changed_cb(self, widget):
# choose a short but descriptive title for the viewer
has_edits = False
names = []
unique_names = set()
for header in self.headers:
has_edits |= header.has_edits
s = header.info.label
if s is None:
# no label provided, show the file name instead
s = header.info.name
if s is not None:
s = os.path.basename(s)
if s is not None:
names.append(s)
unique_names.add(s)
if len(unique_names) > 0:
if len(unique_names) == 1:
self.title = names[0]
else:
self.title = ' : '.join(names)
s = self.title
if has_edits:
s += ' *'
self.emit('title_changed', s)
def setEncoding(self, f, encoding):
h = self.headers[f]
h.info.encoding = encoding
self.footers[f].setEncoding(encoding)
# load a new file into pane 'f'
# 'info' indicates the name of the file and how to retrieve it from the
# version control system if applicable
def load(self, f, info):
name = info.name
encoding = info.encoding
stat = None
if name is None:
# reset to an empty pane
ss = []
else:
rev = info.revision
try:
if rev is None:
# load the contents of a plain file
fd = open(name, 'rb')
s = fd.read()
fd.close()
# get the file's modification times so we can detect changes
stat = os.stat(name)
else:
if info.vcs is None:
raise IOError('Not under version control.')
fullname = os.path.abspath(name)
# retrieve the revision from the version control system
s = info.vcs.getRevision(self.prefs, fullname, rev)
# convert file contents to unicode
if encoding is None:
s, encoding = self.prefs.convertToUnicode(s)
else:
2016-07-04 00:22:54 +00:00
s = str(s, encoding=encoding)
2015-11-21 17:57:32 +00:00
ss = splitlines(s)
except (IOError, OSError, UnicodeDecodeError, WindowsError, LookupError):
# 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.') % (name, )
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.ERROR, msg)
2015-11-21 17:57:32 +00:00
dialog.run()
dialog.destroy()
return
# update the panes contents, last modified time, and title
self.replaceContents(f, ss)
info.encoding = encoding
info.last_stat = info.stat = stat
self.setFileInfo(f, info)
# use the file name to choose appropriate syntax highlighting rules
if name is not None:
syntax = theResources.guessSyntaxForFile(name, ss)
if syntax is not None:
self.setSyntax(syntax)
# load a new file into pane 'f'
def open_file(self, f, reload=False):
h = self.headers[f]
info = h.info
if not reload:
# we need to ask for a file name if we are not reloading the
# existing file
2016-07-09 21:38:29 +00:00
dialog = FileChooserDialog(_('Open File'), self.get_toplevel(), self.prefs, Gtk.FileChooserAction.OPEN, Gtk.STOCK_OPEN, True)
2015-11-21 17:57:32 +00:00
if info.name is not None:
dialog.set_filename(os.path.realpath(info.name))
dialog.set_encoding(info.encoding)
2016-07-09 21:38:29 +00:00
dialog.set_default_response(Gtk.ResponseType.OK)
end = (dialog.run() != Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
name = dialog.get_filename()
rev = None
vcs = None
revision = dialog.get_revision().strip()
if revision != '':
rev = revision
vcs = theVCSs.findByFilename(name, self.prefs)
info = FileInfo(name, dialog.get_encoding(), vcs, rev)
dialog.destroy()
if end:
return
self.loadFromInfo(f, info)
# callback for open file button
def open_file_button_cb(self, widget, f):
self.open_file(f)
# callback for open file menu item
def open_file_cb(self, widget, data):
self.open_file(self.current_pane)
# callback for reload file button
def reload_file_button_cb(self, widget, f):
self.open_file(f, True)
# callback for reload file menu item
def reload_file_cb(self, widget, data):
self.open_file(self.current_pane, True)
# check changes to files on disk when receiving keyboard focus
def focus_in(self, widget, event):
for f, h in enumerate(self.headers):
info = h.info
try:
if info.last_stat is not None:
info = h.info
new_stat = os.stat(info.name)
if info.last_stat[stat.ST_MTIME] < new_stat[stat.ST_MTIME]:
# update our notion of the most recent modification
info.last_stat = new_stat
if info.label is not None:
s = info.label
else:
s = info.name
msg = _('The file %s changed on disk. Do you want to reload the file?') % (s, )
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg)
ok = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
dialog.destroy()
if ok:
self.open_file(f, True)
except OSError:
pass
# save contents of pane 'f' to file
def save_file(self, f, save_as=False):
h = self.headers[f]
info = h.info
name, encoding, rev, label = info.name, info.encoding, info.revision, info.label
if name is None or rev is not None:
# we need to prompt for a file name the current contents were
# not loaded from a regular file
save_as = True
if save_as:
# prompt for a file name
2016-07-09 21:38:29 +00:00
dialog = FileChooserDialog(_('Save %(title)s Pane %(pane)d') % { 'title': self.title, 'pane': f + 1 }, self.get_toplevel(), self.prefs, Gtk.FileChooserAction.SAVE, Gtk.STOCK_SAVE)
2015-11-21 17:57:32 +00:00
if name is not None:
dialog.set_filename(os.path.abspath(name))
if encoding is None:
encoding = self.prefs.getDefaultEncoding()
dialog.set_encoding(encoding)
name, label = None, None
2016-07-09 21:38:29 +00:00
dialog.set_default_response(Gtk.ResponseType.OK)
if dialog.run() == Gtk.ResponseType.OK:
2015-11-21 17:57:32 +00:00
name = dialog.get_filename()
encoding = dialog.get_encoding()
if encoding is None:
if info.encoding is not None:
# this case can occur if the user provided the
# encoding and it is not an encoding we know about
encoding = info.encoding
else:
encoding = self.prefs.getDefaultEncoding()
dialog.destroy()
if name is None:
return False
try:
msg = None
# warn if we are about to overwrite an existing file
if save_as:
if os.path.exists(name):
msg = _('A file named %s already exists. Do you want to overwrite it?') % (name, )
# warn if we are about to overwrite a file that has changed
# since we last read it
elif info.stat is not None:
if info.stat[stat.ST_MTIME] < os.stat(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?') % (name, )
if msg is not None:
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg)
end = (dialog.run() != Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
dialog.destroy()
if end:
return False
except OSError:
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 = []
for line in self.panes[f].lines:
if line is not None:
s = line.getText()
if s is not None:
ss.append(s)
encoded = codecs.encode(u''.join(ss), encoding)
# write file
fd = open(name, 'wb')
fd.write(encoded)
fd.close()
# make the edits look permanent
self.openUndoBlock()
self.bakeEdits(f)
self.closeUndoBlock()
# update the pane file info
info.name, info.encoding, info.revision, info.label = name, encoding, None, label
info.last_stat = info.stat = os.stat(name)
self.setFileInfo(f, info)
# update the syntax highlighting incase we changed the file
# extension
syntax = theResources.guessSyntaxForFile(name, ss)
if syntax is not None:
self.setSyntax(syntax)
return True
except (UnicodeEncodeError, LookupError):
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.ERROR, _('Error encoding to %s.') % (encoding, ))
2015-11-21 17:57:32 +00:00
dialog.run()
dialog.destroy()
except IOError:
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.ERROR, _('Error writing %s.') % (name, ))
2015-11-21 17:57:32 +00:00
dialog.run()
dialog.destroy()
return False
# callback for save file menu item
def save_file_cb(self, widget, data):
self.save_file(self.current_pane)
# 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 all menu item
def save_all_cb(self, widget, data):
for f, h in enumerate(self.headers):
if h.has_edits:
self.save_file(f)
# callback for save file button
def save_file_button_cb(self, widget, f):
self.save_file(f)
# callback for save file as button
def save_file_as_button_cb(self, widget, f):
self.save_file(f, True)
# callback for go to line menu item
def go_to_line_cb(self, widget, data):
parent = self.get_toplevel()
dialog = NumericDialog(parent, _('Go To Line...'), _('Line Number: '), 1, 1, self.panes[self.current_pane].max_line_number + 1)
2016-07-09 21:38:29 +00:00
okay = (dialog.run() == Gtk.ResponseType.ACCEPT)
2015-11-21 17:57:32 +00:00
i = dialog.button.get_value_as_int()
dialog.destroy()
if okay:
self.go_to_line(i)
# callback to receive notification when the name of a file changes
def swapped_panes_cb(self, widget, f_dst, f_src):
f0, f1 = self.headers[f_dst], self.headers[f_src]
f0.has_edits, f1.has_edits = f1.has_edits, f0.has_edits
info0, info1 = f1.info, f0.info
self.setFileInfo(f_dst, info0)
self.setFileInfo(f_src, info1)
# callback to receive notification when the name of a file changes
def num_edits_changed_cb(self, widget, f):
self.headers[f].setEdits(self.panes[f].num_edits > 0)
# callback to record changes to the viewer's mode
def mode_changed_cb(self, widget):
self.updateStatus()
# update the viewer's current status message
def updateStatus(self):
if self.mode == LINE_MODE:
s = _('Press the enter key or double click to edit. Press the space bar or use the RMB menu to manually align.')
elif self.mode == CHAR_MODE:
s = _('Press the escape key to finish editing.')
elif self.mode == ALIGN_MODE:
s = _('Select target line and press the space bar to align. Press the escape key to cancel.')
else:
s = None
self.status = s
self.emit('status_changed', s)
# gets the status bar text
def getStatus(self):
return self.status
# callback to display the cursor in a pane
def cursor_changed_cb(self, widget):
for f, footer in enumerate(self.footers):
footer.updateCursor(self, f)
# callback to display the format of a pane
def format_changed_cb(self, widget, f, format):
self.footers[f].setFormat(format)
def __init__(self, rc_dir):
2016-07-09 21:38:29 +00:00
Gtk.Window.__init__(self, Gtk.WindowType.TOPLEVEL)
2015-11-21 17:57:32 +00:00
self.prefs = Preferences(os.path.join(rc_dir, 'prefs'))
# 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_width': 1024, 'window_height': 768 }
2016-07-09 21:38:29 +00:00
self.int_state['window_x'] = max(0, (Gdk.Screen.width() - self.int_state['window_width']) / 2)
self.int_state['window_y'] = max(0, (Gdk.Screen.height() - self.int_state['window_height']) / 2)
2016-07-05 03:00:18 +00:00
self.connect('configure-event', self.configure_cb)
self.connect('window-state-event', self.window_state_cb)
2015-11-21 17:57:32 +00:00
# search history is application wide
self.search_pattern = None
self.search_history = []
self.connect('delete-event', self.delete_cb)
2016-07-09 21:38:29 +00:00
accel_group = Gtk.AccelGroup()
2015-11-21 17:57:32 +00:00
# create a VBox for our contents
2016-07-09 21:38:29 +00:00
vbox = Gtk.VBox()
2015-11-21 17:57:32 +00:00
# create some custom icons for merging
DIFFUSE_STOCK_NEW_2WAY_MERGE = 'diffuse-new-2way-merge'
DIFFUSE_STOCK_NEW_3WAY_MERGE = 'diffuse-new-3way-merge'
DIFFUSE_STOCK_LEFT_RIGHT = 'diffuse-left-right'
DIFFUSE_STOCK_RIGHT_LEFT = 'diffuse-right-left'
2016-07-09 21:38:29 +00:00
factory = Gtk.IconFactory()
2015-11-21 17:57:32 +00:00
# render the base item used to indicate a new document
2016-07-09 21:38:29 +00:00
p0 = self.render_icon(Gtk.STOCK_NEW, Gtk.IconSize.LARGE_TOOLBAR)
2015-11-21 17:57:32 +00:00
w, h = p0.get_width(), p0.get_height()
# render new 2-way merge icon
s = 0.8
sw, sh = int(s * w), int(s * h)
w1, h1 = w - sw, h - sh
2016-07-09 21:38:29 +00:00
p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h)
2015-11-21 17:57:32 +00:00
p.fill(0)
2016-07-09 21:38:29 +00:00
p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
factory.add(DIFFUSE_STOCK_NEW_2WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p))
2015-11-21 17:57:32 +00:00
# render new 3-way merge icon
s = 0.7
sw, sh = int(s * w), int(s * h)
w1, h1 = (w - sw) / 2, (h - sh) / 2
w2, h2 = w - sw, h - sh
2016-07-09 21:38:29 +00:00
p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h)
2015-11-21 17:57:32 +00:00
p.fill(0)
2016-07-09 21:38:29 +00:00
p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
p0.composite(p, w2, h2, sw, sh, w2, h2, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
factory.add(DIFFUSE_STOCK_NEW_3WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p))
2015-11-21 17:57:32 +00:00
# render the left and right arrow we will use in our custom icons
2016-07-09 21:38:29 +00:00
p0 = self.render_icon(Gtk.STOCK_GO_FORWARD, Gtk.IconSize.LARGE_TOOLBAR)
p1 = self.render_icon(Gtk.STOCK_GO_BACK, Gtk.IconSize.LARGE_TOOLBAR)
2015-11-21 17:57:32 +00:00
w, h, s = p0.get_width(), p0.get_height(), 0.65
sw, sh = int(s * w), int(s * h)
w1, h1 = w - sw, h - sh
# create merge from left then right icon
2016-07-09 21:38:29 +00:00
p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h)
2015-11-21 17:57:32 +00:00
p.fill(0)
2016-07-09 21:38:29 +00:00
p1.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
factory.add(DIFFUSE_STOCK_LEFT_RIGHT, Gtk.IconSet.new_from_pixbuf(p))
2015-11-21 17:57:32 +00:00
# create merge from right then left icon
2016-07-09 21:38:29 +00:00
p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h)
2015-11-21 17:57:32 +00:00
p.fill(0)
2016-07-09 21:38:29 +00:00
p0.composite(p, 0, h1, sw, sh, 0, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
p1.composite(p, w1, 0, sw, sh, w1, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255)
factory.add(DIFFUSE_STOCK_RIGHT_LEFT, Gtk.IconSet.new_from_pixbuf(p))
2015-11-21 17:57:32 +00:00
# make the icons available for use
factory.add_default()
menuspecs = []
menuspecs.append([ _('_File'), [
2016-07-09 21:38:29 +00:00
[_('_Open File...'), self.open_file_cb, None, Gtk.STOCK_OPEN, 'open_file'],
2015-11-21 17:57:32 +00:00
[_('Open File In New _Tab...'), self.open_file_in_new_tab_cb, None, None, 'open_file_in_new_tab'],
[_('Open _Modified Files...'), self.open_modified_files_cb, None, None, 'open_modified_files'],
[_('Open Commi_t...'), self.open_commit_cb, None, None, 'open_commit'],
2016-07-09 21:38:29 +00:00
[_('_Reload File'), self.reload_file_cb, None, Gtk.STOCK_REFRESH, 'reload_file'],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[_('_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'],
2015-11-21 17:57:32 +00:00
[_('Save A_ll'), self.save_all_cb, None, None, 'save_all'],
[],
[_('New _2-Way File Merge'), self.new_2_way_file_merge_cb, None, DIFFUSE_STOCK_NEW_2WAY_MERGE, 'new_2_way_file_merge'],
[_('New _3-Way File Merge'), self.new_3_way_file_merge_cb, None, DIFFUSE_STOCK_NEW_3WAY_MERGE, 'new_3_way_file_merge'],
[_('New _N-Way File Merge...'), self.new_n_way_file_merge_cb, None, None, 'new_n_way_file_merge'],
[],
2016-07-09 21:38:29 +00:00
[_('_Close Tab'), self.close_tab_cb, None, Gtk.STOCK_CLOSE, 'close_tab'],
2015-11-21 17:57:32 +00:00
[_('_Undo Close Tab'), self.undo_close_tab_cb, None, None, 'undo_close_tab'],
2016-07-09 21:38:29 +00:00
[_('_Quit'), self.quit_cb, None, Gtk.STOCK_QUIT, 'quit'] ] ])
2015-11-21 17:57:32 +00:00
menuspecs.append([ _('_Edit'), [
2016-07-09 21:38:29 +00:00
[_('_Undo'), self.button_cb, 'undo', Gtk.STOCK_UNDO, 'undo'],
[_('_Redo'), self.button_cb, 'redo', Gtk.STOCK_REDO, 'redo'],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[_('Cu_t'), self.button_cb, 'cut', Gtk.STOCK_CUT, 'cut'],
[_('_Copy'), self.button_cb, 'copy', Gtk.STOCK_COPY, 'copy'],
[_('_Paste'), self.button_cb, 'paste', Gtk.STOCK_PASTE, 'paste'],
2015-11-21 17:57:32 +00:00
[],
[_('Select _All'), self.button_cb, 'select_all', None, 'select_all'],
2016-07-09 21:38:29 +00:00
[_('C_lear Edits'), self.button_cb, 'clear_edits', Gtk.STOCK_CLEAR, 'clear_edits'],
2015-11-21 17:57:32 +00:00
[_('_Dismiss All Edits'), self.button_cb, 'dismiss_all_edits', None, 'dismiss_all_edits'],
[],
2016-07-09 21:38:29 +00:00
[_('_Find...'), self.find_cb, None, Gtk.STOCK_FIND, 'find'],
2015-11-21 17:57:32 +00:00
[_('Find _Next'), self.find_next_cb, None, None, 'find_next'],
[_('Find Pre_vious'), self.find_previous_cb, None, None, 'find_previous'],
2016-07-09 21:38:29 +00:00
[_('_Go To Line...'), self.go_to_line_cb, None, Gtk.STOCK_JUMP_TO, 'go_to_line'],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[_('Pr_eferences...'), self.preferences_cb, None, Gtk.STOCK_PREFERENCES, 'preferences'] ] ])
2015-11-21 17:57:32 +00:00
submenudef = [ [_('None'), self.syntax_cb, None, None, 'no_syntax_highlighting', True, None, ('syntax', None) ] ]
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, True, None, ('syntax', name) ])
menuspecs.append([ _('_View'), [
[_('_Syntax Highlighting'), None, None, None, None, True, submenudef],
[],
2016-07-09 21:38:29 +00:00
[_('Re_align All'), self.button_cb, 'realign_all', Gtk.STOCK_EXECUTE, 'realign_all'],
2015-11-21 17:57:32 +00:00
[_('_Isolate'), self.button_cb, 'isolate', None, 'isolate'],
[],
2016-07-09 21:38:29 +00:00
[_('_First Difference'), self.button_cb, 'first_difference', Gtk.STOCK_GOTO_TOP, 'first_difference'],
[_('_Previous Difference'), self.button_cb, 'previous_difference', Gtk.STOCK_GO_UP, 'previous_difference'],
[_('_Next Difference'), self.button_cb, 'next_difference', Gtk.STOCK_GO_DOWN, 'next_difference'],
[_('_Last Difference'), self.button_cb, 'last_difference', Gtk.STOCK_GOTO_BOTTOM, 'last_difference'],
2015-11-21 17:57:32 +00:00
[],
[_('Fir_st Tab'), self.first_tab_cb, None, None, 'first_tab'],
[_('Pre_vious Tab'), self.previous_tab_cb, None, None, 'previous_tab'],
[_('Next _Tab'), self.next_tab_cb, None, None, 'next_tab'],
[_('Las_t Tab'), self.last_tab_cb, None, None, 'last_tab'],
[],
[_('Shift Pane _Right'), self.button_cb, 'shift_pane_right', None, 'shift_pane_right'],
[_('Shift Pane _Left'), self.button_cb, 'shift_pane_left', None, 'shift_pane_left'] ] ])
menuspecs.append([ _('F_ormat'), [
[_('Convert To _Upper Case'), self.button_cb, 'convert_to_upper_case', None, 'convert_to_upper_case'],
[_('Convert To _Lower Case'), self.button_cb, 'convert_to_lower_case', None, 'convert_to_lower_case'],
[],
2016-07-09 21:38:29 +00:00
[_('Sort Lines In _Ascending Order'), self.button_cb, 'sort_lines_in_ascending_order', Gtk.STOCK_SORT_ASCENDING, 'sort_lines_in_ascending_order'],
[_('Sort Lines In D_escending Order'), self.button_cb, 'sort_lines_in_descending_order', Gtk.STOCK_SORT_DESCENDING, 'sort_lines_in_descending_order'],
2015-11-21 17:57:32 +00:00
[],
[_('Remove Trailing _White Space'), self.button_cb, 'remove_trailing_white_space', None, 'remove_trailing_white_space'],
[_('Convert Tabs To _Spaces'), self.button_cb, 'convert_tabs_to_spaces', None, 'convert_tabs_to_spaces'],
[_('Convert Leading Spaces To _Tabs'), self.button_cb, 'convert_leading_spaces_to_tabs', None, 'convert_leading_spaces_to_tabs'],
[],
2016-07-09 21:38:29 +00:00
[_('_Increase Indenting'), self.button_cb, 'increase_indenting', Gtk.STOCK_INDENT, 'increase_indenting'],
[_('De_crease Indenting'), self.button_cb, 'decrease_indenting', Gtk.STOCK_UNINDENT, 'decrease_indenting'],
2015-11-21 17:57:32 +00:00
[],
[_('Convert To _DOS Format'), self.button_cb, 'convert_to_dos', None, 'convert_to_dos'],
[_('Convert To _Mac Format'), self.button_cb, 'convert_to_mac', None, 'convert_to_mac'],
[_('Convert To Uni_x Format'), self.button_cb, 'convert_to_unix', None, 'convert_to_unix'] ] ])
menuspecs.append([ _('_Merge'), [
2016-07-09 21:38:29 +00:00
[_('Copy Selection _Right'), self.button_cb, 'copy_selection_right', Gtk.STOCK_GOTO_LAST, 'copy_selection_right'],
[_('Copy Selection _Left'), self.button_cb, 'copy_selection_left', Gtk.STOCK_GOTO_FIRST, 'copy_selection_left'],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[_('Copy Left _Into Selection'), self.button_cb, 'copy_left_into_selection', Gtk.STOCK_GO_FORWARD, 'copy_left_into_selection'],
[_('Copy Right I_nto Selection'), self.button_cb, 'copy_right_into_selection', Gtk.STOCK_GO_BACK, 'copy_right_into_selection'],
2015-11-21 17:57:32 +00:00
[_('_Merge From Left Then Right'), self.button_cb, 'merge_from_left_then_right', DIFFUSE_STOCK_LEFT_RIGHT, 'merge_from_left_then_right'],
[_('M_erge From Right Then Left'), self.button_cb, 'merge_from_right_then_left', DIFFUSE_STOCK_RIGHT_LEFT, 'merge_from_right_then_left'] ] ])
menuspecs.append([ _('_Help'), [
2016-07-09 21:38:29 +00:00
[_('_Help Contents...'), self.help_contents_cb, None, Gtk.STOCK_HELP, 'help_contents'],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[_('_About %s...') % (APP_NAME, ), self.about_cb, None, Gtk.STOCK_ABOUT, 'about'] ] ])
2015-11-21 17:57:32 +00:00
# used to disable menu events when switching tabs
self.menu_update_depth = 0
# build list of radio menu items so we can update them to match the
# currently viewed pane
self.radio_menus = radio_menus = {}
menu_bar = createMenuBar(menuspecs, radio_menus, accel_group)
vbox.pack_start(menu_bar, False, False, 0)
menu_bar.show()
# create button bar
2016-07-09 21:38:29 +00:00
hbox = Gtk.HBox.new(False, 0)
appendButtons(hbox, Gtk.IconSize.LARGE_TOOLBAR, [
2015-11-21 17:57:32 +00:00
[ DIFFUSE_STOCK_NEW_2WAY_MERGE, self.new_2_way_file_merge_cb, None, _('New 2-Way File Merge') ],
[ DIFFUSE_STOCK_NEW_3WAY_MERGE, self.new_3_way_file_merge_cb, None, _('New 3-Way File Merge') ],
[],
2016-07-09 21:38:29 +00:00
[ Gtk.STOCK_EXECUTE, self.button_cb, 'realign_all', _('Realign All') ],
[ Gtk.STOCK_GOTO_TOP, self.button_cb, 'first_difference', _('First Difference') ],
[ Gtk.STOCK_GO_UP, self.button_cb, 'previous_difference', _('Previous Difference') ],
[ Gtk.STOCK_GO_DOWN, self.button_cb, 'next_difference', _('Next Difference') ],
[ Gtk.STOCK_GOTO_BOTTOM, self.button_cb, 'last_difference', _('Last Difference') ],
2015-11-21 17:57:32 +00:00
[],
2016-07-09 21:38:29 +00:00
[ Gtk.STOCK_GOTO_LAST, self.button_cb, 'copy_selection_right', _('Copy Selection Right') ],
[ Gtk.STOCK_GOTO_FIRST, self.button_cb, 'copy_selection_left', _('Copy Selection Left') ],
[ Gtk.STOCK_GO_FORWARD, self.button_cb, 'copy_left_into_selection', _('Copy Left Into Selection') ],
[ Gtk.STOCK_GO_BACK, self.button_cb, 'copy_right_into_selection', _('Copy Right Into Selection') ],
2015-11-21 17:57:32 +00:00
[ DIFFUSE_STOCK_LEFT_RIGHT, self.button_cb, 'merge_from_left_then_right', _('Merge From Left Then Right') ],
[ DIFFUSE_STOCK_RIGHT_LEFT, self.button_cb, 'merge_from_right_then_left', _('Merge From Right Then Left') ],
[],
2016-07-09 21:38:29 +00:00
[ Gtk.STOCK_UNDO, self.button_cb, 'undo', _('Undo') ],
[ Gtk.STOCK_REDO, self.button_cb, 'redo', _('Redo') ],
[ Gtk.STOCK_CUT, self.button_cb, 'cut', _('Cut') ],
[ Gtk.STOCK_COPY, self.button_cb, 'copy', _('Copy') ],
[ Gtk.STOCK_PASTE, self.button_cb, 'paste', _('Paste') ],
[ Gtk.STOCK_CLEAR, self.button_cb, 'clear_edits', _('Clear Edits') ] ])
2015-11-21 17:57:32 +00:00
# avoid the button bar from dictating the minimum window size
hbox.set_size_request(0, hbox.get_size_request()[1])
vbox.pack_start(hbox, False, False, 0)
hbox.show()
self.closed_tabs = []
2016-07-09 21:38:29 +00:00
self.notebook = notebook = Gtk.Notebook.new()
2015-11-21 17:57:32 +00:00
notebook.set_scrollable(True)
notebook.connect('switch-page', self.switch_page_cb)
vbox.pack_start(notebook, True, True, 0)
notebook.show()
# Add a status bar to the bottom
2016-07-09 21:38:29 +00:00
self.statusbar = statusbar = Gtk.Statusbar.new()
2015-11-21 17:57:32 +00:00
vbox.pack_start(statusbar, False, False, 0)
statusbar.show()
self.add_accel_group(accel_group)
self.add(vbox)
vbox.show()
2016-07-05 03:00:18 +00:00
self.connect('focus-in-event', self.focus_in_cb)
2015-11-21 17:57:32 +00:00
# notifies all viewers on focus changes so they may check for external
# changes to files
def focus_in_cb(self, widget, event):
for i in range(self.notebook.get_n_pages()):
self.notebook.get_nth_page(i).focus_in(widget, event)
# 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
2016-07-09 21:38:29 +00:00
if (widget.get_window().get_state() & Gdk.WindowState.MAXIMIZED) == 0:
self.int_state['window_x'], self.int_state['window_y'] = widget.get_window().get_root_origin()
2015-11-21 17:57:32 +00:00
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):
2016-07-09 21:38:29 +00:00
self.bool_state['window_maximized'] = ((event.new_window_state & Gdk.WindowState.MAXIMIZED) != 0)
2015-11-21 17:57:32 +00:00
# load state information that should persist across sessions
def loadState(self, statepath):
if os.path.isfile(statepath):
try:
f = open(statepath, 'r')
ss = readlines(f)
f.close()
for j, s in enumerate(ss):
try:
a = shlex.split(s, True)
if len(a) > 0:
if len(a) == 2 and a[0] in self.bool_state:
self.bool_state[a[0]] = (a[1] == 'True')
elif len(a) == 2 and a[0] in self.int_state:
self.int_state[a[0]] = int(a[1])
else:
raise ValueError()
except ValueError:
# this may happen if the state was written by a
# different version -- don't bother the user
logDebug('Error processing line %d of %s.' % (j + 1, statepath))
except IOError:
# bad $HOME value? -- don't bother the user
logDebug('Error reading %s.' % (statepath, ))
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, statepath):
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(statepath, 'w')
f.write("# This state file was generated by %s %s.\n\n" % (APP_NAME, VERSION))
for s in ss:
f.write(s)
f.close()
except IOError:
# bad $HOME value? -- don't bother the user
logDebug('Error writing %s.' % (statepath, ))
# select viewer for a newly selected file in the confirm close dialogue
def __confirmClose_row_activated_cb(self, tree, path, col, model):
self.notebook.set_current_page(self.notebook.page_num(model[path][3]))
# toggle save state for a file listed in the confirm close dialogue
def __confirmClose_toggle_cb(self, cell, path, model):
model[path][0] = not model[path][0]
# returns True if the list of viewers can be closed. The user will be
# given a chance to save any modified files before this method completes.
def confirmCloseViewers(self, viewers):
# make a list of modified files
2016-07-09 21:38:29 +00:00
model = Gtk.ListStore.new([
GObject.TYPE_BOOLEAN,
GObject.TYPE_STRING,
GObject.TYPE_INT,
GObject.TYPE_OBJECT])
2015-11-21 17:57:32 +00:00
for v in viewers:
for f, h in enumerate(v.headers):
if h.has_edits:
model.append((True, v.title, f + 1, v))
if len(model) == 0:
# there are no modified files, the viewers can be closed
return True
# ask the user which files should be saved
2016-07-09 21:38:29 +00:00
dialog = Gtk.MessageDialog(self.get_toplevel(),
Gtk.DialogFlags.DESTROY_WITH_PARENT,
Gtk.MessageType.WARNING,
Gtk.ButtonsType.NONE,
_('Some files have unsaved changes. Select the files to save before closing.'))
2015-11-21 17:57:32 +00:00
dialog.set_resizable(True)
dialog.set_title(APP_NAME)
# add list of files with unsaved changes
2016-07-09 21:38:29 +00:00
sw = Gtk.ScrolledWindow.new()
sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
treeview = Gtk.TreeView.new_with_model(model)
r = Gtk.CellRendererToggle.new()
2015-11-21 17:57:32 +00:00
r.connect('toggled', self.__confirmClose_toggle_cb, model)
2016-07-09 21:38:29 +00:00
column = Gtk.TreeViewColumn(None, r)
2015-11-21 17:57:32 +00:00
column.add_attribute(r, 'active', 0)
treeview.append_column(column)
2016-07-09 21:38:29 +00:00
r = Gtk.CellRendererText.new()
column = Gtk.TreeViewColumn(_('Tab'), r, text=1)
2015-11-21 17:57:32 +00:00
column.set_resizable(True)
column.set_expand(True)
column.set_sort_column_id(1)
treeview.append_column(column)
2016-07-09 21:38:29 +00:00
column = Gtk.TreeViewColumn(_('Pane'), r, text=2)
2015-11-21 17:57:32 +00:00
column.set_resizable(True)
column.set_sort_column_id(2)
treeview.append_column(column)
2016-07-05 03:00:18 +00:00
treeview.connect('row-activated', self.__confirmClose_row_activated_cb, model)
2015-11-21 17:57:32 +00:00
sw.add(treeview)
treeview.show()
dialog.vbox.pack_start(sw, True, True, 0)
sw.show()
# add custom set of action buttons
2016-07-09 21:38:29 +00:00
dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
button = Gtk.Button.new_with_mnemonic(_('Close _Without Saving'))
dialog.add_action_widget(button, Gtk.ResponseType.REJECT)
2015-11-21 17:57:32 +00:00
button.show()
2016-07-09 21:38:29 +00:00
dialog.add_button(Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
2015-11-21 17:57:32 +00:00
response = dialog.run()
dialog.destroy()
2016-07-09 21:38:29 +00:00
if response == Gtk.ResponseType.OK:
2015-11-21 17:57:32 +00:00
# save all checked files
it = model.get_iter_first()
while it:
if model.get_value(it, 0):
f = model.get_value(it, 2) - 1
v = model.get_value(it, 3)
if not v.save_file(f):
# cancel if we failed to save a file
return False
it = model.iter_next(it)
return True
# cancel if the user did not choose 'Close Without Saving' or 'Save'
2016-07-09 21:38:29 +00:00
return response == Gtk.ResponseType.REJECT
2015-11-21 17:57:32 +00:00
# callback for the close button on each tab
def remove_tab_cb(self, widget, data):
nb = self.notebook
if nb.get_n_pages() > 1:
# warn about losing unsaved changes before removing a tab
if self.confirmCloseViewers([ data ]):
self.closed_tabs.append((nb.page_num(data), data, nb.get_tab_label(data)))
nb.remove(data)
nb.set_show_tabs(self.prefs.getBool('tabs_always_show') or nb.get_n_pages() > 1)
elif not self.prefs.getBool('tabs_warn_before_quit') or confirmTabClose(self.get_toplevel()):
self.quit_cb(widget, data)
# callback for RMB menu on notebook tabs
def notebooktab_pick_cb(self, widget, data):
self.notebook.set_current_page(data)
# callback used when a mouse button is pressed on a notebook tab
def notebooktab_button_press_cb(self, widget, event, data):
if event.button == 2:
# remove the tab on MMB
self.remove_tab_cb(widget, data)
elif event.button == 3:
# create a popup to pick a tab for focus on RMB
2016-07-09 21:38:29 +00:00
menu = Gtk.Menu.new()
2015-11-21 17:57:32 +00:00
nb = self.notebook
for i in range(nb.get_n_pages()):
viewer = nb.get_nth_page(i)
2016-07-09 21:38:29 +00:00
item = Gtk.MenuItem.new_with_label(nb.get_tab_label(viewer).get_text())
2015-11-21 17:57:32 +00:00
item.connect('activate', self.notebooktab_pick_cb, i)
menu.append(item)
item.show()
if viewer is data:
menu.select_item(item)
menu.popup(None, None, None, event.button, event.time)
# update window's title
def updateTitle(self, viewer):
title = self.notebook.get_tab_label(viewer).get_text()
self.set_title('%s - %s' % (title, APP_NAME))
# update the message in the status bar
def setStatus(self, s):
sb = self.statusbar
context = sb.get_context_id('Message')
sb.pop(context)
if s is None:
s = ''
sb.push(context, s)
# update the label in the status bar
def setSyntax(self, s):
# update menu
t = self.radio_menus.get('syntax', None)
if t is not None:
t = t[1]
if s in t:
self.menu_update_depth += 1
t[s].set_active(True)
self.menu_update_depth -= 1
# callback used when switching notebook pages
def switch_page_cb(self, widget, ptr, page_num):
viewer = widget.get_nth_page(page_num)
self.updateTitle(viewer)
self.setStatus(viewer.getStatus())
self.setSyntax(viewer.getSyntax())
# 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.updateTitle(widget)
# callback used when a viewer's status changes
def status_changed_cb(self, widget, s):
# update the label in the notebook's tab
if widget is self.getCurrentViewer():
self.setStatus(s)
# callback used when a viewer's syntax changes
def syntax_changed_cb(self, widget, s):
# update the label
if widget is self.getCurrentViewer():
self.setSyntax(s)
# create an empty viewer with 'n' panes
def newFileDiffViewer(self, n):
self.viewer_count += 1
tabname = _('File Merge %d') % (self.viewer_count, )
2016-07-09 21:38:29 +00:00
tab = NotebookTab(tabname, Gtk.STOCK_FILE)
2015-11-21 17:57:32 +00:00
viewer = Diffuse.FileDiffViewer(n, self.prefs, tabname)
tab.button.connect('clicked', self.remove_tab_cb, viewer)
2016-07-05 03:00:18 +00:00
tab.connect('button-press-event', self.notebooktab_button_press_cb, viewer)
2015-11-21 17:57:32 +00:00
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.prefs.getBool('tabs_always_show') or self.notebook.get_n_pages() > 1)
2016-07-05 03:00:18 +00:00
viewer.connect('title-changed', self.title_changed_cb)
viewer.connect('status-changed', self.status_changed_cb)
viewer.connect('syntax-changed', self.syntax_changed_cb)
2015-11-21 17:57:32 +00:00
return viewer
# create a new viewer to display 'items'
def newLoadedFileDiffViewer(self, items):
specs = []
if len(items) == 0:
for i in range(self.prefs.getInt('tabs_default_panes')):
specs.append(FileInfo())
elif len(items) == 1 and len(items[0][1]) == 1:
# one file specified
# determine which other files to compare it with
name, data, label = items[0]
rev, encoding = data[0]
vcs = theVCSs.findByFilename(name, self.prefs)
if vcs is None:
# shift the existing file so it will be in the second pane
specs.append(FileInfo())
specs.append(FileInfo(name, encoding, None, None, label))
else:
if rev is None:
# no revision specified assume defaults
for name, rev in vcs.getFileTemplate(self.prefs, name):
if rev is None:
s = label
else:
s = None
specs.append(FileInfo(name, encoding, vcs, rev, s))
else:
# single revision specified
specs.append(FileInfo(name, encoding, vcs, rev))
specs.append(FileInfo(name, encoding, None, None, label))
else:
# multiple files specified, use one pane for each file
for name, data, label in items:
for rev, encoding in data:
if rev is None:
vcs, s = None, label
else:
vcs, s = theVCSs.findByFilename(name, self.prefs), None
specs.append(FileInfo(name, encoding, vcs, rev, s))
# open a new viewer
viewer = self.newFileDiffViewer(max(2, len(specs)))
# load the files
for i, spec in enumerate(specs):
viewer.load(i, spec)
return viewer
# create a new viewer for 'items'
def createSingleTab(self, items, labels, options):
if len(items) > 0:
self.newLoadedFileDiffViewer(assign_file_labels(items, labels)).setOptions(options)
# create a new viewer for each item in 'items'
def createSeparateTabs(self, items, labels, options):
# all tabs inherit the first tab's revision and encoding specifications
items = [ (name, items[0][1]) for name, data in items ]
for item in assign_file_labels(items, labels):
self.newLoadedFileDiffViewer([ item ]).setOptions(options)
# create a new viewer for each modified file found in 'items'
def createCommitFileTabs(self, items, labels, options):
new_items = []
for item in items:
name, data = item
# get full path to an existing ancessor directory
dn = os.path.abspath(name)
while not os.path.isdir(dn):
dn, old_dn = os.path.dirname(dn), dn
if dn == old_dn:
break
if len(new_items) == 0 or dn != new_items[-1][0]:
new_items.append([ dn, None, [] ])
dst = new_items[-1]
dst[1] = data[-1][1]
dst[2].append(name)
for dn, encoding, names in new_items:
vcs = theVCSs.findByFolder(dn, self.prefs)
if vcs is not None:
try:
for specs in vcs.getCommitTemplate(self.prefs, options['commit'], names):
viewer = self.newFileDiffViewer(len(specs))
for i, spec in enumerate(specs):
name, rev = spec
viewer.load(i, FileInfo(name, encoding, vcs, rev))
viewer.setOptions(options)
except (IOError, OSError, WindowsError):
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.ERROR, _('Error retrieving commits for %s.') % (dn, ))
2015-11-21 17:57:32 +00:00
dialog.run()
dialog.destroy()
# create a new viewer for each modified file found in 'items'
def createModifiedFileTabs(self, items, labels, options):
new_items = []
for item in items:
name, data = item
# get full path to an existing ancessor directory
dn = os.path.abspath(name)
while not os.path.isdir(dn):
dn, old_dn = os.path.dirname(dn), dn
if dn == old_dn:
break
if len(new_items) == 0 or dn != new_items[-1][0]:
new_items.append([ dn, None, [] ])
dst = new_items[-1]
dst[1] = data[-1][1]
dst[2].append(name)
for dn, encoding, names in new_items:
vcs = theVCSs.findByFolder(dn, self.prefs)
if vcs is not None:
try:
for specs in vcs.getFolderTemplate(self.prefs, names):
viewer = self.newFileDiffViewer(len(specs))
for i, spec in enumerate(specs):
name, rev = spec
viewer.load(i, FileInfo(name, encoding, vcs, rev))
viewer.setOptions(options)
except (IOError, OSError, WindowsError):
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.ERROR, _('Error retrieving modifications for %s.') % (dn, ))
2015-11-21 17:57:32 +00:00
dialog.run()
dialog.destroy()
# close all tabs without differences
def closeOnSame(self):
for i in range(self.notebook.get_n_pages() - 1, -1, -1):
if not self.notebook.get_nth_page(i).hasDifferences():
self.notebook.remove_page(i)
# returns True if the application can safely quit
def confirmQuit(self):
nb = self.notebook
return self.confirmCloseViewers([ nb.get_nth_page(i) for i in range(nb.get_n_pages()) ])
# respond to close window request from the window manager
def delete_cb(self, widget, event):
if self.confirmQuit():
2016-07-09 21:38:29 +00:00
Gtk.main_quit()
2015-11-21 17:57:32 +00:00
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 open file menu item
def open_file_in_new_tab_cb(self, widget, data):
specs = None
2016-07-09 21:38:29 +00:00
dialog = FileChooserDialog(_('Open File In New Tab'), self.get_toplevel(), self.prefs, Gtk.FileChooserAction.OPEN, Gtk.STOCK_OPEN, True)
dialog.set_default_response(Gtk.ResponseType.OK)
accept = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
name, encoding = dialog.get_filename(), dialog.get_encoding()
rev = dialog.get_revision().strip()
if rev == '':
rev = None
dialog.destroy()
if accept:
viewer = self.newLoadedFileDiffViewer([ (name, [ (rev, encoding) ], None) ])
self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
viewer.grab_focus()
# callback for the open modified files menu item
def open_modified_files_cb(self, widget, data):
parent = self.get_toplevel()
2016-07-09 21:38:29 +00:00
dialog = FileChooserDialog(_('Choose Folder With Modified Files'), parent, self.prefs, Gtk.FileChooserAction.SELECT_FOLDER, Gtk.STOCK_OPEN)
dialog.set_default_response(Gtk.ResponseType.OK)
accept = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
name, encoding = dialog.get_filename(), dialog.get_encoding()
dialog.destroy()
if accept:
n = self.notebook.get_n_pages()
self.createModifiedFileTabs([ (name, [ (None, encoding) ]) ], [], {})
if self.notebook.get_n_pages() > n:
# we added some new tabs, focus on the first one
self.notebook.set_current_page(n)
self.getCurrentViewer().grab_focus()
else:
2016-07-09 21:38:29 +00:00
m = MessageDialog(parent, Gtk.MessageType.ERROR, _('No modified files found.'))
2015-11-21 17:57:32 +00:00
m.run()
m.destroy()
# callback for the open commit menu item
def open_commit_cb(self, widget, data):
parent = self.get_toplevel()
2016-07-09 21:38:29 +00:00
dialog = FileChooserDialog(_('Choose Folder With Commit'), parent, self.prefs, Gtk.FileChooserAction.SELECT_FOLDER, Gtk.STOCK_OPEN, True)
dialog.set_default_response(Gtk.ResponseType.OK)
accept = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
name, rev, encoding = dialog.get_filename(), dialog.get_revision(), dialog.get_encoding()
dialog.destroy()
if accept:
n = self.notebook.get_n_pages()
self.createCommitFileTabs([ (name, [ (None, encoding) ]) ], [], { 'commit': rev })
if self.notebook.get_n_pages() > n:
# we added some new tabs, focus on the first one
self.notebook.set_current_page(n)
self.getCurrentViewer().grab_focus()
else:
2016-07-09 21:38:29 +00:00
m = MessageDialog(parent, Gtk.MessageType.ERROR, _('No committed files found.'))
2015-11-21 17:57:32 +00:00
m.run()
m.destroy()
# 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 save all menu item
def save_all_cb(self, widget, data):
for i in range(self.notebook.get_n_pages()):
self.notebook.get_nth_page(i).save_all_cb(widget, data)
# callback for the new 2-way file merge menu item
def new_2_way_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_3_way_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 new n-way file merge menu item
def new_n_way_file_merge_cb(self, widget, data):
parent = self.get_toplevel()
dialog = NumericDialog(parent, _('New N-Way File Merge...'), _('Number of panes: '), 4, 2, 16)
2016-07-09 21:38:29 +00:00
okay = (dialog.run() == Gtk.ResponseType.ACCEPT)
2015-11-21 17:57:32 +00:00
npanes = dialog.button.get_value_as_int()
dialog.destroy()
if okay:
viewer = self.newFileDiffViewer(npanes)
self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
viewer.grab_focus()
# 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 undo close tab menu item
def undo_close_tab_cb(self, widget, data):
if len(self.closed_tabs) > 0:
i, tab, tab_label = self.closed_tabs.pop()
self.notebook.insert_page(tab, tab_label, i)
self.notebook.set_current_page(i)
self.notebook.set_show_tabs(True)
# callback for the quit menu item
def quit_cb(self, widget, data):
if self.confirmQuit():
2016-07-09 21:38:29 +00:00
Gtk.main_quit()
2015-11-21 17:57:32 +00:00
# request search parameters if force=True and then perform a search in the
# current viewer pane
def find(self, force, reverse):
viewer = self.getCurrentViewer()
if force or self.search_pattern is None:
# construct search dialog
history = self.search_history
pattern = viewer.getSelectedText()
for c in '\r\n':
i = pattern.find(c)
if i >= 0:
pattern = pattern[:i]
dialog = SearchDialog(self.get_toplevel(), pattern, history)
dialog.match_case_button.set_active(self.bool_state['search_matchcase'])
dialog.backwards_button.set_active(self.bool_state['search_backwards'])
2016-07-09 21:38:29 +00:00
keep = (dialog.run() == Gtk.ResponseType.ACCEPT)
2015-11-21 17:57:32 +00:00
# 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
# determine where to start searching from
reverse ^= self.bool_state['search_backwards']
from_start, more = False, True
while more:
if viewer.find(self.search_pattern, self.bool_state['search_matchcase'], reverse, from_start):
break
if reverse:
msg = _('Phrase not found. Continue from the end of the file?')
else:
msg = _('Phrase not found. Continue from the start of the file?')
2016-07-09 21:38:29 +00:00
dialog = MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg)
dialog.set_default_response(Gtk.ResponseType.OK)
more = (dialog.run() == Gtk.ResponseType.OK)
2015-11-21 17:57:32 +00:00
dialog.destroy()
from_start = True
# 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)
# notify all viewers of changes to the preferences
def preferences_updated(self):
n = self.notebook.get_n_pages()
self.notebook.set_show_tabs(self.prefs.getBool('tabs_always_show') or n > 1)
for i in range(n):
self.notebook.get_nth_page(i).prefsUpdated()
# callback for the preferences menu item
def preferences_cb(self, widget, data):
if self.prefs.runDialog(self.get_toplevel()):
self.preferences_updated()
# callback for all of the syntax highlighting menu items
def syntax_cb(self, widget, data):
# ignore events while we update the menu when switching tabs
# also ignore notification of the newly disabled item
if self.menu_update_depth == 0 and widget.get_active():
self.getCurrentViewer().setSyntax(data)
# callback for the first tab menu item
def first_tab_cb(self, widget, data):
self.notebook.set_current_page(0)
# callback for the previous tab menu item
def previous_tab_cb(self, widget, data):
i, n = self.notebook.get_current_page(), self.notebook.get_n_pages()
self.notebook.set_current_page((n + i - 1) % n)
# callback for the next tab menu item
def next_tab_cb(self, widget, data):
i, n = self.notebook.get_current_page(), self.notebook.get_n_pages()
self.notebook.set_current_page((i + 1) % n)
# callback for the last tab menu item
def last_tab_cb(self, widget, data):
self.notebook.set_current_page(self.notebook.get_n_pages() - 1)
# callback for most menu items and buttons
def button_cb(self, widget, data):
self.getCurrentViewer().button_cb(widget, data)
# display help documenation
def help_contents_cb(self, widget, data):
help_url = None
if isWindows():
# help documentation is distributed as local HTML files
# search for localised manual first
parts = [ 'manual' ]
if lang is not None:
parts = [ 'manual' ]
parts.extend(lang.split('_'))
while len(parts) > 0:
help_file = os.path.join(bin_dir, '_'.join(parts) + '.html')
if os.path.isfile(help_file):
# we found a help file
help_url = path2url(help_file)
break
del parts[-1]
else:
# verify gnome-help is available
browser = None
p = os.environ.get('PATH', None)
if p is not None:
for s in p.split(os.pathsep):
fp = os.path.join(s, 'gnome-help')
if os.path.isfile(fp):
browser = fp
break
if browser is not None:
# find localised help file
if lang is None:
parts = []
else:
parts = lang.split('_')
s = os.path.abspath(os.path.join(bin_dir, '../share/gnome/help/diffuse'))
while True:
if len(parts) > 0:
d = '_'.join(parts)
else:
# fall back to using 'C'
d = 'C'
help_file = os.path.join(os.path.join(s, d), 'diffuse.xml')
if os.path.isfile(help_file):
args = [ browser, path2url(help_file, 'ghelp') ]
# spawnvp is not available on some systems, use spawnv instead
os.spawnv(os.P_NOWAIT, args[0], args)
return
if len(parts) == 0:
break
del parts[-1]
if help_url is None:
# no local help file is available, show on-line help
help_url = WEBSITE + 'manual.html'
# ask for localised manual
if lang is not None:
help_url += '?lang=' + lang
# use a web browser to display the help documentation
webbrowser.open(help_url)
# callback for the about menu item
def about_cb(self, widget, data):
dialog = AboutDialog()
dialog.run()
dialog.destroy()
2016-07-09 21:38:29 +00:00
GObject.signal_new('title-changed', Diffuse.FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (str, ))
GObject.signal_new('status-changed', Diffuse.FileDiffViewer, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (str, ))
GObject.signal_new('title-changed', Diffuse.FileDiffViewer.PaneHeader, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('open', Diffuse.FileDiffViewer.PaneHeader, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('reload', Diffuse.FileDiffViewer.PaneHeader, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('save', Diffuse.FileDiffViewer.PaneHeader, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
GObject.signal_new('save-as', Diffuse.FileDiffViewer.PaneHeader, GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, ())
2015-11-21 17:57:32 +00:00
# create nested subdirectories and return the complete path
def make_subdirs(p, ss):
for s in ss:
p = os.path.join(p, s)
if not os.path.exists(p):
try:
os.mkdir(p)
except IOError:
pass
return p
# process the command line arguments
if __name__ == '__main__':
# find the config directory and create it if it didn't exist
2016-07-09 03:11:43 +00:00
rc_dir, subdirs = os.environ.get('XDG_CONFIG_HOME', None), [u'diffuse']
2015-11-21 17:57:32 +00:00
if rc_dir is None:
2016-07-09 03:11:43 +00:00
rc_dir = os.path.expanduser(u'~')
subdirs.insert(0, u'.config')
2015-11-21 17:57:32 +00:00
rc_dir = make_subdirs(rc_dir, subdirs)
# find the local data directory and create it if it didn't exist
2016-07-09 03:11:43 +00:00
data_dir, subdirs = os.environ.get('XDG_DATA_HOME', None), [u'diffuse']
2015-11-21 17:57:32 +00:00
if data_dir is None:
2016-07-09 03:11:43 +00:00
data_dir = os.path.expanduser(u'~')
subdirs[:0] = [ u'.local', u'share' ]
2015-11-21 17:57:32 +00:00
data_dir = make_subdirs(data_dir, subdirs)
# load resource files
i, rc_files = 1, []
2016-07-09 03:19:12 +00:00
if i < argc and args[i] == '--no-rcfile':
2015-11-21 17:57:32 +00:00
i += 1
2016-07-09 03:19:12 +00:00
elif i + 1 < argc and args[i] == '--rcfile':
2015-11-21 17:57:32 +00:00
i += 1
rc_files.append(args[i])
i += 1
else:
# parse system wide then personal initialisation files
if isWindows():
2016-07-09 03:11:43 +00:00
rc_file = os.path.join(bin_dir, u'diffuserc')
2015-11-21 17:57:32 +00:00
else:
2016-07-09 03:11:43 +00:00
rc_file = os.path.join(bin_dir, u'../../etc/diffuserc')
for rc_file in rc_file, os.path.join(rc_dir, u'diffuserc'):
2015-11-21 17:57:32 +00:00
if os.path.isfile(rc_file):
rc_files.append(rc_file)
for rc_file in rc_files:
# convert to absolute path so the location of any processing errors are
# reported with normalised file names
rc_file = os.path.abspath(rc_file)
try:
theResources.parse(rc_file)
except IOError:
logError(_('Error reading %s.') % (rc_file, ))
diff = Diffuse(rc_dir)
# load state
statepath = os.path.join(data_dir, 'state')
diff.loadState(statepath)
# process remaining command line arguments
encoding, revs, close_on_same = None, [], False
specs, had_specs, labels = [], False, []
funcs = { 'modified': diff.createModifiedFileTabs,
'commit': diff.createCommitFileTabs,
'separate': diff.createSeparateTabs,
'single': diff.createSingleTab }
mode, options = 'single', {}
while i < argc:
arg = args[i]
if len(arg) > 0 and arg[0] == '-':
if i + 1 < argc and arg in [ '-c', '--commit' ]:
# specified revision
funcs[mode](specs, labels, options)
i += 1
rev = args[i]
specs, labels, options = [], [], { 'commit': args[i] }
mode = 'commit'
elif arg in [ '-D', '--close-if-same' ]:
close_on_same = True
elif i + 1 < argc and arg in [ '-e', '--encoding' ]:
i += 1
encoding = args[i]
encoding = encodings.aliases.aliases.get(encoding, encoding)
elif arg in [ '-m', '--modified' ]:
funcs[mode](specs, labels, options)
specs, labels, options = [], [], {}
mode = 'modified'
elif i + 1 < argc and arg in [ '-r', '--revision' ]:
# specified revision
i += 1
2016-07-04 00:22:54 +00:00
revs.append((args[i], encoding))
2015-11-21 17:57:32 +00:00
elif arg in [ '-s', '--separate' ]:
funcs[mode](specs, labels, options)
specs, labels, options = [], [], {}
# open items in separate tabs
mode = 'separate'
elif arg in [ '-t', '--tab' ]:
funcs[mode](specs, labels, options)
specs, labels, options = [], [], {}
# start a new tab
mode = 'single'
elif i + 1 < argc and arg in [ '-V', '--vcs-order' ]:
i += 1
2016-07-04 00:22:54 +00:00
diff.prefs.setString('vcs_search_order', args[i])
2015-11-21 17:57:32 +00:00
diff.preferences_updated()
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 [ '-E', '--ignore-end-of-line' ]:
diff.prefs.setBool('display_ignore_endofline', True)
diff.prefs.setBool('align_ignore_endofline', 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 i + 1 < argc and arg == '-L':
i += 1
2016-07-04 00:22:54 +00:00
labels.append(args[i])
2015-11-21 17:57:32 +00:00
elif i + 1 < argc and arg == '--line':
i += 1
try:
options['line'] = int(args[i])
except ValueError:
logError(_('Error parsing line number.'))
elif arg == '--null-file':
# add a blank file pane
if mode == 'single' or mode == 'separate':
if len(revs) == 0:
revs.append((None, encoding))
specs.append((None, revs))
revs = []
had_specs = True
else:
logError(_('Skipping unknown argument "%s".') % (args[i], ))
else:
filename = diff.prefs.convertToNativePath(args[i])
if (mode == 'single' or mode == 'separate') and os.path.isdir(filename):
if len(specs) > 0:
filename = os.path.join(filename, os.path.basename(specs[-1][0]))
else:
logError(_('Error processing argument "%s". Directory not expected.') % (args[i], ))
filename = None
if filename is not None:
if len(revs) == 0:
revs.append((None, encoding))
specs.append((filename, revs))
revs = []
had_specs = True
i += 1
if mode in [ 'modified', 'commit' ] and len(specs) == 0:
specs.append((os.curdir, [ (None, encoding) ]))
had_specs = True
funcs[mode](specs, labels, options)
# create a file diff viewer if the command line arguments haven't
# implicitly created any
if not had_specs:
diff.newLoadedFileDiffViewer([])
elif close_on_same:
diff.closeOnSame()
nb = diff.notebook
n = nb.get_n_pages()
if n > 0:
nb.set_show_tabs(diff.prefs.getBool('tabs_always_show') or n > 1)
nb.get_nth_page(0).grab_focus()
diff.show()
2016-07-09 21:38:29 +00:00
Gtk.main()
2015-11-21 17:57:32 +00:00
# save state
diff.saveState(statepath)