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 <bar> \\n& <baz>' 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&bax#blah">', 541 u'http://example.com:8000/foo?bar=baz&bax#blah</a>') 542 >>> htmlize('https://example/') 543 u'<a href="https://example/">https://example/</a>' 544 >>> htmlize('<https://example/>') 545 u'<<a href="https://example/">https://example/</a>>' 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