diff --git a/src/main.py b/src/main.py index b307479..ab461db 100644 --- a/src/main.py +++ b/src/main.py @@ -44,6 +44,7 @@ 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 @@ -54,452 +55,6 @@ whitespace = ' \t\n\r\x0b\x0c' theResources = Resources() theVCSs = VcsRegistry() -# 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 = utils.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 - # convenience method for creating a menu according to a template def createMenu(specs, radio=None, accel_group=None): menu = Gtk.Menu.new() @@ -830,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: @@ -2747,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): @@ -2764,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 @@ -3494,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' @@ -3552,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 @@ -3807,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() @@ -4637,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 1d56cd5..5555778 100644 --- a/src/meson.build +++ b/src/meson.build @@ -33,6 +33,7 @@ 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 index 06698df..0d8c2a6 100644 --- a/src/resources.py +++ b/src/resources.py @@ -222,7 +222,7 @@ class Resources: elif token == 'Ctrl': modifiers |= Gdk.ModifierType.CONTROL_MASK elif token == 'Alt': - modifiers |= Gdk.ModifierType.MOD1_MASK + modifiers |= Gdk.ModifierType.MOD1_MASK # pylint: disable=no-member elif len(token) == 0 or token[0] == '_': raise ValueError() else: diff --git a/src/utils.py b/src/utils.py index 3cd5795..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 @@ -230,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'):