Extract Preferences in its own module

This commit is contained in:
Romain Failliot 2021-11-20 15:50:35 -05:00
parent 6f4a5f3250
commit ac88e3dacb
5 changed files with 526 additions and 461 deletions

View File

@ -44,6 +44,7 @@ from urllib.parse import urlparse
from diffuse import utils from diffuse import utils
from diffuse import constants from diffuse import constants
from diffuse.preferences import Preferences
from diffuse.resources import Resources from diffuse.resources import Resources
from diffuse.vcs.vcs_registry import VcsRegistry from diffuse.vcs.vcs_registry import VcsRegistry
@ -54,452 +55,6 @@ whitespace = ' \t\n\r\x0b\x0c'
theResources = Resources() theResources = Resources()
theVCSs = VcsRegistry() 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 # convenience method for creating a menu according to a template
def createMenu(specs, radio=None, accel_group=None): def createMenu(specs, radio=None, accel_group=None):
menu = Gtk.Menu.new() menu = Gtk.Menu.new()
@ -830,11 +385,6 @@ def removeNullLines(blocks, lines_set):
bn += blocks[bi] bn += blocks[bi]
bi += 1 bi += 1
def nullToEmpty(s):
if s is None:
s = ''
return s
# returns true if the string only contains whitespace characters # returns true if the string only contains whitespace characters
def isBlank(s): def isBlank(s):
for c in whitespace: 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 # return the results for pane 'f' if idx=0 and 'f+1' if idx=1
def getDiffRanges(self, f, i, idx, flag): def getDiffRanges(self, f, i, idx, flag):
result = [] result = []
s1 = nullToEmpty(self.getLineText(f, i)) s1 = utils.null_to_empty(self.getLineText(f, i))
s2 = nullToEmpty(self.getLineText(f + 1, i)) s2 = utils.null_to_empty(self.getLineText(f + 1, i))
# ignore blank lines if specified # ignore blank lines if specified
if self.prefs.getBool('display_ignore_blanklines') and isBlank(s1) and isBlank(s2): 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'): if self.prefs.getBool('display_ignore_endofline'):
s = utils.strip_eol(s) s = utils.strip_eol(s)
s1 = nullToEmpty(self.getCompareString(f, i)) s1 = utils.null_to_empty(self.getCompareString(f, i))
s2 = nullToEmpty(self.getCompareString(f + 1, i)) s2 = utils.null_to_empty(self.getCompareString(f + 1, i))
# build a mapping from characters in compare string to those in the # build a mapping from characters in compare string to those in the
# original string # original string
@ -3494,7 +3044,7 @@ class FileDiffViewer(Gtk.Grid):
def im_retrieve_surrounding_cb(self, im): def im_retrieve_surrounding_cb(self, im):
if self.mode == CHAR_MODE: if self.mode == CHAR_MODE:
# notify input method of text surrounding the cursor # 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) im.set_surrounding(s, len(s), self.current_char)
# callback for 'focus_in_event' # callback for 'focus_in_event'
@ -3552,7 +3102,7 @@ class FileDiffViewer(Gtk.Grid):
col = self.cursor_column col = self.cursor_column
if col < 0: if col < 0:
# find the current cursor column # 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) col = self.stringWidth(s)
if event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Down ]: if event.keyval in [ Gdk.KEY_Up, Gdk.KEY_Down ]:
delta = 1 delta = 1
@ -3807,7 +3357,7 @@ class FileDiffViewer(Gtk.Grid):
needs_block = (self.undoblock is None) needs_block = (self.undoblock is None)
if needs_block: if needs_block:
self.openUndoBlock() self.openUndoBlock()
self.replaceText(nullToEmpty(text)) self.replaceText(utils.null_to_empty(text))
if needs_block: if needs_block:
self.closeUndoBlock() self.closeUndoBlock()
@ -4637,7 +4187,7 @@ class FileChooserDialog(Gtk.FileChooserDialog):
label = Gtk.Label.new(_('Encoding: ')) label = Gtk.Label.new(_('Encoding: '))
hbox.pack_start(label, False, False, 0) hbox.pack_start(label, False, False, 0)
label.show() 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) hbox.pack_start(entry, False, False, 5)
entry.show() entry.show()
if rev: if rev:

View File

@ -33,6 +33,7 @@ configure_file(
diffuse_sources = [ diffuse_sources = [
'__init__.py', '__init__.py',
'main.py', 'main.py',
'preferences.py',
'resources.py', 'resources.py',
'utils.py', 'utils.py',
] ]

479
src/preferences.py Normal file
View File

@ -0,0 +1,479 @@
# Diffuse: a graphical tool for merging and comparing text files.
#
# Copyright (C) 2019 Derrick Moser <derrick_moser@yahoo.com>
# Copyright (C) 2021 Romain Failliot <romain.failliot@foolstep.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the 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()

View File

@ -222,7 +222,7 @@ class Resources:
elif token == 'Ctrl': elif token == 'Ctrl':
modifiers |= Gdk.ModifierType.CONTROL_MASK modifiers |= Gdk.ModifierType.CONTROL_MASK
elif token == 'Alt': elif token == 'Alt':
modifiers |= Gdk.ModifierType.MOD1_MASK modifiers |= Gdk.ModifierType.MOD1_MASK # pylint: disable=no-member
elif len(token) == 0 or token[0] == '_': elif len(token) == 0 or token[0] == '_':
raise ValueError() raise ValueError()
else: else:

View File

@ -47,6 +47,29 @@ class MessageDialog(Gtk.MessageDialog):
text=s) text=s)
self.set_title(constants.APP_NAME) 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 # platform test
def isWindows(): def isWindows():
return os.name == 'nt' return os.name == 'nt'
@ -69,7 +92,7 @@ def logError(msg):
def logErrorAndDialog(msg,parent=None): def logErrorAndDialog(msg,parent=None):
logError(msg) logError(msg)
dialog = MessageDialog(parent, Gtk.MessageType.ERROR, msg) dialog = MessageDialog(parent, Gtk.MessageType.ERROR, msg)
dialog.run() dialog.run() # pylint: disable=no-member
dialog.destroy() dialog.destroy()
# create nested subdirectories and return the complete path # create nested subdirectories and return the complete path
@ -230,6 +253,18 @@ def splitlines(text: str) -> list[str]:
def readlines(fd): def readlines(fd):
return _strip_eols(splitlines(fd.read())) 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 # use the program's location as a starting place to search for supporting files
# such as icon and help documentation # such as icon and help documentation
if hasattr(sys, 'frozen'): if hasattr(sys, 'frozen'):