1# qtlib.py - Qt utility code
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 or any later version.
7
8from __future__ import absolute_import
9
10import atexit
11import os
12import posixpath
13import re
14import shutil
15import sip
16import stat
17import subprocess
18import sys
19import tempfile
20import weakref
21
22from .qtcore import (
23    PYQT_VERSION,
24    QByteArray,
25    QDir,
26    QEvent,
27    QFile,
28    QObject,
29    QProcess,
30    QSize,
31    QUrl,
32    QT_VERSION,
33    Qt,
34    pyqtSignal,
35    pyqtSlot,
36)
37from .qtgui import (
38    QAction,
39    QApplication,
40    QComboBox,
41    QCommonStyle,
42    QColor,
43    QDesktopServices,
44    QDialog,
45    QFont,
46    QFrame,
47    QHBoxLayout,
48    QIcon,
49    QInputDialog,
50    QKeySequence,
51    QLabel,
52    QLineEdit,
53    QMessageBox,
54    QPainter,
55    QPalette,
56    QPixmap,
57    QPushButton,
58    QShortcut,
59    QSizePolicy,
60    QStyle,
61    QStyleOptionButton,
62    QVBoxLayout,
63    QWidget,
64)
65
66from mercurial import (
67    color,
68    encoding,
69    extensions,
70    pycompat,
71    util,
72)
73from mercurial.utils import (
74    procutil,
75    stringutil,
76)
77
78from ..util import (
79    editor,
80    hglib,
81    paths,
82    terminal,
83)
84from ..util.i18n import _
85
86try:
87    import win32con  # pytype: disable=import-error
88    openflags = win32con.CREATE_NO_WINDOW  # type: int
89except ImportError:
90    openflags = 0
91
92if hglib.TYPE_CHECKING:
93    from typing import (
94        Any,
95        Dict,
96        List,
97        Text,
98        Tuple,
99        Optional,
100    )
101
102if pycompat.ispy3:
103    from html import escape as htmlescape
104else:
105    import cgi
106    def htmlescape(s, quote=True):
107        return cgi.escape(s, quote)  # pytype: disable=module-attr
108
109# largest allowed size for widget, defined in <src/gui/kernel/qwidget.h>
110QWIDGETSIZE_MAX = (1 << 24) - 1
111
112tmproot = None
113def gettempdir():
114    """Return the byte string path of a temporary directory, static for the
115    application lifetime, removed recursively atexit."""
116    global tmproot
117    def cleanup():
118        try:
119            os.chmod(tmproot, os.stat(tmproot).st_mode | stat.S_IWUSR)
120            for top, dirs, files in os.walk(tmproot):
121                for name in dirs + files:
122                    fullname = os.path.join(top, name)
123                    os.chmod(fullname, os.stat(fullname).st_mode | stat.S_IWUSR)
124            shutil.rmtree(tmproot)
125        except OSError:
126            pass
127    if not tmproot:
128        tmproot = tempfile.mkdtemp(prefix=b'thg.')
129        atexit.register(cleanup)
130    return tmproot
131
132def openhelpcontents(url):
133    'Open online help, use local CHM file if available'
134    if not url.startswith('http'):
135        fullurl = 'https://tortoisehg.readthedocs.org/en/latest/' + url
136        # Use local CHM file if it can be found
137        if os.name == 'nt' and paths.bin_path:
138            chm = os.path.join(paths.bin_path, 'doc', 'TortoiseHg.chm')
139            if os.path.exists(chm):
140                fullurl = (r'mk:@MSITStore:%s::/' % chm) + url
141                openlocalurl(fullurl)
142                return
143        QDesktopServices.openUrl(QUrl(fullurl))
144
145def openlocalurl(path):
146    '''open the given path with the default application
147
148    takes bytes or unicode as argument
149    returns True if open was successfull
150    '''
151
152    if isinstance(path, bytes):
153        path = hglib.tounicode(path)
154    if os.name == 'nt' and path.startswith('\\\\'):
155        # network share, special handling because of qt bug 13359
156        # see https://bugreports.qt.io/browse/QTBUG-13359
157        qurl = QUrl()
158        qurl.setUrl(QDir.toNativeSeparators(path))
159    else:
160        qurl = QUrl.fromLocalFile(path)
161    return QDesktopServices.openUrl(qurl)
162
163def editfiles(repo, files, lineno=None, search=None, parent=None):
164    # type: (Any, List[bytes], Optional[int], Optional[bytes], Optional[QWidget]) -> None
165    if len(files) == 1:
166        # if editing a single file, open in cwd context of that file
167        filename = files[0].strip()
168        if not filename:
169            return
170        path = repo.wjoin(filename)
171        cwd = os.path.dirname(path)
172        files = [os.path.basename(path)]
173    else:
174        # else edit in cwd context of repo root
175        cwd = repo.root
176
177    toolpath, args, argsln, argssearch = editor.detecteditor(repo, files)
178    if os.path.basename(toolpath) in (b'vi', b'vim', b'hgeditor'):
179        QMessageBox.critical(parent, _('No visual editor configured'),
180                             _('Please configure a visual editor.'))
181        from tortoisehg.hgqt.settings import SettingsDialog
182        dlg = SettingsDialog(False, focus='tortoisehg.editor')
183        dlg.exec_()
184        return
185
186    files = [procutil.shellquote(util.localpath(f)) for f in files]
187    assert len(files) == 1 or lineno is None, (files, lineno)
188
189    cmdline = None
190    if search:
191        assert lineno is not None
192        if argssearch:
193            cmdline = b' '.join([toolpath, argssearch])
194            cmdline = cmdline.replace(b'$LINENUM', b'%d' % lineno)
195            cmdline = cmdline.replace(b'$SEARCH', search)
196        elif argsln:
197            cmdline = b' '.join([toolpath, argsln])
198            cmdline = cmdline.replace(b'$LINENUM', b'%d' % lineno)
199        elif args:
200            cmdline = b' '.join([toolpath, args])
201    elif lineno is not None:
202        if argsln:
203            cmdline = b' '.join([toolpath, argsln])
204            cmdline = cmdline.replace(b'$LINENUM', b'%d' % lineno)
205        elif args:
206            cmdline = b' '.join([toolpath, args])
207    else:
208        if args:
209            cmdline = b' '.join([toolpath, args])
210
211    if cmdline is None:
212        # editor was not specified by editor-tools configuration, fall
213        # back to older tortoisehg.editor OpenAtLine parsing
214        cmdline = b' '.join([toolpath] + files) # default
215        try:
216            regexp = re.compile(b'\[([^\]]*)\]')
217            expanded = []
218            pos = 0
219            for m in regexp.finditer(toolpath):
220                expanded.append(toolpath[pos:m.start()-1])
221                phrase = toolpath[m.start()+1:m.end()-1]
222                pos = m.end()+1
223                if b'$LINENUM' in phrase:
224                    if lineno is None:
225                        # throw away phrase
226                        continue
227                    phrase = phrase.replace(b'$LINENUM', b'%d' % lineno)
228                elif b'$SEARCH' in phrase:
229                    if search is None:
230                        # throw away phrase
231                        continue
232                    phrase = phrase.replace(b'$SEARCH', search)
233                if b'$FILE' in phrase:
234                    phrase = phrase.replace(b'$FILE', files[0])
235                    files = []
236                expanded.append(phrase)
237            expanded.append(toolpath[pos:])
238            cmdline = b' '.join(expanded + files)
239        except ValueError:
240            # '[' or ']' not found
241            pass
242        except TypeError:
243            # variable expansion failed
244            pass
245
246    shell = not (len(cwd) >= 2 and cwd[0:2] == br'\\')
247    try:
248        if b'$FILES' in cmdline:
249            cmdline = cmdline.replace(b'$FILES', b' '.join(files))
250            subprocess.Popen(procutil.tonativestr(cmdline), shell=shell,
251                             creationflags=openflags,
252                             stderr=None, stdout=None, stdin=None,
253                             cwd=procutil.tonativestr(cwd))
254        elif b'$FILE' in cmdline:
255            for file in files:
256                cmd = cmdline.replace(b'$FILE', file)
257                subprocess.Popen(procutil.tonativestr(cmd), shell=shell,
258                                 creationflags=openflags,
259                                 stderr=None, stdout=None, stdin=None,
260                                 cwd=procutil.tonativestr(cwd))
261        else:
262            # assume filenames were expanded already
263            subprocess.Popen(procutil.tonativestr(cmdline), shell=shell,
264                             creationflags=openflags,
265                             stderr=None, stdout=None, stdin=None,
266                             cwd=procutil.tonativestr(cwd))
267    except (OSError, EnvironmentError) as e:
268        QMessageBox.warning(parent,
269                _('Editor launch failure'),
270                u'%s : %s' % (hglib.tounicode(cmdline),
271                              hglib.tounicode(str(e))))
272
273def openshell(root, reponame, ui=None):
274    if not os.path.exists(root):
275        WarningMsgBox(
276            _('Failed to open path in terminal'),
277            _('"%s" is not a valid directory') % hglib.tounicode(root))
278        return
279    shell, args = terminal.detectterminal(ui)
280    if shell:
281        if args:
282            shell = shell + b' ' + util.expandpath(args)
283        # check invalid expression in tortoisehg.shell.  we shouldn't apply
284        # string formatting to untrusted value, but too late to change syntax.
285        try:
286            shell % {b'root': b'', b'reponame': b''}
287        except (KeyError, TypeError, ValueError):
288            # KeyError: "%(invalid)s", TypeError: "%(root)d", ValueError: "%"
289            ErrorMsgBox(_('Failed to open path in terminal'),
290                        _('Invalid configuration: %s')
291                        % hglib.tounicode(shell))
292            return
293        shellcmd = shell % {b'root': root, b'reponame': reponame}
294
295        cwd = os.getcwd()
296        try:
297            # Unix: QProcess.startDetached(program) cannot parse single-quoted
298            # parameters built using procutil.shellquote().
299            # Windows: subprocess.Popen(program, shell=True) cannot spawn
300            # cmd.exe in new window, probably because the initial cmd.exe is
301            # invoked with SW_HIDE.
302            os.chdir(root)
303            if os.name == 'nt':
304                # can't parse shellcmd in POSIX way
305                started = QProcess.startDetached(hglib.tounicode(shellcmd))
306            else:
307                fullargs = pycompat.maplist(hglib.tounicode,
308                                            pycompat.shlexsplit(shellcmd))
309                started = QProcess.startDetached(fullargs[0], fullargs[1:])
310        finally:
311            os.chdir(cwd)
312        if not started:
313            ErrorMsgBox(_('Failed to open path in terminal'),
314                        _('Unable to start the following command:'),
315                        hglib.tounicode(shellcmd))
316    else:
317        InfoMsgBox(_('No shell configured'),
318                   _('A terminal shell must be configured'))
319
320
321# 'type' argument of QSettings.value() can't be used because:
322#  a) it appears to be broken before PyQt 4.11.x (#4882)
323#  b) it may raise TypeError if a setting has a value of an unexpected type
324
325def readBool(qs, key, default=False):
326    """Read the specified value from QSettings and coerce into bool"""
327    v = qs.value(key, default)
328    if hglib.isbasestring(v):
329        # qvariant.cpp:qt_convertToBool()
330        return not (v == '0' or v == 'false' or v == '')
331    return bool(v)
332
333def readByteArray(qs, key, default=b''):
334    """Read the specified value from QSettings and coerce into QByteArray"""
335    v = qs.value(key, default)
336    if v is None:
337        return QByteArray(default)
338    try:
339        return QByteArray(v)
340    except TypeError:
341        return QByteArray(default)
342
343def readInt(qs, key, default=0):
344    """Read the specified value from QSettings and coerce into int"""
345    v = qs.value(key, default)
346    if v is None:
347        return int(default)
348    try:
349        return int(v)
350    except (TypeError, ValueError):
351        return int(default)
352
353def readString(qs, key, default=''):
354    """Read the specified value from QSettings and coerce into string"""
355    v = qs.value(key, default)
356    if v is None:
357        return pycompat.unicode(default)
358    try:
359        return pycompat.unicode(v)
360    except ValueError:
361        return pycompat.unicode(default)
362
363def readStringList(qs, key, default=()):
364    """Read the specified value from QSettings and coerce into string list"""
365    v = qs.value(key, default)
366    if v is None:
367        return list(default)
368    if hglib.isbasestring(v):
369        # qvariant.cpp:convert()
370        return [v]
371    try:
372        return [pycompat.unicode(e) for e in v]
373    except (TypeError, ValueError):
374        return list(default)
375
376
377def isDarkTheme(palette=None):
378    """True if white-on-black color scheme is preferable"""
379    if not palette:
380        palette = QApplication.palette()
381    return palette.color(QPalette.Base).black() >= 0x80
382
383# _styles maps from ui labels to effects
384# _effects maps an effect to font style properties.  We define a limited
385# set of _effects, since we convert color effect names to font style
386# effect programatically.
387
388# TODO: update ui._styles instead of color._defaultstyles
389_styles = pycompat.rapply(pycompat.sysstr, color._defaultstyles)  # type: Dict[str, str]
390
391_effects = {
392    'bold': 'font-weight: bold',
393    'italic': 'font-style: italic',
394    'underline': 'text-decoration: underline',
395}
396
397_thgstyles = {
398    # Styles defined by TortoiseHg
399    'log.branch': 'black #aaffaa_background',
400    'log.patch': 'black #aaddff_background',
401    'log.unapplied_patch': 'black #dddddd_background',
402    'log.tag': 'black #ffffaa_background',
403    'log.bookmark': 'blue #ffffaa_background',
404    'log.curbookmark': 'black #ffdd77_background',
405    'log.modified': 'black #ffddaa_background',
406    'log.added': 'black #aaffaa_background',
407    'log.removed': 'black #ffcccc_background',
408    'log.warning': 'black #ffcccc_background',
409    'status.deleted': 'red bold',
410    'ui.error': 'red bold #ffcccc_background',
411    'ui.warning': 'black bold #ffffaa_background',
412    'control': 'black bold #dddddd_background',
413
414    # Topic related styles
415    'log.topic': 'black bold #2ecc71_background',
416    'topic.active': 'black bold #2ecc71_background',
417}
418
419thgstylesheet = '* { white-space: pre; font-family: monospace;' \
420                ' font-size: 9pt; }'
421tbstylesheet = 'QToolBar { border: 0px }'
422
423def configstyles(ui):
424    # extensions may provide more labels and default effects
425    for name, ext in extensions.extensions():
426        extstyle = getattr(ext, 'colortable', {})
427        _styles.update(pycompat.rapply(pycompat.sysstr, extstyle))
428
429    # tortoisehg defines a few labels and default effects
430    _styles.update(_thgstyles)
431
432    # allow the user to override
433    for status, cfgeffects in ui.configitems(b'color'):  # type: Tuple[bytes, bytes]
434        if b'.' not in status:
435            continue
436        cfgeffects = ui.configlist(b'color', status)
437        _styles[pycompat.sysstr(status)] = pycompat.sysstr(b' '.join(cfgeffects))
438
439    for status, cfgeffects in ui.configitems(b'thg-color'):  # type: Tuple[bytes, bytes]
440        if b'.' not in status:
441            continue
442        cfgeffects = ui.configlist(b'thg-color', status)
443        _styles[pycompat.sysstr(status)] = pycompat.sysstr(b' '.join(cfgeffects))
444
445# See https://doc.qt.io/qt-4.8/richtext-html-subset.html
446# and https://www.w3.org/TR/SVG/types.html#ColorKeywords
447
448def geteffect(labels):
449    'map labels like "log.date" to Qt font styles'
450    labels = str(labels) # Could be QString
451    effects = []
452    # Multiple labels may be requested
453    for l in labels.split():
454        if not l:
455            continue
456        # Each label may request multiple effects
457        es = _styles.get(l, '')
458        for e in es.split():
459            if e in _effects:
460                effects.append(_effects[e])
461            elif e.endswith('_background'):
462                e = e[:-11]
463                if e.startswith('#') or e in QColor.colorNames():
464                    effects.append('background-color: ' + e)
465            elif e.startswith('#') or e in QColor.colorNames():
466                # Accept any valid QColor
467                effects.append('color: ' + e)
468    return ';'.join(effects)
469
470def gettextcoloreffect(labels):
471    """Map labels like "log.date" to foreground color if available"""
472    for l in str(labels).split():
473        if not l:
474            continue
475        for e in _styles.get(l, '').split():
476            if e.startswith('#') or e in QColor.colorNames():
477                return QColor(e)
478    return QColor()
479
480def getbgcoloreffect(labels):
481    """Map labels like "log.date" to background color if available
482
483    Returns QColor object. You may need to check validity by isValid().
484    """
485    for l in str(labels).split():
486        if not l:
487            continue
488        for e in _styles.get(l, '').split():
489            if e.endswith('_background'):
490                return QColor(e[:-11])
491    return QColor()
492
493# TortoiseHg uses special names for the properties controlling the appearance of
494# its interface elements.
495#
496# This dict maps internal style names to corresponding CSS property names.
497NAME_MAP = {
498    'fg': 'color',
499    'bg': 'background-color',
500    'family': 'font-family',
501    'size': 'font-size',
502    'weight': 'font-weight',
503    'space': 'white-space',
504    'style': 'font-style',
505    'decoration': 'text-decoration',
506}
507
508def markup(msg, **styles):
509    style = {'white-space': 'pre'}
510    for name, value in styles.items():
511        if not value:
512            continue
513        if name in NAME_MAP:
514            name = NAME_MAP[name]
515        style[name] = value
516    style = ';'.join(['%s: %s' % t for t in style.items()])
517    msg = hglib.tounicode(msg)
518    msg = htmlescape(msg, False)
519    msg = msg.replace('\n', '<br />')
520    return u'<span style="%s">%s</span>' % (style, msg)
521
522def descriptionhtmlizer(ui):
523    """Return a function to mark up ctx.description() as an HTML
524
525    >>> from mercurial import ui
526    >>> u = ui.ui()
527    >>> htmlize = descriptionhtmlizer(u)
528    >>> htmlize('foo <bar> \\n& <baz>')
529    u'foo &lt;bar&gt; \\n&amp; &lt;baz&gt;'
530
531    changeset hash link:
532    >>> htmlize('foo af50a62e9c20 bar')
533    u'foo <a href="cset:af50a62e9c20">af50a62e9c20</a> bar'
534    >>> htmlize('af50a62e9c2040dcdaf61ba6a6400bb45ab56410') # doctest: +ELLIPSIS
535    u'<a href="cset:af...10">af...10</a>'
536
537    http/https links:
538    >>> s = htmlize('foo http://example.com:8000/foo?bar=baz&bax#blah')
539    >>> (s[:63], s[63:]) # doctest: +NORMALIZE_WHITESPACE
540    (u'foo <a href="http://example.com:8000/foo?bar=baz&amp;bax#blah">',
541     u'http://example.com:8000/foo?bar=baz&amp;bax#blah</a>')
542    >>> htmlize('https://example/')
543    u'<a href="https://example/">https://example/</a>'
544    >>> htmlize('<https://example/>')
545    u'&lt;<a href="https://example/">https://example/</a>&gt;'
546
547    issue links:
548    >>> u.setconfig(b'tortoisehg', b'issue.regex', br'#(\\d+)\\b')
549    >>> u.setconfig(b'tortoisehg', b'issue.link', b'http://example/issue/{1}/')
550    >>> htmlize = descriptionhtmlizer(u)
551    >>> htmlize('foo #123')
552    u'foo <a href="http://example/issue/123/">#123</a>'
553
554    missing issue.link setting:
555    >>> u.setconfig(b'tortoisehg', b'issue.link', b'')
556    >>> htmlize = descriptionhtmlizer(u)
557    >>> htmlize('foo #123')
558    u'foo #123'
559
560    too many replacements in issue.link:
561    >>> u.setconfig(b'tortoisehg', b'issue.link', b'http://example/issue/{1}/{2}')
562    >>> htmlize = descriptionhtmlizer(u)
563    >>> htmlize('foo #123')
564    u'foo #123'
565
566    invalid regexp in issue.regex:
567    >>> u.setconfig(b'tortoisehg', b'issue.regex', b'(')
568    >>> htmlize = descriptionhtmlizer(u)
569    >>> htmlize('foo #123')
570    u'foo #123'
571    >>> htmlize('http://example/')
572    u'<a href="http://example/">http://example/</a>'
573    """
574    csmatch = r'(\b[0-9a-f]{12}(?:[0-9a-f]{28})?\b)'
575    httpmatch = r'(\b(http|https)://([-A-Za-z0-9+&@#/%?=~_()|!:,.;]*' \
576                r'[-A-Za-z0-9+&@#/%=~_()|]))'
577    regexp = r'%s|%s' % (csmatch, httpmatch)
578    bodyre = re.compile(regexp)
579
580    issuematch = hglib.tounicode(ui.config(b'tortoisehg', b'issue.regex'))
581    issuerepl = hglib.tounicode(ui.config(b'tortoisehg', b'issue.link'))
582    if issuematch and issuerepl:
583        regexp += '|(%s)' % issuematch
584        try:
585            bodyre = re.compile(regexp)
586        except re.error:
587            pass
588
589    def htmlize(desc):
590        """Mark up ctx.description() [localstr] as an HTML [unicode]"""
591        desc = hglib.tounicode(desc)
592
593        buf = ''
594        pos = 0
595        for m in bodyre.finditer(desc):
596            a, b = m.span()
597            if a >= pos:
598                buf += htmlescape(desc[pos:a], False)
599                pos = b
600            groups = m.groups()
601            if groups[0]:
602                cslink = htmlescape(groups[0])
603                buf += '<a href="cset:%s">%s</a>' % (cslink, cslink)
604            if groups[1]:
605                urllink = htmlescape(groups[1])
606                buf += '<a href="%s">%s</a>' % (urllink, urllink)
607            if len(groups) > 4 and groups[4]:
608                issue = htmlescape(groups[4])
609                issueparams = groups[4:]
610                try:
611                    link = re.sub(r'\{(\d+)\}',
612                                  lambda m: issueparams[int(m.group(1))],
613                                  issuerepl)
614                    link = htmlescape(link)
615                    buf += '<a href="%s">%s</a>' % (link, issue)
616                except IndexError:
617                    buf += issue
618
619        if pos < len(desc):
620            buf += htmlescape(desc[pos:], False)
621
622        return buf
623
624    return htmlize
625
626_iconcache = {}
627
628if getattr(sys, 'frozen', False) and os.name == 'nt':
629    def iconpath(f, *insidef):
630        return posixpath.join(':/icons', f, *insidef)
631else:
632    def iconpath(f, *insidef):
633        return os.path.join(paths.get_icon_path(), f, *insidef)
634
635if hasattr(QIcon, 'hasThemeIcon'):  # PyQt>=4.7
636    def _findthemeicon(name):
637        if QIcon.hasThemeIcon(name):
638            return QIcon.fromTheme(name)
639else:
640    def _findthemeicon(name):
641        pass
642
643def _findcustomicon(name):
644    # let a user set the icon of a custom tool button
645    if os.path.isabs(name):
646        path = name
647        if QFile.exists(path):
648            return QIcon(path)
649    return None
650
651# https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html
652_SCALABLE_ICON_PATHS = [(QSize(), 'scalable/actions', '.svg'),
653                        (QSize(), 'scalable/apps', '.svg'),
654                        (QSize(), 'scalable/status', '.svg'),
655                        (QSize(16, 16), '16x16/actions', '.png'),
656                        (QSize(16, 16), '16x16/apps', '.png'),
657                        (QSize(16, 16), '16x16/mimetypes', '.png'),
658                        (QSize(16, 16), '16x16/status', '.png'),
659                        (QSize(22, 22), '22x22/actions', '.png'),
660                        (QSize(32, 32), '32x32/actions', '.png'),
661                        (QSize(32, 32), '32x32/status', '.png'),
662                        (QSize(24, 24), '24x24/actions', '.png')]
663
664def getallicons():
665    """Get a sorted, unique list of all available icons"""
666    iconset = set()
667    for size, subdir, sfx in _SCALABLE_ICON_PATHS:
668        path = iconpath(subdir)
669        d = QDir(path)
670        d.setNameFilters(['*%s' % sfx])
671        for iconname in d.entryList():
672            iconset.add(pycompat.unicode(iconname).rsplit('.', 1)[0])
673    return sorted(iconset)
674
675def _findscalableicon(name):
676    """Find icon from qrc by using freedesktop-like icon lookup"""
677    o = QIcon()
678    for size, subdir, sfx in _SCALABLE_ICON_PATHS:
679        path = iconpath(subdir, name + sfx)
680        if QFile.exists(path):
681            for mode in (QIcon.Normal, QIcon.Active):
682                o.addFile(path, size, mode)
683    if not o.isNull():
684        return o
685
686def geticon(name):
687    """
688    Return a QIcon for the specified name. (the given 'name' parameter
689    must *not* provide the extension).
690
691    This searches for the icon from theme, Qt resource or icons directory,
692    named as 'name.(svg|png|ico)'.
693    """
694    try:
695        return _iconcache[name]  # pytype: disable=key-error
696    except KeyError:
697        _iconcache[name] = (_findthemeicon(name)
698                            or _findscalableicon(name)
699                            or _findcustomicon(name)
700                            or QIcon())
701        return _iconcache[name]
702
703
704def getoverlaidicon(base, overlay):
705    """Generate an overlaid icon"""
706    pixmap = base.pixmap(16, 16)
707    painter = QPainter(pixmap)
708    painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
709    painter.drawPixmap(0, 0, overlay.pixmap(16, 16))
710    del painter
711    return QIcon(pixmap)
712
713
714_pixmapcache = {}
715
716def getpixmap(name, width=16, height=16):
717    key = '%s_%sx%s' % (name, width, height)
718    try:
719        return _pixmapcache[key]
720    except KeyError:
721        pixmap = geticon(name).pixmap(width, height)
722    _pixmapcache[key] = pixmap
723    return pixmap
724
725def getcheckboxpixmap(state, bgcolor, widget):
726    pix = QPixmap(16,16)
727    painter = QPainter(pix)
728    painter.fillRect(0, 0, 16, 16, bgcolor)
729    option = QStyleOptionButton()
730    style = QApplication.style()
731    option.initFrom(widget)
732    option.rect = style.subElementRect(style.SE_CheckBoxIndicator, option, None)
733    option.rect.moveTo(1, 1)
734    option.state |= state
735    style.drawPrimitive(style.PE_IndicatorCheckBox, option, painter)
736    return pix
737
738
739# On machines with a retina display running OSX (i.e. "darwin"), most icons are
740# too big because Qt4 does not support retina displays very well.
741# To fix that we let users force tortoishg to use smaller icons by setting a
742# THG_RETINA environment variable to True (or any value that mercurial parses
743# as True.
744# Whereas on Linux, Qt4 has no support for high dpi displays at all causing
745# icons to be rendered unusably small. The workaround for that is to render
746# the icons at double the normal size.
747# TODO: Remove this hack after upgrading to Qt5.
748IS_RETINA = stringutil.parsebool(encoding.environ.get(b'THG_RETINA', b'0'))
749
750def _fixIconSizeForRetinaDisplay(s):
751    if IS_RETINA:
752        if sys.platform == 'darwin':
753            if s > 1:
754                s /= 2
755        elif sys.platform == 'linux2':
756            s *= 2
757    return s
758
759def smallIconSize():
760    style = QApplication.style()
761    s = style.pixelMetric(QStyle.PM_SmallIconSize)
762    s = _fixIconSizeForRetinaDisplay(s)
763    return QSize(s, s)
764
765def toolBarIconSize():
766    if sys.platform == 'darwin':
767        # most Mac users will have laptop-sized screens and prefer a smaller
768        # toolbar to preserve vertical space.
769        style = QCommonStyle()
770    else:
771        style = QApplication.style()
772    s = style.pixelMetric(QStyle.PM_ToolBarIconSize)
773    s = _fixIconSizeForRetinaDisplay(s)
774    return QSize(s, s)
775
776def listviewRetinaIconSize():
777    return QSize(16, 16)
778
779def treeviewRetinaIconSize():
780    return QSize(16, 16)
781
782def barRetinaIconSize():
783    return QSize(10, 10)
784
785class ThgFont(QObject):
786    changed = pyqtSignal(QFont)
787    def __init__(self, name):
788        QObject.__init__(self)
789        self.myfont = QFont()
790        self.myfont.fromString(name)
791    def font(self):
792        return self.myfont
793    def setFont(self, f):
794        self.myfont = f
795        self.changed.emit(f)
796
797_fontdefaults = {
798    'fontcomment': 'monospace,10',
799    'fontdiff': 'monospace,10',
800    'fontlog': 'monospace,10',
801    'fontoutputlog': 'sans,8'
802}
803if sys.platform == 'darwin':
804    _fontdefaults['fontoutputlog'] = 'sans,10'
805_fontcache = {}
806
807def initfontcache(ui):
808    for name in _fontdefaults:
809        fname = ui.config(b'tortoisehg', pycompat.sysbytes(name),
810                          pycompat.sysbytes(_fontdefaults[name]))
811        _fontcache[name] = ThgFont(hglib.tounicode(fname))
812
813def getfont(name):
814    assert name in _fontdefaults, (name, _fontdefaults)
815    return _fontcache[name]
816
817def CommonMsgBox(icon, title, main, text='', buttons=QMessageBox.Ok,
818                 labels=None, parent=None, defaultbutton=None):
819    if labels is None:
820        labels = []
821    msg = QMessageBox(parent)
822    msg.setIcon(icon)
823    msg.setWindowTitle(title)
824    msg.setStandardButtons(buttons)
825    for button_id, label in labels:
826        msg.button(button_id).setText(label)
827    if defaultbutton:
828        msg.setDefaultButton(defaultbutton)
829    msg.setText('<b>%s</b>' % main)
830    info = ''
831    for line in text.split('\n'):
832        info += '<nobr>%s</nobr><br />' % line
833    msg.setInformativeText(info)
834    return msg.exec_()
835
836def InfoMsgBox(*args, **kargs):
837    return CommonMsgBox(QMessageBox.Information, *args, **kargs)
838
839def WarningMsgBox(*args, **kargs):
840    return CommonMsgBox(QMessageBox.Warning, *args, **kargs)
841
842def ErrorMsgBox(*args, **kargs):
843    return CommonMsgBox(QMessageBox.Critical, *args, **kargs)
844
845def QuestionMsgBox(*args, **kargs):
846    btn = QMessageBox.Yes | QMessageBox.No
847    res = CommonMsgBox(QMessageBox.Question, buttons=btn, *args, **kargs)
848    return res == QMessageBox.Yes
849
850class CustomPrompt(QMessageBox):
851    def __init__(self, title, message, parent, choices, default=None,
852                 esc=None, files=None):
853        QMessageBox.__init__(self, parent)
854
855        self.setWindowTitle(hglib.tounicode(title))
856        self.setText(hglib.tounicode(message))
857        if files:
858            self.setDetailedText('\n'.join(hglib.tounicode(f) for f in files))
859        self.hotkeys = {}
860        for i, s in enumerate(choices):
861            btn = self.addButton(s, QMessageBox.AcceptRole)
862            try:
863                char = s[s.index('&')+1].lower()
864                self.hotkeys[char] = btn
865            except (ValueError, IndexError):
866                pass
867            if default == i:
868                self.setDefaultButton(btn)
869            if esc == i:
870                self.setEscapeButton(btn)
871
872    def run(self):
873        return self.exec_()
874
875    def keyPressEvent(self, event):
876        for k, btn in self.hotkeys.items():
877            if event.text() == k:
878                btn.clicked.emit(False)
879        super(CustomPrompt, self).keyPressEvent(event)
880
881class ChoicePrompt(QDialog):
882    def __init__(self, title, message, parent, choices, default=None):
883        # type: (Text, Text, QWidget, List[Text], Optional[Text]) -> None
884        QDialog.__init__(self, parent)
885        self.setWindowTitle(title)
886        self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
887
888        self.box = QHBoxLayout()
889        self.vbox = QVBoxLayout()
890        self.vbox.setSpacing(8)
891
892        self.message_lbl = QLabel()
893        self.message_lbl.setText(message)
894        self.vbox.addWidget(self.message_lbl)
895
896        self.choice_combo = combo = QComboBox()
897        self.choices = choices
898        combo.addItems(choices)
899        if default:
900            try:
901                combo.setCurrentIndex(choices.index(default))
902            except:
903                # Ignore a missing default value
904                pass
905        self.vbox.addWidget(combo)
906        self.box.addLayout(self.vbox)
907        vbox = QVBoxLayout()
908        self.ok = QPushButton('&OK')
909        self.ok.clicked.connect(self.accept)
910        vbox.addWidget(self.ok)
911        self.cancel = QPushButton('&Cancel')
912        self.cancel.clicked.connect(self.reject)
913        vbox.addWidget(self.cancel)
914        vbox.addStretch()
915        self.box.addLayout(vbox)
916        self.setLayout(self.box)
917
918    def run(self):
919        # type: () -> Optional[Text]
920        if self.exec_():
921            return self.choices[self.choice_combo.currentIndex()]
922        return None
923
924def allowCaseChangingInput(combo):
925    """Allow case-changing input of known combobox item
926
927    QComboBox performs case-insensitive inline completion by default. It's
928    all right, but sadly it implies case-insensitive check for duplicates,
929    i.e. you can no longer enter "Foo" if the combobox contains "foo".
930
931    For details, read QComboBoxPrivate::_q_editingFinished() and matchFlags()
932    of src/gui/widgets/qcombobox.cpp.
933    """
934    assert isinstance(combo, QComboBox) and combo.isEditable()
935    combo.completer().setCaseSensitivity(Qt.CaseSensitive)
936
937class BadCompletionBlocker(QObject):
938    """Disable unexpected inline completion by enter key if selectAll()-ed
939
940    If the selection state looks in the middle of the completion, QComboBox
941    replaces the edit text by the current completion on enter key pressed.
942    This is wrong in the following scenario:
943
944    >>> from .qtgui import QKeyEvent
945    >>> combo = QComboBox(editable=True)
946    >>> combo.addItem('history value')
947    >>> combo.setEditText('initial value')
948    >>> combo.lineEdit().selectAll()
949    >>> QApplication.sendEvent(
950    ...     combo, QKeyEvent(QEvent.KeyPress, Qt.Key_Enter, Qt.NoModifier))
951    True
952    >>> str(combo.currentText())
953    'history value'
954
955    In this example, QLineControl picks the first item in the combo box
956    because the completion prefix has not been set.
957
958    BadCompletionBlocker is intended to work around this problem.
959
960    >>> combo.installEventFilter(BadCompletionBlocker(combo))
961    >>> combo.setEditText('initial value')
962    >>> combo.lineEdit().selectAll()
963    >>> QApplication.sendEvent(
964    ...     combo, QKeyEvent(QEvent.KeyPress, Qt.Key_Enter, Qt.NoModifier))
965    True
966    >>> str(combo.currentText())
967    'initial value'
968
969    For details, read QLineControl::processKeyEvent() and complete() of
970    src/gui/widgets/qlinecontrol.cpp.
971    """
972
973    def __init__(self, parent):
974        super(BadCompletionBlocker, self).__init__(parent)
975        if not isinstance(parent, QComboBox):
976            raise ValueError('invalid object to watch: %r' % parent)
977
978    def eventFilter(self, watched, event):
979        if watched is not self.parent():
980            return super(BadCompletionBlocker, self).eventFilter(watched, event)
981        if (event.type() != QEvent.KeyPress
982            or event.key() not in (Qt.Key_Enter, Qt.Key_Return)
983            or not watched.isEditable()):
984            return False
985        # deselect without completion if all text selected
986        le = watched.lineEdit()
987        if le.selectedText() == le.text():
988            le.deselect()
989        return False
990
991class ActionPushButton(QPushButton):
992    """Button which properties are defined by QAction like QToolButton"""
993
994    def __init__(self, action, parent=None):
995        super(ActionPushButton, self).__init__(parent)
996        self.setAutoDefault(False)  # action won't be used as dialog default
997        self._defaultAction = action
998        self.addAction(action)
999        self.clicked.connect(action.trigger)
1000        self._copyActionProps()
1001
1002    def actionEvent(self, event):
1003        if (event.type() == QEvent.ActionChanged
1004            and event.action() is self._defaultAction):
1005            self._copyActionProps()
1006        super(ActionPushButton, self).actionEvent(event)
1007
1008    def _copyActionProps(self):
1009        action = self._defaultAction
1010        self.setEnabled(action.isEnabled())
1011        self.setText(action.text())
1012        self.setToolTip(action.toolTip())
1013
1014class PMButton(QPushButton):
1015    """Toggle button with plus/minus icon images"""
1016
1017    def __init__(self, expanded=True, parent=None):
1018        QPushButton.__init__(self, parent)
1019
1020        size = QSize(11, 11)
1021        self.setIconSize(size)
1022        self.setMaximumSize(size)
1023        self.setFlat(True)
1024        self.setAutoDefault(False)
1025
1026        self.plus = QIcon(iconpath('expander-open.png'))
1027        self.minus = QIcon(iconpath('expander-close.png'))
1028        icon = expanded and self.minus or self.plus
1029        self.setIcon(icon)
1030
1031        self.clicked.connect(self._toggle_icon)
1032
1033    @pyqtSlot()
1034    def _toggle_icon(self):
1035        icon = self.is_expanded() and self.plus or self.minus
1036        self.setIcon(icon)
1037
1038    def set_expanded(self, state=True):
1039        icon = state and self.minus or self.plus
1040        self.setIcon(icon)
1041
1042    def set_collapsed(self, state=True):
1043        icon = state and self.plus or self.minus
1044        self.setIcon(icon)
1045
1046    def is_expanded(self):
1047        return self.icon().cacheKey() == self.minus.cacheKey()
1048
1049    def is_collapsed(self):
1050        return not self.is_expanded()
1051
1052class ClickableLabel(QLabel):
1053
1054    clicked = pyqtSignal()
1055
1056    def __init__(self, label, parent=None):
1057        QLabel.__init__(self, parent)
1058
1059        self.setText(label)
1060
1061    def mouseReleaseEvent(self, event):
1062        self.clicked.emit()
1063
1064class ExpanderLabel(QWidget):
1065
1066    expanded = pyqtSignal(bool)
1067
1068    def __init__(self, label, expanded=True, stretch=True, parent=None):
1069        QWidget.__init__(self, parent)
1070
1071        box = QHBoxLayout()
1072        box.setSpacing(4)
1073        box.setContentsMargins(*(0,)*4)
1074        self.button = PMButton(expanded, self)
1075        self.button.clicked.connect(self.pm_clicked)
1076        box.addWidget(self.button)
1077        self.label = ClickableLabel(label, self)
1078        self.label.clicked.connect(self.button.click)
1079        box.addWidget(self.label)
1080        if not stretch:
1081            box.addStretch(0)
1082
1083        self.setLayout(box)
1084
1085    def pm_clicked(self):
1086        self.expanded.emit(self.button.is_expanded())
1087
1088    def set_expanded(self, state=True):
1089        if not self.button.is_expanded() == state:
1090            self.button.set_expanded(state)
1091            self.expanded.emit(state)
1092
1093    def is_expanded(self):
1094        return self.button.is_expanded()
1095
1096class StatusLabel(QWidget):
1097
1098    def __init__(self, parent=None):
1099        QWidget.__init__(self, parent)
1100        # same policy as status bar of QMainWindow
1101        self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
1102
1103        box = QHBoxLayout()
1104        box.setContentsMargins(*(0,)*4)
1105        self.status_icon = QLabel()
1106        self.status_icon.setMaximumSize(16, 16)
1107        self.status_icon.setAlignment(Qt.AlignCenter)
1108        box.addWidget(self.status_icon)
1109        self.status_text = QLabel()
1110        self.status_text.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
1111        box.addWidget(self.status_text)
1112        box.addStretch(0)
1113
1114        self.setLayout(box)
1115
1116    def set_status(self, text, icon=None):
1117        self.set_text(text)
1118        self.set_icon(icon)
1119
1120    def clear_status(self):
1121        self.clear_text()
1122        self.clear_icon()
1123
1124    def set_text(self, text=''):
1125        if text is None:
1126            text = ''
1127        self.status_text.setText(text)
1128
1129    def clear_text(self):
1130        self.set_text()
1131
1132    def set_icon(self, icon=None):
1133        if icon is None:
1134            self.clear_icon()
1135        else:
1136            if isinstance(icon, bool):
1137                icon = geticon(icon and 'thg-success' or 'thg-error')
1138            elif hglib.isbasestring(icon):
1139                icon = geticon(icon)
1140            elif not isinstance(icon, QIcon):
1141                raise TypeError('%s: bool, str or QIcon' % type(icon))
1142            self.status_icon.setVisible(True)
1143            self.status_icon.setPixmap(icon.pixmap(16, 16))
1144
1145    def clear_icon(self):
1146        self.status_icon.setHidden(True)
1147
1148class LabeledSeparator(QWidget):
1149
1150    def __init__(self, label=None, parent=None):
1151        QWidget.__init__(self, parent)
1152
1153        box = QHBoxLayout()
1154        box.setContentsMargins(*(0,)*4)
1155
1156        if label:
1157            if hglib.isbasestring(label):
1158                label = QLabel(label)
1159            box.addWidget(label)
1160
1161        sep = QFrame()
1162        sep.setFrameShadow(QFrame.Sunken)
1163        sep.setFrameShape(QFrame.HLine)
1164        box.addWidget(sep, 1, Qt.AlignVCenter)
1165
1166        self.setLayout(box)
1167
1168class WidgetGroups(object):
1169    """ Support for bulk-updating properties of Qt widgets """
1170
1171    def __init__(self):
1172        object.__init__(self)
1173
1174        self.clear(all=True)
1175
1176    ### Public Methods ###
1177
1178    def add(self, widget, group='default'):
1179        if group not in self.groups:
1180            self.groups[group] = []
1181        widgets = self.groups[group]
1182        if widget not in widgets:
1183            widgets.append(widget)
1184
1185    def remove(self, widget, group='default'):
1186        if group not in self.groups:
1187            return
1188        widgets = self.groups[group]
1189        if widget in widgets:
1190            widgets.remove(widget)
1191
1192    def clear(self, group='default', all=True):
1193        if all:
1194            self.groups = {}
1195        else:
1196            del self.groups[group]
1197
1198    def set_prop(self, prop, value, group='default', cond=None):
1199        if group not in self.groups:
1200            return
1201        widgets = self.groups[group]
1202        if callable(cond):
1203            widgets = [w for w in widgets if cond(w)]
1204        for widget in widgets:
1205            getattr(widget, prop)(value)
1206
1207    def set_visible(self, *args, **kargs):
1208        self.set_prop('setVisible', *args, **kargs)
1209
1210    def set_enable(self, *args, **kargs):
1211        self.set_prop('setEnabled', *args, **kargs)
1212
1213class DialogKeeper(QObject):
1214    """Manage non-blocking dialogs identified by creation parameters
1215
1216    Example "open single dialog per type":
1217
1218    >>> mainwin = QWidget()
1219    >>> dialogs = DialogKeeper(lambda self, cls: cls(self), parent=mainwin)
1220    >>> dlg1 = dialogs.open(QDialog)
1221    >>> dlg1.parent() is mainwin
1222    True
1223    >>> dlg2 = dialogs.open(QDialog)
1224    >>> dlg1 is dlg2
1225    True
1226    >>> dialogs.count()
1227    1
1228
1229    closed dialog will be deleted:
1230
1231    >>> from .qtcore import QEventLoop, QTimer
1232    >>> def processDeferredDeletion():
1233    ...     loop = QEventLoop()
1234    ...     QTimer.singleShot(0, loop.quit)
1235    ...     loop.exec_()
1236
1237    >>> dlg1.reject()
1238    >>> processDeferredDeletion()
1239    >>> dialogs.count()
1240    0
1241
1242    and recreates as necessary:
1243
1244    >>> dlg3 = dialogs.open(QDialog)
1245    >>> dlg1 is dlg3
1246    False
1247
1248    creates new dialog of the same type:
1249
1250    >>> dlg4 = dialogs.openNew(QDialog)
1251    >>> dlg3 is dlg4
1252    False
1253    >>> dialogs.count()
1254    2
1255
1256    and the last dialog is preferred:
1257
1258    >>> dialogs.open(QDialog) is dlg4
1259    True
1260    >>> dlg4.reject()
1261    >>> processDeferredDeletion()
1262    >>> dialogs.count()
1263    1
1264    >>> dialogs.open(QDialog) is dlg3
1265    True
1266
1267    The following example is not recommended because it creates reference
1268    cycles and makes hard to garbage-collect::
1269
1270        self._dialogs = DialogKeeper(self._createDialog)
1271        self._dialogs = DialogKeeper(lambda *args: Foo(self))
1272    """
1273
1274    def __init__(self, createdlg, genkey=None, parent=None):
1275        super(DialogKeeper, self).__init__(parent)
1276        self._createdlg = createdlg
1277        self._genkey = genkey or DialogKeeper._defaultgenkey
1278        self._keytodlgs = {}  # key: [dlg, ...]
1279
1280    def open(self, *args, **kwargs):
1281        """Create new dialog or reactivate existing dialog"""
1282        dlg = self._preparedlg(self._genkey(self.parent(), *args, **kwargs),
1283                               args, kwargs)
1284        dlg.show()
1285        dlg.raise_()
1286        dlg.activateWindow()
1287        return dlg
1288
1289    def openNew(self, *args, **kwargs):
1290        """Create new dialog even if there exists the specified one"""
1291        dlg = self._populatedlg(self._genkey(self.parent(), *args, **kwargs),
1292                                args, kwargs)
1293        dlg.show()
1294        dlg.raise_()
1295        dlg.activateWindow()
1296        return dlg
1297
1298    def _preparedlg(self, key, args, kwargs):
1299        if key in self._keytodlgs:
1300            assert len(self._keytodlgs[key]) > 0, key
1301            return self._keytodlgs[key][-1]  # prefer latest
1302        else:
1303            return self._populatedlg(key, args, kwargs)
1304
1305    def _populatedlg(self, key, args, kwargs):
1306        dlg = self._createdlg(self.parent(), *args, **kwargs)
1307        if key not in self._keytodlgs:
1308            self._keytodlgs[key] = []
1309        self._keytodlgs[key].append(dlg)
1310        dlg.setAttribute(Qt.WA_DeleteOnClose)
1311        dlg.destroyed.connect(self._cleanupdlgs)
1312        return dlg
1313
1314    # "destroyed" is emitted soon after Python wrapper is deleted
1315    @pyqtSlot()
1316    def _cleanupdlgs(self):
1317        for key, dialogs in list(self._keytodlgs.items()):
1318            livedialogs = [dlg for dlg in dialogs if not sip.isdeleted(dlg)]
1319            if livedialogs:
1320                self._keytodlgs[key] = livedialogs
1321            else:
1322                del self._keytodlgs[key]
1323
1324    def count(self):
1325        return sum(len(dlgs) for dlgs in self._keytodlgs.values())
1326
1327    @staticmethod
1328    def _defaultgenkey(_parent, *args, **_kwargs):
1329        return args
1330
1331class TaskWidget(object):
1332    def canswitch(self):
1333        """Return True if the widget allows to switch away from it"""
1334        return True
1335
1336    def canExit(self):
1337        return True
1338
1339    def reload(self):
1340        pass
1341
1342class DemandWidget(QWidget):
1343    'Create a widget the first time it is shown'
1344
1345    def __init__(self, createfuncname, createinst, parent=None):
1346        super(DemandWidget, self).__init__(parent)
1347        # We store a reference to the create function name to avoid having a
1348        # hard reference to the bound function, which prevents it being
1349        # disposed. Weak references to bound functions don't work.
1350        self._createfuncname = createfuncname
1351        self._createinst = weakref.ref(createinst)
1352        self._widget = None
1353        vbox = QVBoxLayout()
1354        vbox.setContentsMargins(*(0,)*4)
1355        self.setLayout(vbox)
1356
1357    def showEvent(self, event):
1358        """create the widget if necessary"""
1359        self.get()
1360        super(DemandWidget, self).showEvent(event)
1361
1362    def forward(self, funcname, *args, **opts):
1363        if self._widget:
1364            return getattr(self._widget, funcname)(*args, **opts)
1365        return None
1366
1367    def get(self):
1368        """Returns the stored widget"""
1369        if self._widget is None:
1370            func = getattr(self._createinst(), self._createfuncname, None)
1371            self._widget = func()
1372            self.layout().addWidget(self._widget)
1373        return self._widget
1374
1375    def canswitch(self):
1376        """Return True if the widget allows to switch away from it"""
1377        if self._widget is None:
1378            return True
1379        return self._widget.canswitch()
1380
1381    def canExit(self):
1382        if self._widget is None:
1383            return True
1384        return self._widget.canExit()
1385
1386    def __getattr__(self, name):
1387        return getattr(self._widget, name)
1388
1389class Spacer(QWidget):
1390    """Spacer to separate controls in a toolbar"""
1391
1392    def __init__(self, width, height, parent=None):
1393        QWidget.__init__(self, parent)
1394        self.width = width
1395        self.height = height
1396
1397    def sizeHint(self):
1398        return QSize(self.width, self.height)
1399
1400def getCurrentUsername(widget, repo, opts=None):
1401    # type: (Optional[QWidget], Any, Optional[Dict[Text, Text]]) -> Optional[Text]
1402    if opts:
1403        # 1. Override has highest priority
1404        user = opts.get('user')
1405        if user:
1406            return user
1407
1408    # 2. Read from repository
1409    user = hglib.configuredusername(repo.ui)
1410    if user:
1411        return hglib.tounicode(user)
1412
1413    # 3. Get a username from the user
1414    QMessageBox.information(widget, _('Please enter a username'),
1415                _('You must identify yourself to Mercurial'),
1416                QMessageBox.Ok)
1417    from tortoisehg.hgqt.settings import SettingsDialog
1418    dlg = SettingsDialog(False, focus='ui.username')
1419    dlg.exec_()
1420    repo.invalidateui()
1421    return hglib.tounicode(hglib.configuredusername(repo.ui))
1422
1423class _EncodingSafeInputDialog(QInputDialog):
1424    def accept(self):
1425        try:
1426            hglib.fromunicode(self.textValue())
1427            return super(_EncodingSafeInputDialog, self).accept()
1428        except UnicodeEncodeError:
1429            WarningMsgBox(_('Text Translation Failure'),
1430                          _('Unable to translate input to local encoding.'),
1431                          parent=self)
1432
1433def getTextInput(parent, title, label, mode=QLineEdit.Normal, text='',
1434                 flags=Qt.WindowFlags()):
1435    flags |= (Qt.CustomizeWindowHint | Qt.WindowTitleHint
1436              | Qt.WindowCloseButtonHint)
1437    dlg = _EncodingSafeInputDialog(parent, flags)
1438    dlg.setWindowTitle(title)
1439    dlg.setLabelText(label)
1440    dlg.setTextValue(text)
1441    dlg.setTextEchoMode(mode)
1442
1443    r = dlg.exec_()
1444    dlg.setParent(None)  # so that garbage collected
1445    return r and dlg.textValue() or '', bool(r)
1446
1447def keysequence(o):
1448    """Create QKeySequence from string or QKeySequence"""
1449    if isinstance(o, (QKeySequence, QKeySequence.StandardKey)):
1450        return o
1451    try:
1452        return getattr(QKeySequence, str(o))  # standard key
1453    except AttributeError:
1454        return QKeySequence(o)
1455
1456def newshortcutsforstdkey(key, *args, **kwargs):
1457    """Create [QShortcut,...] for all key bindings of the given StandardKey"""
1458    return [QShortcut(keyseq, *args, **kwargs)
1459            for keyseq in QKeySequence.keyBindings(key)]
1460
1461class PaletteSwitcher(object):
1462    """
1463    Class that can be used to enable a predefined, alterantive background color
1464    for a widget
1465
1466    This is normally used to change the color of widgets when they display some
1467    "filtered" content which is a subset of the actual widget contents.
1468
1469    The alternative background color is fixed, and depends on the original
1470    background color (dark and light backgrounds use a different alternative
1471    color).
1472
1473    The alterenative color cannot be changed because the idea is to set a
1474    consistent "filter" style for all widgets.
1475
1476    An instance of this class must be added as a property of the widget whose
1477    background we want to change. The constructor takes the "target widget" as
1478    its only parameter.
1479
1480    In order to enable or disable the background change, simply call the
1481    enablefilterpalette() method.
1482    """
1483    def __init__(self, targetwidget):
1484        self._targetwref = weakref.ref(targetwidget)  # avoid circular ref
1485        self._defaultpalette = targetwidget.palette()
1486        if not isDarkTheme(self._defaultpalette):
1487            filterbgcolor = QColor('#FFFFB7')
1488        else:
1489            filterbgcolor = QColor('darkgrey')
1490        self._filterpalette = QPalette()
1491        self._filterpalette.setColor(QPalette.Base, filterbgcolor)
1492
1493    def enablefilterpalette(self, enabled=False):
1494        targetwidget = self._targetwref()
1495        if not targetwidget:
1496            return
1497        if enabled:
1498            pl = self._filterpalette
1499        else:
1500            pl = self._defaultpalette
1501        targetwidget.setPalette(pl)
1502
1503def setContextMenuShortcut(action, shortcut):
1504    """Set shortcut for a context menu action, making sure it's visible"""
1505    action.setShortcut(shortcut)
1506    if QT_VERSION >= 0x50a00 and PYQT_VERSION >= 0x50a00:
1507        action.setShortcutVisibleInContextMenu(True)
1508
1509def setContextMenuShortcuts(action, shortcuts):
1510    # type: (QAction, List[QKeySequence]) -> None
1511    """Set shortcuts for a context menu action, making sure it's visible"""
1512    action.setShortcuts(shortcuts)
1513    if QT_VERSION >= 0x50a00 and PYQT_VERSION >= 0x50a00:
1514        action.setShortcutVisibleInContextMenu(True)
1515