diff --git a/.pylintrc b/.pylintrc index ad9e286..47d6cd6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -87,23 +87,24 @@ disable=raw-checker-failed, deprecated-pragma, use-symbolic-message-instead, - # temporary silenced messages + # temporary silenced messages (ordered alphabetically) + duplicate-code, + fixme, + import-error, invalid-name, - missing-module-docstring, missing-class-docstring, missing-function-docstring, - import-error, + missing-module-docstring, no-self-use, + too-few-public-methods, too-many-arguments, too-many-branches, + too-many-instance-attributes, too-many-locals, - too-many-statements, too-many-nested-blocks, - too-few-public-methods, - unused-argument, - fixme, + too-many-statements, unnecessary-dict-index-lookup, - duplicate-code + unused-argument # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/CHANGELOG.md b/CHANGELOG.md index cb15d78..ea36afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a flatpak job in the CI ### Changed +- main.py slimmed down by about 1000 lines ### Fixed diff --git a/src/main.py b/src/main.py index e57616e..ab461db 100644 --- a/src/main.py +++ b/src/main.py @@ -44,954 +44,16 @@ from urllib.parse import urlparse from diffuse import utils from diffuse import constants +from diffuse.preferences import Preferences +from diffuse.resources import Resources from diffuse.vcs.vcs_registry import VcsRegistry # 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' -# 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, identify 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 - -def readconfiglines(fd): - return fd.read().replace('\r', '').split('\n') - -# 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) - modifiers = Gdk.ModifierType(0) - key = None - for token in v.split('+'): - if token == 'Shift': - modifiers |= Gdk.ModifierType.SHIFT_MASK - elif token == 'Ctrl': - modifiers |= Gdk.ModifierType.CONTROL_MASK - elif token == 'Alt': - modifiers |= Gdk.ModifierType.MOD1_MASK - elif len(token) == 0 or token[0] == '_': - raise ValueError() - else: - token = 'KEY_' + token - if not hasattr(Gdk, token): - raise ValueError() - key = getattr(Gdk, token) - 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: - utils.logDebug(f'Warning: unknown colour "{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: - utils.logDebug(f'Warning: unknown float "{symbol}"') - self.floats[symbol] = v = 0.5 - return v - - # string resources - def getString(self, symbol): - try: - return self.strings[symbol] - except KeyError: - utils.logDebug(f'Warning: unknown string "{symbol}"') - self.strings[symbol] = v = '' - return v - - # syntax highlighting - def getSyntaxNames(self): - return list(self.syntaxes.keys()) - - 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') - ss = readconfiglines(f) - 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 - if args[0] == 'import' and len(args) == 2: - path = os.path.expanduser(args[1]) - # relative paths are relative to the parsed file - path = os.path.join(utils.globEscape(os.path.dirname(file_name)), path) - 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 - elif args[0] == 'keybinding' and len(args) == 4: - self.setKeyBinding(args[1], args[2], args[3]) - # eg. set the regular background colour to white - # colour text_background 1.0 1.0 1.0 - elif args[0] in [ 'colour', 'color' ] and len(args) == 5: - self.colours[args[1]] = Colour(float(args[2]), float(args[3]), float(args[4])) - # eg. set opacity of the line_selection colour - # float line_selection_opacity 0.4 - elif args[0] == 'float' and len(args) == 3: - self.floats[args[1]] = float(args[2]) - # eg. set the help browser - # string help_browser gnome-help - elif args[0] == 'string' and len(args) == 3: - self.strings[args[1]] = args[2] - if args[1] == 'difference_colours': - self.setDifferenceColours(args[2]) - # eg. start a syntax specification for Python - # syntax Python normal text - # where 'normal' is the name of the default state and - # 'text' is the classification of all characters not - # explicitly matched by a syntax highlighting rule - elif args[0] == 'syntax' and (len(args) == 2 or len(args) == 4): - 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 '#' - elif args[0] == 'syntax_pattern' and self.current_syntax is not None and len(args) >= 5: - flags = 0 - for arg in args[5:]: - if arg == 'ignorecase': - flags |= re.IGNORECASE - else: - raise ValueError() - self.current_syntax.addPattern(args[1], args[2], args[3], re.compile(args[4], flags)) - # eg. default to the Python syntax rules when viewing - # a file ending with '.py' or '.pyw' - # syntax_files Python '\.pyw?$' - elif args[0] == 'syntax_files' and (len(args) == 2 or len(args) == 3): - key = args[1] - if len(args) == 2: - # remove file pattern for a syntax specification - try: - del self.syntax_file_patterns[key] - except KeyError: - pass - else: - flags = 0 - if utils.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$' - elif args[0] == 'syntax_magic' and len(args) > 1: - 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:]: - if arg == 'ignorecase': - 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: - utils.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 -class EncodingMenu(Gtk.Box): - def __init__(self, prefs, autodetect=False): - Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) - self.combobox = combobox = Gtk.ComboBoxText.new() - 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 -class FileEntry(Gtk.Box): - def __init__(self, parent, title): - Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) - self.toplevel = parent - self.title = title - self.entry = entry = Gtk.Entry.new() - self.pack_start(entry, True, True, 0) - entry.show() - button = Gtk.Button.new() - image = Gtk.Image.new() - image.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.MENU) - button.add(image) - image.show() - button.connect('clicked', self.chooseFile) - self.pack_start(button, False, False, 0) - button.show() - - # action performed when the pick file button is pressed - def chooseFile(self, widget): - dialog = Gtk.FileChooserDialog(self.title, self.toplevel, Gtk.FileChooserAction.OPEN, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) - dialog.set_current_folder(os.path.realpath(os.curdir)) - if dialog.run() == Gtk.ResponseType.OK: - self.entry.set_text(dialog.get_filename()) - dialog.destroy() - - def set_text(self, s): - self.entry.set_text(s) - - def get_text(self): - return self.entry.get_text() - -# adaptor class to allow a Gtk.FontButton to be read like a Gtk.Entry -class FontButton(Gtk.FontButton): - def __init__(self): - Gtk.FontButton.__init__(self) - - def get_text(self): - return self.get_font_name() - -# 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 utils.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') % constants.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 utils.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 utils.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 ]) - vcs_template.append(vcs_folders_template) - - 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') - ss = readconfiglines(f) - 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 - utils.logDebug(f'Error processing line {j + 1} of {self.path}.') - except IOError: - # bad $HOME value? -- don't bother the user - utils.logDebug(f'Error reading {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): - dialog = Gtk.Dialog(_('Preferences'), parent=parent, destroy_with_parent=True) - dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT) - dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) - - 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) # pylint: disable=no-member - w.show() - - accept = (dialog.run() == Gtk.ResponseType.OK) - 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(f'{k} {v}\n') - for k, v in self.int_prefs.items(): - if v != self.default_int_prefs[k]: - ss.append(f'{k} {v}\n') - for k, v in self.string_prefs.items(): - if v != self.default_string_prefs[k]: - v_escaped = v.replace('\\', '\\\\').replace('"', '\\"') - ss.append(f'{k} "{v_escaped}"\n') - ss.sort() - f = open(self.path, 'w') - f.write(f'# This prefs file was generated by {constants.APP_NAME} {constants.VERSION}.\n\n') - for s in ss: - f.write(s) - f.close() - except IOError: - utils.logErrorAndDialog(_('Error writing %s.') % (self.path, ), parent) - dialog.destroy() - return accept - - # recursively traverses 'template' to build the preferences dialogue - # and the individual preference widgets into 'widgets' so their value - # can be easily queried by the caller - def _buildPrefsDialog(self, parent, widgets, template): - type = template[0] - if type == 'FolderSet': - notebook = Gtk.Notebook.new() - notebook.set_border_width(10) - i = 1 - while i < len(template): - label = Gtk.Label.new(template[i]) - i += 1 - w = self._buildPrefsDialog(parent, widgets, template[i]) - i += 1 - notebook.append_page(w, label) - w.show() - label.show() - return notebook - else: - table = Gtk.Grid.new() - table.set_border_width(10) - for i, tpl in enumerate(template[1:]): - type = tpl[0] - if type == 'FolderSet': - w = self._buildPrefsDialog(parent, widgets, tpl) - table.attach(w, 0, i, 2, 1) - w.show() - elif type == 'Boolean': - button = Gtk.CheckButton.new_with_mnemonic(tpl[3]) - button.set_active(self.bool_prefs[tpl[1]]) - widgets[tpl[1]] = button - table.attach(button, 1, i, 1, 1) - button.connect('toggled', self._toggled_cb, widgets, tpl[1]) - button.show() - else: - label = Gtk.Label.new(tpl[3] + ': ') - label.set_xalign(1.0) - label.set_yalign(0.5) - table.attach(label, 0, i, 1, 1) - label.show() - if tpl[0] in [ 'Font', 'Integer' ]: - entry = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) - if tpl[0] == 'Font': - button = FontButton() - button.set_font(self.string_prefs[tpl[1]]) - else: - button = Gtk.SpinButton.new(Gtk.Adjustment.new(self.int_prefs[tpl[1]], tpl[4], tpl[5], 1, 0, 0), 1.0, 0) - 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: - entry = Gtk.Entry.new() - widgets[tpl[1]] = entry - entry.set_text(self.string_prefs[tpl[1]]) - table.attach(entry, 1, i, 1, 1) - 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: - encoding = encoding.lower().replace('-', '').replace('_', '') - for m in magic.get(encoding, [ b'' ]): - if s.startswith(m): - break - else: - continue - return str(s, encoding=encoding), encoding - except (UnicodeDecodeError, LookupError): - pass - return ''.join([ chr(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 utils.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 +theVCSs = VcsRegistry() # convenience method for creating a menu according to a template def createMenu(specs, radio=None, accel_group=None): @@ -1117,8 +179,6 @@ def convert_to_format(s, format): s += '\r' return s -theVCSs = VcsRegistry() - # utility method to step advance an adjustment def step_adjustment(adj, delta): v = adj.get_value() + delta @@ -1325,11 +385,6 @@ def removeNullLines(blocks, lines_set): 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: @@ -3242,8 +2297,8 @@ class FileDiffViewer(Gtk.Grid): # 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)) + s1 = utils.null_to_empty(self.getLineText(f, i)) + s2 = utils.null_to_empty(self.getLineText(f + 1, i)) # ignore blank lines if specified if self.prefs.getBool('display_ignore_blanklines') and isBlank(s1) and isBlank(s2): @@ -3259,8 +2314,8 @@ class FileDiffViewer(Gtk.Grid): if self.prefs.getBool('display_ignore_endofline'): s = utils.strip_eol(s) - s1 = nullToEmpty(self.getCompareString(f, i)) - s2 = nullToEmpty(self.getCompareString(f + 1, i)) + s1 = utils.null_to_empty(self.getCompareString(f, i)) + s2 = utils.null_to_empty(self.getCompareString(f + 1, i)) # build a mapping from characters in compare string to those in the # original string @@ -3989,7 +3044,7 @@ class FileDiffViewer(Gtk.Grid): 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 = utils.null_to_empty(self.getLineText(self.current_pane, self.current_line)) im.set_surrounding(s, len(s), self.current_char) # callback for 'focus_in_event' @@ -4047,7 +3102,7 @@ class FileDiffViewer(Gtk.Grid): col = self.cursor_column if col < 0: # find the current cursor column - s = nullToEmpty(self.getLineText(f, i))[:self.current_char] + s = utils.null_to_empty(self.getLineText(f, i))[:self.current_char] col = self.stringWidth(s) if event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Down ]: delta = 1 @@ -4302,7 +3357,7 @@ class FileDiffViewer(Gtk.Grid): needs_block = (self.undoblock is None) if needs_block: self.openUndoBlock() - self.replaceText(nullToEmpty(text)) + self.replaceText(utils.null_to_empty(text)) if needs_block: self.closeUndoBlock() @@ -5132,7 +4187,7 @@ class FileChooserDialog(Gtk.FileChooserDialog): label = Gtk.Label.new(_('Encoding: ')) hbox.pack_start(label, False, False, 0) label.show() - self.encoding = entry = EncodingMenu(prefs, action in [ Gtk.FileChooserAction.OPEN, Gtk.FileChooserAction.SELECT_FOLDER ]) + self.encoding = entry = utils.EncodingMenu(prefs, action in [ Gtk.FileChooserAction.OPEN, Gtk.FileChooserAction.SELECT_FOLDER ]) hbox.pack_start(entry, False, False, 5) entry.show() if rev: diff --git a/src/meson.build b/src/meson.build index 1d93c65..5555778 100644 --- a/src/meson.build +++ b/src/meson.build @@ -33,6 +33,8 @@ configure_file( diffuse_sources = [ '__init__.py', 'main.py', + 'preferences.py', + 'resources.py', 'utils.py', ] diff --git a/src/preferences.py b/src/preferences.py new file mode 100644 index 0000000..197cded --- /dev/null +++ b/src/preferences.py @@ -0,0 +1,479 @@ +# Diffuse: a graphical tool for merging and comparing text files. +# +# Copyright (C) 2019 Derrick Moser +# Copyright (C) 2021 Romain Failliot +# +# 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 License, 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; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import codecs +import encodings +import os +import shlex +import sys + +# pylint: disable=wrong-import-position +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +# pylint: enable=wrong-import-position + +from diffuse import utils +from diffuse import constants + +# 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 utils.isWindows(): + svk_bin = 'svk.bat' + else: + svk_bin = 'svk' + + auto_detect_codecs = [ 'utf_8', 'utf_16', 'latin_1' ] + e = utils.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 ] + # pylint: disable=line-too-long + 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') % constants.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') ] + ], + ] + # pylint: disable=line-too-long + + # 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 utils.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 utils.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 ]) + vcs_template.append(vcs_folders_template) + + 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: + with open(self.path, 'r', encoding='utf-8') as f: + ss = utils.readconfiglines(f) + 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 + utils.logDebug(f'Error processing line {j + 1} of {self.path}.') + except IOError: + # bad $HOME value? -- don't bother the user + utils.logDebug(f'Error reading {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): + dialog = Gtk.Dialog(_('Preferences'), parent=parent, destroy_with_parent=True) + dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT) + dialog.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) + + 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) # pylint: disable=no-member + w.show() + + accept = (dialog.run() == Gtk.ResponseType.OK) # pylint: disable=no-member + if accept: + for k in self.bool_prefs: + self.bool_prefs[k] = widgets[k].get_active() + for k in self.int_prefs: + self.int_prefs[k] = widgets[k].get_value_as_int() + for k in self.string_prefs: + self.string_prefs[k] = utils.null_to_empty(widgets[k].get_text()) + try: + ss = [] + for k, v in self.bool_prefs.items(): + if v != self.default_bool_prefs[k]: + ss.append(f'{k} {v}\n') + for k, v in self.int_prefs.items(): + if v != self.default_int_prefs[k]: + ss.append(f'{k} {v}\n') + for k, v in self.string_prefs.items(): + if v != self.default_string_prefs[k]: + v_escaped = v.replace('\\', '\\\\').replace('"', '\\"') + ss.append(f'{k} "{v_escaped}"\n') + ss.sort() + with open(self.path, 'w', encoding='utf-8') as f: + # pylint: disable-next=line-too-long + f.write(f'# This prefs file was generated by {constants.APP_NAME} {constants.VERSION}.\n\n') + for s in ss: + f.write(s) + except IOError: + utils.logErrorAndDialog(_('Error writing %s.') % (self.path, ), parent) + 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): + tpl_section = template[0] + if tpl_section == 'FolderSet': + notebook = Gtk.Notebook.new() + notebook.set_border_width(10) + i = 1 + while i < len(template): + label = Gtk.Label.new(template[i]) + i += 1 + w = self._buildPrefsDialog(parent, widgets, template[i]) + i += 1 + notebook.append_page(w, label) + w.show() + label.show() + return notebook + + table = Gtk.Grid.new() + table.set_border_width(10) + for i, tpl in enumerate(template[1:]): + tpl_section = tpl[0] + if tpl_section == 'FolderSet': + w = self._buildPrefsDialog(parent, widgets, tpl) + table.attach(w, 0, i, 2, 1) + w.show() + elif tpl_section == 'Boolean': + button = Gtk.CheckButton.new_with_mnemonic(tpl[3]) + button.set_active(self.bool_prefs[tpl[1]]) + widgets[tpl[1]] = button + table.attach(button, 1, i, 1, 1) + button.connect('toggled', self._toggled_cb, widgets, tpl[1]) + button.show() + else: + label = Gtk.Label.new(tpl[3] + ': ') + label.set_xalign(1.0) + label.set_yalign(0.5) + table.attach(label, 0, i, 1, 1) + label.show() + if tpl[0] in [ 'Font', 'Integer' ]: + entry = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) + if tpl[0] == 'Font': + button = _FontButton() + button.set_font(self.string_prefs[tpl[1]]) + else: + button = Gtk.SpinButton.new( + Gtk.Adjustment.new(self.int_prefs[tpl[1]], tpl[4], tpl[5], 1, 0, 0), + 1.0, + 0) + widgets[tpl[1]] = button + entry.pack_start(button, False, False, 0) + button.show() + else: + if tpl[0] == 'Encoding': + entry = utils.EncodingMenu(self) + entry.set_text(tpl[3]) + elif tpl[0] == 'File': + entry = _FileEntry(parent, tpl[3]) + else: + entry = Gtk.Entry.new() + widgets[tpl[1]] = entry + entry.set_text(self.string_prefs[tpl[1]]) + table.attach(entry, 1, i, 1, 1) + 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: + encoding = encoding.lower().replace('-', '').replace('_', '') + for m in magic.get(encoding, [ b'' ]): + if s.startswith(m): + break + else: + continue + return str(s, encoding=encoding), encoding + except (UnicodeDecodeError, LookupError): + pass + return ''.join([ chr(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 utils.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 + +# adaptor class to allow a Gtk.FontButton to be read like a Gtk.Entry +class _FontButton(Gtk.FontButton): + def __init__(self): + Gtk.FontButton.__init__(self) + + def get_text(self): + # pylint: disable=no-member + return self.get_font_name() + +# text entry widget with a button to help pick file names +class _FileEntry(Gtk.Box): + def __init__(self, parent, title): + # pylint: disable=no-member + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) + self.toplevel = parent + self.title = title + self.entry = entry = Gtk.Entry.new() + self.pack_start(entry, True, True, 0) + entry.show() + button = Gtk.Button.new() + image = Gtk.Image.new() + image.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.MENU) + button.add(image) + image.show() + button.connect('clicked', self.chooseFile) + self.pack_start(button, False, False, 0) + button.show() + + # action performed when the pick file button is pressed + def chooseFile(self, widget): + # pylint: disable=no-member + dialog = Gtk.FileChooserDialog( + self.title, + self.toplevel, + Gtk.FileChooserAction.OPEN, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) + dialog.set_current_folder(os.path.realpath(os.curdir)) + if dialog.run() == Gtk.ResponseType.OK: + self.entry.set_text(dialog.get_filename()) + dialog.destroy() + + def set_text(self, s): + self.entry.set_text(s) + + def get_text(self): + return self.entry.get_text() diff --git a/src/resources.py b/src/resources.py new file mode 100644 index 0000000..0d8c2a6 --- /dev/null +++ b/src/resources.py @@ -0,0 +1,537 @@ +# Diffuse: a graphical tool for merging and comparing text files. +# +# Copyright (C) 2019 Derrick Moser +# Copyright (C) 2021 Romain Failliot +# +# 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 License, 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; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +# 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. + +import glob +import os +import re +import shlex + +# pylint: disable=wrong-import-position +import gi +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk +# pylint: enable=wrong-import-position + +from diffuse import utils + +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) + modifiers = Gdk.ModifierType(0) + key = None + for token in v.split('+'): + if token == 'Shift': + modifiers |= Gdk.ModifierType.SHIFT_MASK + elif token == 'Ctrl': + modifiers |= Gdk.ModifierType.CONTROL_MASK + elif token == 'Alt': + modifiers |= Gdk.ModifierType.MOD1_MASK # pylint: disable=no-member + elif len(token) == 0 or token[0] == '_': + raise ValueError() + else: + token = 'KEY_' + token + if not hasattr(Gdk, token): + raise ValueError() + key = getattr(Gdk, token) + 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: + return None + + 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: + utils.logDebug(f'Warning: unknown colour "{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: + utils.logDebug(f'Warning: unknown float "{symbol}"') + self.floats[symbol] = v = 0.5 + return v + + # string resources + def getString(self, symbol): + try: + return self.strings[symbol] + except KeyError: + utils.logDebug(f'Warning: unknown string "{symbol}"') + self.strings[symbol] = v = '' + return v + + # syntax highlighting + def getSyntaxNames(self): + return list(self.syntaxes.keys()) + + 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 + return None + + # 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) + with open(file_name, 'r', encoding='utf-8') as f: + ss = utils.readconfiglines(f) + + # 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 + if args[0] == 'import' and len(args) == 2: + path = os.path.expanduser(args[1]) + # relative paths are relative to the parsed file + path = os.path.join(utils.globEscape(os.path.dirname(file_name)), path) + 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 + elif args[0] == 'keybinding' and len(args) == 4: + self.setKeyBinding(args[1], args[2], args[3]) + # eg. set the regular background colour to white + # colour text_background 1.0 1.0 1.0 + elif args[0] in [ 'colour', 'color' ] and len(args) == 5: + self.colours[args[1]] = _Colour(float(args[2]), float(args[3]), float(args[4])) + # eg. set opacity of the line_selection colour + # float line_selection_opacity 0.4 + elif args[0] == 'float' and len(args) == 3: + self.floats[args[1]] = float(args[2]) + # eg. set the help browser + # string help_browser gnome-help + elif args[0] == 'string' and len(args) == 3: + self.strings[args[1]] = args[2] + if args[1] == 'difference_colours': + self.setDifferenceColours(args[2]) + # eg. start a syntax specification for Python + # syntax Python normal text + # where 'normal' is the name of the default state and + # 'text' is the classification of all characters not + # explicitly matched by a syntax highlighting rule + elif args[0] == 'syntax' and (len(args) == 2 or len(args) == 4): + 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 '#' + elif ( + args[0] == 'syntax_pattern' and + self.current_syntax is not None and + len(args) >= 5 + ): + flags = 0 + for arg in args[5:]: + if arg == 'ignorecase': + flags |= re.IGNORECASE + else: + raise ValueError() + self.current_syntax.addPattern( + args[1], + args[2], + args[3], + re.compile(args[4], flags)) + # eg. default to the Python syntax rules when viewing + # a file ending with '.py' or '.pyw' + # syntax_files Python '\.pyw?$' + elif args[0] == 'syntax_files' and (len(args) == 2 or len(args) == 3): + key = args[1] + if len(args) == 2: + # remove file pattern for a syntax specification + try: + del self.syntax_file_patterns[key] + except KeyError: + pass + else: + flags = 0 + if utils.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$' + elif args[0] == 'syntax_magic' and len(args) > 1: + 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:]: + if arg == 'ignorecase': + flags |= re.IGNORECASE + else: + raise ValueError() + self.syntax_magic_patterns[key] = re.compile(args[2], flags) + else: + raise ValueError() + # pylint: disable-next=bare-except + except: # Grr... the 're' module throws weird errors + #except ValueError: + utils.logError(_(f'Error processing line {i + 1} of {file_name}.')) + +# 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, identify 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 diff --git a/src/utils.py b/src/utils.py index 9d5c18b..dec2319 100644 --- a/src/utils.py +++ b/src/utils.py @@ -47,6 +47,29 @@ class MessageDialog(Gtk.MessageDialog): text=s) self.set_title(constants.APP_NAME) +# widget to help pick an encoding +class EncodingMenu(Gtk.Box): + def __init__(self, prefs, autodetect=False): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) + self.combobox = combobox = Gtk.ComboBoxText.new() + 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) # pylint: disable=no-member + 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() + return self.encodings[i] if i >= 0 else None + # platform test def isWindows(): return os.name == 'nt' @@ -69,7 +92,7 @@ def logError(msg): def logErrorAndDialog(msg,parent=None): logError(msg) dialog = MessageDialog(parent, Gtk.MessageType.ERROR, msg) - dialog.run() + dialog.run() # pylint: disable=no-member dialog.destroy() # create nested subdirectories and return the complete path @@ -190,6 +213,9 @@ def popenReadLines(dn, cmd, prefs, bash_pref, success_results=None): return _strip_eols(splitlines(popenRead( dn, cmd, prefs, bash_pref, success_results).decode('utf-8', errors='ignore'))) +def readconfiglines(fd): + return fd.read().replace('\r', '').split('\n') + # escape special glob characters def globEscape(s): m = { c: f'[{c}]' for c in '[]?*' } @@ -227,6 +253,18 @@ def splitlines(text: str) -> list[str]: def readlines(fd): return _strip_eols(splitlines(fd.read())) +# map an encoding name to its standard form +def norm_encoding(e): + if e is not None: + return e.replace('-', '_').lower() + return None + +def null_to_empty(s): + if s is None: + s = '' + return s + + # use the program's location as a starting place to search for supporting files # such as icon and help documentation if hasattr(sys, 'frozen'):