PageRenderTime 130ms CodeModel.GetById 58ms app.highlight 62ms RepoModel.GetById 1ms app.codeStats 1ms

/tortoisehg/hgqt/settings.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 1015 lines | 920 code | 74 blank | 21 comment | 151 complexity | 1170bcfa172b9acf5422de7a38cc8aa8 MD5 | raw file
   1# settings.py - Configuration dialog for TortoiseHg and Mercurial
   2#
   3# Copyright 2010 Steve Borho <steve@borho.org>
   4#
   5# This software may be used and distributed according to the terms of the
   6# GNU General Public License version 2, incorporated herein by reference.
   7
   8import os
   9
  10from mercurial import ui, util, error
  11
  12from tortoisehg.util import hglib, settings, paths, wconfig, i18n
  13from tortoisehg.hgqt.i18n import _
  14from tortoisehg.hgqt import qtlib, qscilib, thgrepo
  15
  16from PyQt4.QtCore import *
  17from PyQt4.QtGui import *
  18
  19# Technical Debt
  20#   stacked widget or pages need to be scrollable
  21#   we need a consistent icon set
  22#   connect to thgrepo.configChanged signal and refresh
  23
  24_unspecstr = _('<unspecified>')
  25ENTRY_WIDTH = 300
  26
  27class SettingsCombo(QComboBox):
  28    def __init__(self, parent=None, **opts):
  29        QComboBox.__init__(self, parent, toolTip=opts['tooltip'])
  30        self.opts = opts
  31        self.setEditable(opts.get('canedit', False))
  32        self.setValidator(opts.get('validator', None))
  33        self.defaults = opts.get('defaults', [])
  34        if self.defaults and self.isEditable():
  35            self.setCompleter(QCompleter(self.defaults, self))
  36        self.curvalue = None
  37        self.loaded = False
  38        if 'nohist' in opts:
  39            self.previous = []
  40        else:
  41            settings = opts['settings']
  42            slist = settings.value('settings/'+opts['cpath']).toStringList()
  43            self.previous = [s for s in slist if s]
  44        self.setFixedWidth(ENTRY_WIDTH)
  45
  46    def resetList(self):
  47        self.clear()
  48        ucur = hglib.tounicode(self.curvalue)
  49        if self.opts.get('defer') and not self.loaded:
  50            if self.curvalue == None: # unspecified
  51                self.addItem(_unspecstr)
  52            else:
  53                self.addItem(ucur or '...')
  54            return
  55        self.addItem(_unspecstr)
  56        curindex = None
  57        for s in self.defaults:
  58            if ucur == s:
  59                curindex = self.count()
  60            self.addItem(s)
  61        if self.defaults and self.previous:
  62            self.insertSeparator(len(self.defaults)+1)
  63        for m in self.previous:
  64            if ucur == m and not curindex:
  65                curindex = self.count()
  66            self.addItem(m)
  67        if curindex is not None:
  68            self.setCurrentIndex(curindex)
  69        elif self.curvalue is None:
  70            self.setCurrentIndex(0)
  71        elif self.curvalue:
  72            self.addItem(ucur)
  73            self.setCurrentIndex(self.count()-1)
  74        else:  # empty string
  75            self.setEditText(ucur)
  76
  77    def showPopup(self):
  78        if self.opts.get('defer') and not self.loaded:
  79            self.defaults = self.opts['defer']()
  80            self.loaded = True
  81            self.resetList()
  82        QComboBox.showPopup(self)
  83
  84    ## common APIs for all edit widgets
  85
  86    def setValue(self, curvalue):
  87        self.curvalue = curvalue
  88        self.resetList()
  89
  90    def value(self):
  91        utext = self.currentText()
  92        if utext == _unspecstr:
  93            return None
  94        if 'nohist' in self.opts or utext in self.defaults + self.previous or not utext:
  95            return hglib.fromunicode(utext)
  96        self.previous.insert(0, utext)
  97        self.previous = self.previous[:10]
  98        settings = QSettings()
  99        settings.setValue('settings/'+self.opts['cpath'], self.previous)
 100        return hglib.fromunicode(utext)
 101
 102    def isDirty(self):
 103        return self.value() != self.curvalue
 104
 105
 106class PasswordEntry(QLineEdit):
 107    def __init__(self, parent=None, **opts):
 108        QLineEdit.__init__(self, parent, toolTip=opts['tooltip'])
 109        self.opts = opts
 110        self.curvalue = None
 111        self.setEchoMode(QLineEdit.Password)
 112        self.setFixedWidth(ENTRY_WIDTH)
 113
 114    ## common APIs for all edit widgets
 115
 116    def setValue(self, curvalue):
 117        self.curvalue = curvalue
 118        if curvalue:
 119            self.setText(hglib.tounicode(curvalue))
 120        else:
 121            self.setText('')
 122
 123    def value(self):
 124        utext = self.text()
 125        return utext and hglib.fromunicode(utext) or None
 126
 127    def isDirty(self):
 128        return self.value() != self.curvalue
 129
 130class FontEntry(QPushButton):
 131    def __init__(self, parent=None, **opts):
 132        QPushButton.__init__(self, parent, toolTip=opts['tooltip'])
 133        self.opts = opts
 134        self.curvalue = None
 135        self.clicked.connect(self.on_clicked)
 136        cpath = self.opts['cpath']
 137        assert cpath.startswith('tortoisehg.')
 138        self.fname = cpath[11:]
 139        self.setFixedWidth(ENTRY_WIDTH)
 140
 141    def on_clicked(self, checked):
 142        def newFont(font):
 143            self.setText(font.toString())
 144            thgf.setFont(font)
 145        thgf = qtlib.getfont(self.fname)
 146        origfont = self.currentFont() or thgf.font()
 147        dlg = QFontDialog(self)
 148        dlg.currentFontChanged.connect(newFont)
 149        font, isok = dlg.getFont(origfont, self)
 150        self.setText(font.toString())
 151        thgf.setFont(font)
 152
 153    def currentFont(self):
 154        """currently selected QFont if specified"""
 155        if not self.value():
 156            return None
 157
 158        f = QFont()
 159        f.fromString(self.value())
 160        return f
 161
 162    ## common APIs for all edit widgets
 163
 164    def setValue(self, curvalue):
 165        self.curvalue = curvalue
 166        if curvalue:
 167            self.setText(hglib.tounicode(curvalue))
 168        else:
 169            self.setText(_unspecstr)
 170
 171    def value(self):
 172        utext = self.text()
 173        if utext == _unspecstr:
 174            return None
 175        else:
 176            return hglib.fromunicode(utext)
 177
 178    def isDirty(self):
 179        return self.value() != self.curvalue
 180
 181class SettingsCheckBox(QCheckBox):
 182    def __init__(self, parent=None, **opts):
 183        QCheckBox.__init__(self, parent, toolTip=opts['tooltip'])
 184        self.opts = opts
 185        self.curvalue = None
 186        self.setText(opts['label'])
 187        self.valfunc = self.opts['valfunc']
 188        self.toggled.connect(self.valfunc)
 189
 190    def setValue(self, curvalue):
 191        if self.curvalue == None:
 192            self.curvalue = curvalue
 193        self.setChecked(curvalue)
 194
 195    def value(self):
 196        return self.isChecked()
 197
 198    def isDirty(self):
 199        return self.value() != self.curvalue
 200
 201
 202def genEditCombo(opts, defaults=[]):
 203    opts['canedit'] = True
 204    opts['defaults'] = defaults
 205    return SettingsCombo(**opts)
 206
 207def genIntEditCombo(opts):
 208    'EditCombo, only allows integer values'
 209    opts['canedit'] = True
 210    opts['validator'] = QIntValidator()
 211    return SettingsCombo(**opts)
 212
 213def genPasswordEntry(opts):
 214    'Generate a password entry box'
 215    return PasswordEntry(**opts)
 216
 217def genDefaultCombo(opts, defaults=[]):
 218    'user must select from a list'
 219    opts['defaults'] = defaults
 220    opts['nohist'] = True
 221    return SettingsCombo(**opts)
 222
 223def genBoolCombo(opts):
 224    'true, false, unspecified'
 225    opts['defaults'] = ['True', 'False']
 226    opts['nohist'] = True
 227    return SettingsCombo(**opts)
 228
 229def genDeferredCombo(opts, func):
 230    'Values retrieved from a function at popup time'
 231    opts['defer'] = func
 232    opts['nohist'] = True
 233    return SettingsCombo(**opts)
 234
 235def genFontEdit(opts):
 236    return FontEntry(**opts)
 237
 238def findDiffTools():
 239    return hglib.difftools(ui.ui())
 240
 241def findMergeTools():
 242    return hglib.mergetools(ui.ui())
 243
 244def genCheckBox(opts):
 245    opts['nohist'] = True
 246    return SettingsCheckBox(**opts)
 247
 248class _fi(object):
 249    """Information of each field"""
 250    __slots__ = ('label', 'cpath', 'values', 'tooltip',
 251                 'restartneeded', 'globalonly')
 252
 253    def __init__(self, label, cpath, values, tooltip,
 254                 restartneeded=False, globalonly=False):
 255        self.label = label
 256        self.cpath = cpath
 257        self.values = values
 258        self.tooltip = tooltip
 259        self.restartneeded = restartneeded
 260        self.globalonly = globalonly
 261
 262INFO = (
 263({'name': 'general', 'label': 'TortoiseHg', 'icon': 'thg_logo'}, (
 264    _fi(_('UI Language'), 'tortoisehg.ui.language',
 265        (genDeferredCombo, i18n.availablelanguages),
 266        _('Specify your preferred user interface language (restart needed)'),
 267        restartneeded=True, globalonly=True),
 268    _fi(_('Three-way Merge Tool'), 'ui.merge',
 269        (genDeferredCombo, findMergeTools),
 270        _('Graphical merge program for resolving merge conflicts.  If left'
 271        ' unspecified, Mercurial will use the first applicable tool it finds'
 272        ' on your system or use its internal merge tool that leaves conflict'
 273        ' markers in place.  Chose internal:merge to force conflict markers,'
 274        ' internal:prompt to always select local or other, or internal:dump'
 275        ' to leave files in the working directory for manual merging')),
 276    _fi(_('Visual Diff Tool'), 'tortoisehg.vdiff',
 277        (genDeferredCombo, findDiffTools),
 278        _('Specify visual diff tool, as described in the [merge-tools]'
 279          ' section of your Mercurial configuration files.  If left'
 280          ' unspecified, TortoiseHg will use the selected merge tool.'
 281          ' Failing that it uses the first applicable tool it finds.')),
 282    _fi(_('Visual Editor'), 'tortoisehg.editor', genEditCombo,
 283        _('Specify the visual editor used to view files.  Format:<br>'
 284          'myeditor -flags [$FILE --num=$LINENUM][--search $SEARCH]<br><br>'
 285          'See <a href="%s">OpenAtLine</a>'
 286          % 'http://bitbucket.org/tortoisehg/thg/wiki/OpenAtLine')),
 287    _fi(_('Shell'), 'tortoisehg.shell', genEditCombo,
 288        _('Specify your preferred terminal shell application')),
 289    _fi(_('Immediate Operations'), 'tortoisehg.immediate', genEditCombo,
 290        _('Space separated list of shell operations you would like '
 291          'to be performed immediately, without user interaction. '
 292          'Commands are "add remove revert forget". '
 293          'Default: None (leave blank)')),
 294    _fi(_('Poll Frequency'), 'tortoisehg.pollfreq', genIntEditCombo,
 295        _('The period (in milliseconds) between modification time polling of '
 296          'key repository files, looking for changes.  Values under '
 297          '100ms are ignored.  Default: 500')),
 298    _fi(_('Tab Width'), 'tortoisehg.tabwidth', genIntEditCombo,
 299        _('Specify the number of spaces that tabs expand to in various'
 300        ' TortoiseHg windows.'
 301        ' Default: 0, Not expanded')),
 302    _fi(_('Max Diff Size'), 'tortoisehg.maxdiff', genIntEditCombo,
 303        _('The maximum size file (in KB) that TortoiseHg will '
 304        'show changes for in the changelog, status, and commit windows.'
 305        ' A value of zero implies no limit.  Default: 1024 (1MB)')),
 306    _fi(_('Capture stderr'), 'tortoisehg.stderrcapt', genBoolCombo,
 307        _('Redirect stderr to a buffer which is parsed at the end of'
 308        ' the process for runtime errors. Default: True')),
 309    _fi(_('Fork GUI'), 'tortoisehg.guifork', genBoolCombo,
 310        _('When running from the command line, fork a background'
 311        ' process to run graphical dialogs.  Default: True')),
 312    _fi(_('Full Path Title'), 'tortoisehg.fullpath', genBoolCombo,
 313        _('Show a full directory path of the repository in the dialog title'
 314        ' instead of just the root directory name.  Default: False')),
 315    _fi(_('Auto-resolve merges'), 'tortoisehg.autoresolve', genBoolCombo,
 316        _('Indicates whether TortoiseHg should attempt to automatically resolve'
 317          ' changes from both sides to the same file, and only report merge'
 318          ' conflicts when this is not possible. When False, all files with'
 319          ' changes on both sides of the merge will report as conflicting, even'
 320          ' if the edits are to different parts of the file. In either case,'
 321          ' when conflicts occur, the user will be invited to review and'
 322          ' resolve changes manually. Default: False.')),
 323    )),
 324
 325({'name': 'log', 'label': _('Workbench'), 'icon': 'menulog'}, (
 326    _fi(_('Author Coloring'), 'tortoisehg.authorcolor', genBoolCombo,
 327        _('Color changesets by author name.  If not enabled,'
 328        ' the changes are colored green for merge, red for'
 329        ' non-trivial parents, black for normal.'
 330        ' Default: False')),
 331    _fi(_('Task Tabs'), 'tortoisehg.tasktabs', (genDefaultCombo,
 332         ['east', 'west', 'off']),
 333        _('Show tabs along the side of the bottom half of each repo'
 334        ' widget allowing one to switch task tabs without using the toolbar.'
 335        ' Default: off')),
 336    _fi(_('Long Summary'), 'tortoisehg.longsummary', genBoolCombo,
 337        _('If true, concatenate multiple lines of changeset summary'
 338        ' until they reach 80 characters.'
 339        ' Default: False')),
 340    _fi(_('Log Batch Size'), 'tortoisehg.graphlimit', genIntEditCombo,
 341        _('The number of revisions to read and display in the'
 342        ' changelog viewer in a single batch.'
 343        ' Default: 500')),
 344    _fi(_('Dead Branches'), 'tortoisehg.deadbranch', genEditCombo,
 345        _('Comma separated list of branch names that should be ignored'
 346        ' when building a list of branch names for a repository.'
 347        ' Default: None (leave blank)')),
 348    _fi(_('Branch Colors'), 'tortoisehg.branchcolors', genEditCombo,
 349        _('Space separated list of branch names and colors of the form'
 350        ' branch:#XXXXXX. Spaces and colons in the branch name must be'
 351        ' escaped using a backslash (\\). Likewise some other characters'
 352        ' can be escaped in this way, e.g. \\u0040 will be decoded to the'
 353        ' @ character, and \\n to a linefeed.'
 354        ' Default: None (leave blank)')),
 355    _fi(_('Hide Tags'), 'tortoisehg.hidetags', genEditCombo,
 356        _('Space separated list of tags that will not be shown.'
 357        ' Useful example: Specify "qbase qparent qtip" to hide the'
 358        ' standard tags inserted by the Mercurial Queues Extension.'
 359        ' Default: None (leave blank)')),
 360    _fi(_('After Pull Operation'), 'tortoisehg.postpull', (genDefaultCombo,
 361        ['none', 'update', 'fetch', 'rebase']),
 362        _('Operation which is performed directly after a successful pull.'
 363        ' update equates to pull --update, fetch equates to the fetch'
 364        ' extension, rebase equates to pull --rebase.  Default: none')),
 365    )),
 366
 367({'name': 'commit', 'label': _('Commit'), 'icon': 'menucommit'}, (
 368    _fi(_('Username'), 'ui.username', genEditCombo,
 369        _('Name associated with commits.  The common format is<br>'
 370          '"Full Name &lt;email@example.com&gt;"')),
 371    _fi(_('Summary Line Length'), 'tortoisehg.summarylen', genIntEditCombo,
 372       _('Suggested length of commit message lines. A red vertical'
 373         ' line will mark this length.  CTRL-E will reflow the current'
 374         ' paragraph to the specified line length. Default: 80')),
 375    _fi(_('Close After Commit'), 'tortoisehg.closeci', genBoolCombo,
 376        _('Close the commit tool after every successful'
 377          ' commit.  Default: False')),
 378    _fi(_('Push After Commit'), 'tortoisehg.cipushafter', genEditCombo,
 379        _('Attempt to push to specified URL or alias after each successful'
 380          ' commit.  Default: No push')),
 381    _fi(_('Auto Commit List'), 'tortoisehg.autoinc', genEditCombo,
 382       _('Comma separated list of files that are automatically included'
 383         ' in every commit.  Intended for use only as a repository setting.'
 384         '  Default: None (leave blank)')),
 385    _fi(_('Auto Exclude List'), 'tortoisehg.ciexclude', genEditCombo,
 386       _('Comma separated list of files that are automatically unchecked'
 387         ' when the status, and commit dialogs are opened.'
 388         '  Default: None (leave blank)')),
 389    _fi(_('English Messages'), 'tortoisehg.engmsg', genBoolCombo,
 390       _('Generate English commit messages even if LANGUAGE or LANG'
 391         ' environment variables are set to a non-English language.'
 392         ' This setting is used by the Merge, Tag and Backout dialogs.'
 393         '  Default: False')),
 394    )),
 395
 396({'name': 'web', 'label': _('Web Server'), 'icon': 'proxy'}, (
 397    _fi(_('Name'), 'web.name', genEditCombo,
 398        _('Repository name to use in the web interface, and by TortoiseHg '
 399        ' as a shorthand name.  Default is the working directory.')),
 400    _fi(_('Description'), 'web.description', genEditCombo,
 401        _("Textual description of the repository's purpose or"
 402        ' contents.')),
 403    _fi(_('Contact'), 'web.contact', genEditCombo,
 404        _('Name or email address of the person in charge of the'
 405        ' repository.')),
 406    _fi(_('Style'), 'web.style', (genDefaultCombo,
 407        ['paper', 'monoblue', 'coal', 'spartan', 'gitweb', 'old']),
 408        _('Which template map style to use')),
 409    _fi(_('Archive Formats'), 'web.allow_archive',
 410        (genEditCombo, ['bz2', 'gz', 'zip']),
 411        _('Comma separated list of archive formats allowed for'
 412        ' downloading')),
 413    _fi(_('Port'), 'web.port', genIntEditCombo, _('Port to listen on')),
 414    _fi(_('Push Requires SSL'), 'web.push_ssl', genBoolCombo,
 415        _('Whether to require that inbound pushes be transported'
 416        ' over SSL to prevent password sniffing.')),
 417    _fi(_('Stripes'), 'web.stripes', genIntEditCombo,
 418        _('How many lines a "zebra stripe" should span in multiline output.'
 419        ' Default is 1; set to 0 to disable.')),
 420    _fi(_('Max Files'), 'web.maxfiles', genIntEditCombo,
 421        _('Maximum number of files to list per changeset. Default: 10')),
 422    _fi(_('Max Changes'), 'web.maxchanges', genIntEditCombo,
 423        _('Maximum number of changes to list on the changelog. '
 424          'Default: 10')),
 425    _fi(_('Allow Push'), 'web.allow_push', (genEditCombo, ['*']),
 426        _('Whether to allow pushing to the repository. If empty or not'
 427        ' set, push is not allowed. If the special value "*", any remote'
 428        ' user can push, including unauthenticated users. Otherwise, the'
 429        ' remote user must have been authenticated, and the authenticated'
 430        ' user name must be present in this list (separated by whitespace'
 431        ' or ","). The contents of the allow_push list are examined after'
 432        ' the deny_push list.')),
 433    _fi(_('Deny Push'), 'web.deny_push', (genEditCombo, ['*']),
 434        _('Whether to deny pushing to the repository. If empty or not set,'
 435        ' push is not denied. If the special value "*", all remote users'
 436        ' are denied push. Otherwise, unauthenticated users are all'
 437        ' denied, and any authenticated user name present in this list'
 438        ' (separated by whitespace or ",") is also denied. The contents'
 439        ' of the deny_push list are examined before the allow_push list.')),
 440    _fi(_('Encoding'), 'web.encoding', (genEditCombo, ['UTF-8']),
 441        _('Character encoding name')),
 442    )),
 443
 444({'name': 'proxy', 'label': _('Proxy'), 'icon': QStyle.SP_DriveNetIcon}, (
 445    _fi(_('Host'), 'http_proxy.host', genEditCombo,
 446        _('Host name and (optional) port of proxy server, for'
 447        ' example "myproxy:8000"')),
 448    _fi(_('Bypass List'), 'http_proxy.no', genEditCombo,
 449        _('Optional. Comma-separated list of host names that'
 450        ' should bypass the proxy')),
 451    _fi(_('User'), 'http_proxy.user', genEditCombo,
 452        _('Optional. User name to authenticate with at the proxy server')),
 453    _fi(_('Password'), 'http_proxy.passwd', genPasswordEntry,
 454        _('Optional. Password to authenticate with at the proxy server')),
 455    )),
 456
 457({'name': 'email', 'label': _('Email'), 'icon': QStyle.SP_ArrowForward}, (
 458    _fi(_('From'), 'email.from', genEditCombo,
 459        _('Email address to use in the "From" header and for'
 460        ' the SMTP envelope')),
 461    _fi(_('To'), 'email.to', genEditCombo,
 462        _('Comma-separated list of recipient email addresses')),
 463    _fi(_('Cc'), 'email.cc', genEditCombo,
 464        _('Comma-separated list of carbon copy recipient email addresses')),
 465    _fi(_('Bcc'), 'email.bcc', genEditCombo,
 466        _('Comma-separated list of blind carbon copy recipient'
 467        ' email addresses')),
 468    _fi(_('method'), 'email.method', (genEditCombo, ['smtp']),
 469        _('Optional. Method to use to send email messages. If value is'
 470        ' "smtp" (default), use SMTP (configured below).  Otherwise, use as'
 471        ' name of program to run that acts like sendmail (takes "-f" option'
 472        ' for sender, list of recipients on command line, message on stdin).'
 473        ' Normally, setting this to "sendmail" or "/usr/sbin/sendmail"'
 474        ' is enough to use sendmail to send messages.')),
 475    _fi(_('SMTP Host'), 'smtp.host', genEditCombo,
 476        _('Host name of mail server')),
 477    _fi(_('SMTP Port'), 'smtp.port', genIntEditCombo,
 478        _('Port to connect to on mail server.'
 479        ' Default: 25')),
 480    _fi(_('SMTP TLS'), 'smtp.tls', genBoolCombo,
 481        _('Connect to mail server using TLS.'
 482        ' Default: False')),
 483    _fi(_('SMTP Username'), 'smtp.username', genEditCombo,
 484        _('Username to authenticate to mail server with')),
 485    _fi(_('SMTP Password'), 'smtp.password', genPasswordEntry,
 486        _('Password to authenticate to mail server with')),
 487    _fi(_('Local Hostname'), 'smtp.local_hostname', genEditCombo,
 488        _('Hostname the sender can use to identify itself to the'
 489        ' mail server.')),
 490    )),
 491
 492({'name': 'diff', 'label': _('Diff'),
 493  'icon': QStyle.SP_FileDialogContentsView}, (
 494    _fi(_('Patch EOL'), 'patch.eol', (genDefaultCombo,
 495        ['auto', 'strict', 'crlf', 'lf']),
 496        _('Normalize file line endings during and after patch to lf or'
 497        ' crlf.  Strict does no normalization.  Auto does per-file'
 498        ' detection, and is the recommended setting.'
 499        ' Default: strict')),
 500    _fi(_('Git Format'), 'diff.git', genBoolCombo,
 501        _('Use git extended diff header format.'
 502        ' Default: False')),
 503    _fi(_('MQ Git Format'), 'mq.git', (genDefaultCombo,
 504        ['auto', 'keep', 'yes', 'no']),
 505     _("If set to 'keep', mq will obey the [diff] section configuration while"
 506       " preserving existing git patches upon qrefresh. If set to 'yes' or"
 507       " 'no', mq will override the [diff] section and always generate git or"
 508       " regular patches, possibly losing data in the second case.")),
 509    _fi(_('No Dates'), 'diff.nodates', genBoolCombo,
 510        _('Do not include modification dates in diff headers.'
 511        ' Default: False')),
 512    _fi(_('Show Function'), 'diff.showfunc', genBoolCombo,
 513        _('Show which function each change is in.'
 514        ' Default: False')),
 515    _fi(_('Ignore White Space'), 'diff.ignorews', genBoolCombo,
 516        _('Ignore white space when comparing lines.'
 517        ' Default: False')),
 518    _fi(_('Ignore WS Amount'), 'diff.ignorewsamount', genBoolCombo,
 519        _('Ignore changes in the amount of white space.'
 520        ' Default: False')),
 521    _fi(_('Ignore Blank Lines'), 'diff.ignoreblanklines', genBoolCombo,
 522        _('Ignore changes whose lines are all blank.'
 523        ' Default: False')),
 524    )),
 525
 526({'name': 'fonts', 'label': _('Fonts'), 'icon': 'fonts'}, (
 527    _fi(_('Message Font'), 'tortoisehg.fontcomment', genFontEdit,
 528        _('Font used to display commit messages. Default: monospace 10'),
 529       globalonly=True),
 530    _fi(_('Diff Font'), 'tortoisehg.fontdiff', genFontEdit,
 531        _('Font used to display text differences. Default: monospace 10'),
 532       globalonly=True),
 533    _fi(_('List Font'), 'tortoisehg.fontlist', genFontEdit,
 534        _('Font used to display file lists. Default: sans 9'),
 535       globalonly=True),
 536    _fi(_('ChangeLog Font'), 'tortoisehg.fontlog', genFontEdit,
 537        _('Font used to display changelog data. Default: monospace 10'),
 538       globalonly=True),
 539    _fi(_('Output Font'), 'tortoisehg.fontoutputlog', genFontEdit,
 540        _('Font used to display output messages. Default: sans 8'),
 541       globalonly=True),
 542    )),
 543
 544({'name': 'extensions', 'label': _('Extensions'), 'icon': 'extensions'}, (
 545    )),
 546
 547({'name': 'reviewboard', 'label': _('Review Board'), 'icon': 'reviewboard'}, (
 548    _fi(_('Server'), 'reviewboard.server', genEditCombo,
 549        _('Path to review board'
 550        ' example "http://demo.reviewboard.org"')),
 551    _fi(_('User'), 'reviewboard.user', genEditCombo,
 552        _('User name to authenticate with review board')),
 553    _fi(_('Password'), 'reviewboard.password', genPasswordEntry,
 554        _('Password to authenticate with review board')),
 555    _fi(_('Server Repository ID'), 'reviewboard.repoid', genEditCombo,
 556        _('The default repository id for this repo on the review board server')),
 557    _fi(_('Target Groups'), 'reviewboard.target_groups', genEditCombo,
 558        _('A comma separated list of target groups')),
 559    _fi(_('Target People'), 'reviewboard.target_people', genEditCombo,
 560        _('A comma separated list of target people')),
 561    )),
 562
 563)
 564
 565CONF_GLOBAL = 0
 566CONF_REPO   = 1
 567
 568class SettingsDialog(QDialog):
 569    'Dialog for editing Mercurial.ini or hgrc'
 570    def __init__(self, configrepo=False, focus=None, parent=None, root=None):
 571        QDialog.__init__(self, parent)
 572        self.setWindowTitle(_('TortoiseHg Settings'))
 573        self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
 574
 575        if not hasattr(wconfig.config(), 'write'):
 576            qtlib.ErrorMsgBox(_('Iniparse package not found'),
 577                         _("Can't change settings without iniparse package - "
 578                           'view is readonly.'), parent=self)
 579            print 'Please install http://code.google.com/p/iniparse/'
 580
 581        layout = QVBoxLayout()
 582        self.setLayout(layout)
 583
 584        s = QSettings()
 585        self.settings = s
 586        self.restoreGeometry(s.value('settings/geom').toByteArray())
 587
 588        def username():
 589            return util.username() or os.environ.get('USERNAME') or _('User')
 590
 591        self.conftabs = QTabWidget()
 592        layout.addWidget(self.conftabs)
 593        utab = SettingsForm(rcpath=util.user_rcpath(), focus=focus)
 594        self.conftabs.addTab(utab, qtlib.geticon('settings_user'),
 595                             _("%s's global settings") % username())
 596        utab.restartRequested.connect(self._pushRestartRequest)
 597
 598        try:
 599            if root is None:
 600                root = paths.find_root()
 601            if root:
 602                repo = thgrepo.repository(ui.ui(), root)
 603            else:
 604                repo = None
 605        except error.RepoError:
 606            repo = None
 607            if configrepo:
 608                qtlib.ErrorMsgBox(_('No repository found'),
 609                                  _('no repo at ') + root, parent=self)
 610
 611        if repo:
 612            reporcpath = os.sep.join([repo.root, '.hg', 'hgrc'])
 613            rtab = SettingsForm(rcpath=reporcpath, focus=focus)
 614            self.conftabs.addTab(rtab, qtlib.geticon('settings_repo'),
 615                                 _('%s repository settings') % repo.displayname)
 616            rtab.restartRequested.connect(self._pushRestartRequest)
 617
 618        BB = QDialogButtonBox
 619        bb = QDialogButtonBox(BB.Ok|BB.Cancel)
 620        bb.accepted.connect(self.accept)
 621        bb.rejected.connect(self.reject)
 622        layout.addWidget(bb)
 623        self.bb = bb
 624
 625        self._restartreqs = set()
 626
 627        self.conftabs.setCurrentIndex(configrepo and CONF_REPO or CONF_GLOBAL)
 628
 629    def isDirty(self):
 630        return util.any(self.conftabs.widget(i).isDirty()
 631                        for i in xrange(self.conftabs.count()))
 632
 633    @pyqtSlot(unicode)
 634    def _pushRestartRequest(self, key):
 635        self._restartreqs.add(unicode(key))
 636
 637    def applyChanges(self):
 638        for i in xrange(self.conftabs.count()):
 639            self.conftabs.widget(i).applyChanges()
 640        if self._restartreqs:
 641            qtlib.InfoMsgBox(_('Settings'),
 642                             _('Restart all TortoiseHg applications'
 643                               ' for the following changes to take effect:'),
 644                             ', '.join(sorted(self._restartreqs)))
 645            self._restartreqs.clear()
 646
 647    def canExit(self):
 648        if self.isDirty():
 649            ret = qtlib.CustomPrompt(_('Confirm Exit'),
 650                            _('Apply changes before exit?'), self,
 651                            (_('&Yes'), _('&No (discard changes)'),
 652                         _  ('Cancel')), default=2, esc=2).run()
 653            if ret == 2:
 654                return False
 655            elif ret == 0:
 656                self.applyChanges()
 657                return True
 658        return True
 659
 660    def accept(self):
 661        self.applyChanges()
 662        s = self.settings
 663        s.setValue('settings/geom', self.saveGeometry())
 664        s.sync()
 665        QDialog.accept(self)
 666
 667    def reject(self):
 668        if not self.canExit():
 669            return
 670        s = self.settings
 671        s.setValue('settings/geom', self.saveGeometry())
 672        s.sync()
 673        QDialog.reject(self)
 674
 675class SettingsForm(QWidget):
 676    """Widget for each settings file"""
 677
 678    restartRequested = pyqtSignal(unicode)
 679
 680    def __init__(self, rcpath, focus=None, parent=None):
 681        super(SettingsForm, self).__init__(parent)
 682
 683        if isinstance(rcpath, (list, tuple)):
 684            self.rcpath = rcpath
 685        else:
 686            self.rcpath = [rcpath]
 687
 688        layout = QVBoxLayout()
 689        self.setLayout(layout)
 690
 691        tophbox = QHBoxLayout()
 692        layout.addLayout(tophbox)
 693
 694        self.fnedit = QLineEdit()
 695        self.fnedit.setReadOnly(True)
 696        self.fnedit.setFrame(False)
 697        self.fnedit.setFocusPolicy(Qt.NoFocus)
 698        self.fnedit.setStyleSheet('QLineEdit { background: transparent; }')
 699        edit = QPushButton(_('Edit File'))
 700        edit.clicked.connect(self.editClicked)
 701        self.editbtn = edit
 702        reload = QPushButton(_('Reload'))
 703        reload.clicked.connect(self.reloadClicked)
 704        self.reloadbtn = reload
 705        tophbox.addWidget(QLabel(_('Settings File:')))
 706        tophbox.addWidget(self.fnedit)
 707        tophbox.addWidget(edit)
 708        tophbox.addWidget(reload)
 709
 710        bothbox = QHBoxLayout()
 711        layout.addLayout(bothbox)
 712        pageList = QListWidget()
 713        pageList.setResizeMode(QListView.Fixed)
 714        stack = QStackedWidget()
 715        bothbox.addWidget(pageList, 0)
 716        bothbox.addWidget(stack, 1)
 717        pageList.currentRowChanged.connect(stack.setCurrentIndex)
 718
 719        self.pages = {}
 720        self.stack = stack
 721        self.pageList = pageList
 722
 723        desctext = QTextBrowser()
 724        desctext.setOpenExternalLinks(True)
 725        layout.addWidget(desctext)
 726        self.desctext = desctext
 727
 728        self.settings = QSettings()
 729
 730        # add page items to treeview
 731        for meta, info in INFO:
 732            if isinstance(meta['icon'], str):
 733                icon = qtlib.geticon(meta['icon'])
 734            else:
 735                style = QApplication.style()
 736                icon = QIcon()
 737                icon.addPixmap(style.standardPixmap(meta['icon']))
 738            item = QListWidgetItem(icon, meta['label'])
 739            pageList.addItem(item)
 740            self.addPage(meta['name'])
 741
 742        self.refresh()
 743        self.focusField(focus or 'ui.merge')
 744
 745    def editClicked(self):
 746        'Open internal editor in stacked widget'
 747        if self.isDirty():
 748            ret = qtlib.CustomPrompt(_('Confirm Save'),
 749                    _('Save changes before edit?'), self,
 750                    (_('&Save'), _('&Discard'), _('Cancel')),
 751                    default=2, esc=2).run()
 752            if ret == 0:
 753                self.applyChanges()
 754            elif ret == 2:
 755                return
 756        if qscilib.fileEditor(self.fn, foldable=True) == QDialog.Accepted:
 757            self.refresh()
 758
 759    def refresh(self, *args):
 760        # refresh config values
 761        self.ini = self.loadIniFile(self.rcpath)
 762        self.readonly = not (hasattr(self.ini, 'write') and os.access(self.fn, os.W_OK))
 763        self.stack.setDisabled(self.readonly)
 764        self.fnedit.setText(hglib.tounicode(self.fn))
 765        for name, info, widgets in self.pages.values():
 766            if name == 'extensions':
 767                extsmentioned = False
 768                for row, w in enumerate(widgets):
 769                    val = self.readCPath('extensions.' + w.opts['label'])
 770                    if val == None:
 771                        curvalue = False
 772                    elif len(val) and val[0] == '!':
 773                        curvalue = False
 774                        extsmentioned = True
 775                    else:
 776                        curvalue = True
 777                        extsmentioned = True
 778                    w.setValue(curvalue)
 779                if not extsmentioned:
 780                    # make sure widgets are shown properly,
 781                    # even when no extensions mentioned in the config file
 782                    self.validateextensions()
 783            else:
 784                for row, e in enumerate(info):
 785                    curvalue = self.readCPath(e.cpath)
 786                    widgets[row].setValue(curvalue)
 787
 788    def isDirty(self):
 789        if self.readonly:
 790            return False
 791        for name, info, widgets in self.pages.values():
 792            for w in widgets:
 793                if w.isDirty():
 794                    return True
 795        return False
 796
 797    def reloadClicked(self):
 798        if self.isDirty():
 799            d = QMessageBox.question(self, _('Confirm Reload'),
 800                            _('Unsaved changes will be lost.\n'
 801                            'Do you want to reload?'),
 802                            QMessageBox.Ok | QMessageBox.Cancel)
 803            if d != QMessageBox.Ok:
 804                return
 805        self.refresh()
 806
 807    def focusField(self, focusfield):
 808        'Set page and focus to requested datum'
 809        for i, (meta, info) in enumerate(INFO):
 810            for n, e in enumerate(info):
 811                if e.cpath == focusfield:
 812                    self.pageList.setCurrentRow(i)
 813                    QTimer.singleShot(0, lambda:
 814                            self.pages[meta['name']][2][n].setFocus())
 815                    return
 816
 817    def fillFrame(self, info):
 818        widgets = []
 819        frame = QFrame()
 820        form = QFormLayout()
 821        frame.setLayout(form)
 822        self.stack.addWidget(frame)
 823
 824        for e in info:
 825            opts = {'label': e.label, 'cpath': e.cpath, 'tooltip': e.tooltip,
 826                    'settings':self.settings}
 827            if isinstance(e.values, tuple):
 828                func = e.values[0]
 829                w = func(opts, e.values[1])
 830            else:
 831                func = e.values
 832                w = func(opts)
 833            w.installEventFilter(self)
 834            if e.globalonly:
 835                w.setEnabled(self.rcpath == util.user_rcpath())
 836            lbl = QLabel(e.label)
 837            lbl.installEventFilter(self)
 838            lbl.setToolTip(e.tooltip)
 839            form.addRow(lbl, w)
 840            widgets.append(w)
 841        return widgets
 842
 843    def fillExtensionsFrame(self):
 844        widgets = []
 845        frame = QFrame()
 846        grid = QGridLayout()
 847        frame.setLayout(grid)
 848        self.stack.addWidget(frame)
 849        allexts = hglib.allextensions()
 850        allextslist = list(allexts)
 851        MAXCOLUMNS = 3
 852        maxrows = (len(allextslist) + MAXCOLUMNS - 1) / MAXCOLUMNS
 853        i = 0
 854        extsinfo = ()
 855        for i, name in enumerate(sorted(allexts)):
 856            tt = hglib.tounicode(allexts[name])
 857            opts = {'label':name, 'cpath':'extensions.' + name, 'tooltip':tt,
 858                    'valfunc':self.validateextensions}
 859            w = genCheckBox(opts)
 860            w.installEventFilter(self)
 861            row, col = i / maxrows, i % maxrows
 862            grid.addWidget(w, col, row)
 863            widgets.append(w)
 864        return extsinfo, widgets
 865
 866    def eventFilter(self, obj, event):
 867        if event.type() in (QEvent.Enter, QEvent.FocusIn):
 868            self.desctext.setHtml(obj.toolTip())
 869        if event.type() == QEvent.ToolTip:
 870            return True  # tooltip is shown in self.desctext
 871        return False
 872
 873    def addPage(self, name):
 874        for data in INFO:
 875            if name == data[0]['name']:
 876                meta, info = data
 877                break
 878        if name == 'extensions':
 879            extsinfo, widgets = self.fillExtensionsFrame()
 880            self.pages[name] = name, extsinfo, widgets
 881        else:
 882            widgets = self.fillFrame(info)
 883            self.pages[name] = name, info, widgets
 884
 885    def readCPath(self, cpath):
 886        'Retrieve a value from the parsed config file'
 887        # Presumes single section/key level depth
 888        section, key = cpath.split('.', 1)
 889        return self.ini.get(section, key)
 890
 891    def loadIniFile(self, rcpath):
 892        for fn in rcpath:
 893            if os.path.exists(fn):
 894                break
 895        else:
 896            for fn in rcpath:
 897                # Try to create a file from rcpath
 898                try:
 899                    f = open(fn, 'w')
 900                    f.write('# Generated by TortoiseHg setting dialog\n')
 901                    f.close()
 902                    break
 903                except (IOError, OSError):
 904                    pass
 905            else:
 906                qtlib.WarningMsgBox(_('Unable to create a Mercurial.ini file'),
 907                       _('Insufficient access rights, reverting to read-only'
 908                         ' mode.'), parent=self)
 909                from mercurial import config
 910                self.fn = rcpath[0]
 911                return config.config()
 912        self.fn = fn
 913        return wconfig.readfile(self.fn)
 914
 915    def recordNewValue(self, cpath, newvalue):
 916        """Set the given value to ini; returns True if changed"""
 917        # 'newvalue' is in local encoding
 918        section, key = cpath.split('.', 1)
 919        if newvalue == self.ini.get(section, key):
 920            return False
 921        if newvalue == None:
 922            try:
 923                del self.ini[section][key]
 924            except KeyError:
 925                pass
 926        else:
 927            self.ini.set(section, key, newvalue)
 928        return True
 929
 930    def applyChanges(self):
 931        if self.readonly:
 932            return
 933
 934        for name, info, widgets in self.pages.values():
 935            if name == 'extensions':
 936                self.applyChangesForExtensions()
 937            else:
 938                for row, e in enumerate(info):
 939                    newvalue = widgets[row].value()
 940                    changed = self.recordNewValue(e.cpath, newvalue)
 941                    if changed and e.restartneeded:
 942                        self.restartRequested.emit(e.label)
 943
 944        try:
 945            wconfig.writefile(self.ini, self.fn)
 946        except IOError, e:
 947            qtlib.WarningMsgBox(_('Unable to write configuration file'),
 948                                str(e), parent=self)
 949
 950    def applyChangesForExtensions(self):
 951        emitChanged = False
 952        section = 'extensions'
 953        enabledexts = hglib.enabledextensions()
 954        for chk in self.pages['extensions'][2]:
 955            if (not emitChanged) and chk.isDirty():
 956                self.restartRequested.emit(_('Extensions'))
 957                emitChanged = True
 958            key = chk.opts['label']
 959            newvalue = chk.value()
 960            if newvalue and (key in enabledexts):
 961                continue    # unchanged
 962            if newvalue:
 963                self.ini.set(section, key, '')
 964            else:
 965                for cand in (key, 'hgext.%s' % key, 'hgext/%s' % key):
 966                    try:
 967                        del self.ini[section][cand]
 968                    except KeyError:
 969                        pass
 970
 971    @pyqtSlot()
 972    def validateextensions(self):
 973        section = 'extensions'
 974        enabledexts = hglib.enabledextensions()
 975        selectedexts = set(chk.opts['label']
 976                           for chk in self.pages['extensions'][2]
 977                           if chk.isChecked())
 978        invalidexts = hglib.validateextensions(selectedexts)
 979
 980        def getinival(name):
 981            if section not in self.ini:
 982                return None
 983            for cand in (name, 'hgext.%s' % name, 'hgext/%s' % name):
 984                try:
 985                    return self.ini[section][cand]
 986                except KeyError:
 987                    pass
 988
 989        def changable(name):
 990            curval = getinival(name)
 991            if curval not in ('', None):
 992                # enabled or unspecified, official extensions only
 993                return False
 994            elif name in enabledexts and curval is None:
 995                # re-disabling ext is not supported
 996                return False
 997            elif name in invalidexts and name not in selectedexts:
 998                # disallow to enable bad exts, but allow to disable it
 999                return False
1000            else:
1001                return True
1002
1003        allexts = hglib.allextensions()
1004        for chk in self.pages['extensions'][2]:
1005            name = chk.opts['label']
1006            chk.setEnabled(changable(name))
1007            invalmsg = invalidexts.get(name)
1008            if invalmsg:
1009                invalmsg = invalmsg.decode('utf-8')
1010            chk.setToolTip(invalmsg or hglib.tounicode(allexts[name]))
1011
1012
1013def run(ui, *pats, **opts):
1014    return SettingsDialog(opts.get('alias') == 'repoconfig',
1015                          focus=opts.get('focus'))