1# fileview.py - File diff, content, and annotation display widget 2# 3# Copyright 2010 Steve Borho <steve@borho.org> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2, incorporated herein by reference. 7 8from __future__ import absolute_import 9 10import difflib 11import os 12import re 13 14from . import qsci as Qsci 15from .qtcore import ( 16 QEvent, 17 QObject, 18 QPoint, 19 QSettings, 20 QTime, 21 QTimer, 22 Qt, 23 pyqtSignal, 24 pyqtSlot, 25) 26from .qtgui import ( 27 QAction, 28 QActionGroup, 29 QApplication, 30 QColor, 31 QFontMetrics, 32 QFrame, 33 QInputDialog, 34 QKeySequence, 35 QLabel, 36 QPalette, 37 QShortcut, 38 QStyle, 39 QToolBar, 40 QHBoxLayout, 41 QVBoxLayout, 42 QWidget, 43) 44 45from mercurial import ( 46 pycompat, 47 util, 48) 49 50from mercurial.utils import ( 51 dateutil, 52) 53 54from ..util import ( 55 colormap, 56 hglib, 57) 58from ..util.i18n import _ 59from . import ( 60 blockmatcher, 61 cmdcore, 62 filedata, 63 fileencoding, 64 lexers, 65 qscilib, 66 qtlib, 67 visdiff, 68) 69 70if hglib.TYPE_CHECKING: 71 from typing import ( 72 Optional, 73 ) 74 75qsci = qscilib.Scintilla 76 77# _NullMode is the fallback mode to display error message or repository history 78_NullMode = 0 79DiffMode = 1 80FileMode = 2 81AnnMode = 3 82 83_LineNumberMargin = 1 84_AnnotateMargin = 2 85_ChunkSelectionMargin = 4 86 87_ChunkStartMarker = 0 88_IncludedChunkStartMarker = 1 89_ExcludedChunkStartMarker = 2 90_InsertedLineMarker = 3 91_ReplacedLineMarker = 4 92_ExcludedLineMarker = 5 93_FirstAnnotateLineMarker = 6 # to 31 94 95_ChunkSelectionMarkerMask = ( 96 (1 << _IncludedChunkStartMarker) | (1 << _ExcludedChunkStartMarker)) 97 98class HgFileView(QFrame): 99 "file diff, content, and annotation viewer" 100 101 linkActivated = pyqtSignal(str) 102 fileDisplayed = pyqtSignal(str, str) 103 showMessage = pyqtSignal(str) 104 revisionSelected = pyqtSignal(int) 105 shelveToolExited = pyqtSignal() 106 chunkSelectionChanged = pyqtSignal() 107 108 grepRequested = pyqtSignal(str, dict) 109 """Emitted (pattern, opts) when user request to search changelog""" 110 111 def __init__(self, repoagent, parent): 112 QFrame.__init__(self, parent) 113 framelayout = QVBoxLayout(self) 114 framelayout.setContentsMargins(0,0,0,0) 115 116 l = QHBoxLayout() 117 l.setContentsMargins(0,0,0,0) 118 l.setSpacing(0) 119 120 self._repoagent = repoagent 121 repo = repoagent.rawRepo() 122 123 self.topLayout = QVBoxLayout() 124 125 self.labelhbox = hbox = QHBoxLayout() 126 hbox.setContentsMargins(0,0,0,0) 127 hbox.setSpacing(2) 128 self.topLayout.addLayout(hbox) 129 130 self.diffToolbar = QToolBar(_('Diff Toolbar')) 131 self.diffToolbar.setIconSize(qtlib.smallIconSize()) 132 self.diffToolbar.setStyleSheet(qtlib.tbstylesheet) 133 hbox.addWidget(self.diffToolbar) 134 135 self.filenamelabel = w = QLabel() 136 w.setWordWrap(True) 137 f = w.textInteractionFlags() 138 w.setTextInteractionFlags(f | Qt.TextSelectableByMouse) 139 w.linkActivated.connect(self.linkActivated) 140 hbox.addWidget(w, 1) 141 142 self.extralabel = w = QLabel() 143 w.setWordWrap(True) 144 w.linkActivated.connect(self.linkActivated) 145 self.topLayout.addWidget(w) 146 w.hide() 147 148 framelayout.addLayout(self.topLayout) 149 framelayout.addLayout(l, 1) 150 151 hbox = QHBoxLayout() 152 hbox.setContentsMargins(0, 0, 0, 0) 153 hbox.setSpacing(0) 154 l.addLayout(hbox) 155 156 self.blk = blockmatcher.BlockList(self) 157 self.blksearch = blockmatcher.BlockList(self) 158 self.sci = qscilib.Scintilla(self) 159 hbox.addWidget(self.blk) 160 hbox.addWidget(self.sci, 1) 161 hbox.addWidget(self.blksearch) 162 163 self.sci.cursorPositionChanged.connect(self._updateDiffActions) 164 self.sci.setContextMenuPolicy(Qt.CustomContextMenu) 165 self.sci.customContextMenuRequested.connect(self._onMenuRequested) 166 self.sci.SCN_ZOOM.connect(self._updateScrollBar) 167 168 self.blk.linkScrollBar(self.sci.verticalScrollBar()) 169 self.blk.setVisible(False) 170 self.blksearch.linkScrollBar(self.sci.verticalScrollBar()) 171 self.blksearch.setVisible(False) 172 173 self.sci.setReadOnly(True) 174 self.sci.setUtf8(True) 175 self.sci.installEventFilter(qscilib.KeyPressInterceptor(self)) 176 self.sci.setCaretLineVisible(False) 177 178 self.sci.markerDefine(qsci.Invisible, _ChunkStartMarker) 179 180 # hide margin 0 (markers) 181 self.sci.setMarginType(0, qsci.SymbolMargin) 182 self.sci.setMarginWidth(0, 0) 183 184 self.searchbar = qscilib.SearchToolBar() 185 self.searchbar.hide() 186 self.searchbar.searchRequested.connect(self.find) 187 self.searchbar.conditionChanged.connect(self.highlightText) 188 self.addActions(self.searchbar.editorActions()) 189 self.layout().addWidget(self.searchbar) 190 191 self._fd = self._nullfd = filedata.createNullData(repo) 192 self._lostMode = _NullMode 193 self._lastSearch = u'', False 194 195 self._modeToggleGroup = QActionGroup(self) 196 self._modeToggleGroup.triggered.connect(self._setModeByAction) 197 self._modeActionMap = {} 198 for mode, icon, tooltip in [ 199 (DiffMode, 'view-diff', _('View change as unified diff ' 200 'output')), 201 (FileMode, 'view-file', _('View change in context of file')), 202 (AnnMode, 'view-annotate', _('Annotate with revision numbers')), 203 (_NullMode, '', '')]: 204 if icon: 205 a = self._modeToggleGroup.addAction(qtlib.geticon(icon), '') 206 else: 207 a = self._modeToggleGroup.addAction('') 208 self._modeActionMap[mode] = a 209 a.setCheckable(True) 210 a.setData(mode) 211 a.setToolTip(tooltip) 212 213 diffc = _DiffViewControl(self.sci, self) 214 diffc.chunkMarkersBuilt.connect(self._updateDiffActions) 215 filec = _FileViewControl(repo.ui, self.sci, self.blk, self) 216 filec.chunkMarkersBuilt.connect(self._updateDiffActions) 217 messagec = _MessageViewControl(self.sci, self) 218 messagec.forceDisplayRequested.connect(self._forceDisplayFile) 219 annotatec = _AnnotateViewControl(repoagent, self.sci, self._fd, self) 220 annotatec.showMessage.connect(self.showMessage) 221 annotatec.editSelectedRequested.connect(self._editSelected) 222 annotatec.grepRequested.connect(self.grepRequested) 223 annotatec.searchSelectedTextRequested.connect(self._searchSelectedText) 224 annotatec.setSourceRequested.connect(self._setSource) 225 annotatec.visualDiffRevisionRequested.connect(self._visualDiffRevision) 226 annotatec.visualDiffToLocalRequested.connect(self._visualDiffToLocal) 227 chunkselc = _ChunkSelectionViewControl(self.sci, self._fd, self) 228 chunkselc.chunkSelectionChanged.connect(self.chunkSelectionChanged) 229 230 self._activeViewControls = [] 231 self._modeViewControlsMap = { 232 DiffMode: [diffc], 233 FileMode: [filec], 234 AnnMode: [filec, annotatec], 235 _NullMode: [messagec], 236 } 237 self._chunkSelectionViewControl = chunkselc # enabled as necessary 238 239 # Next/Prev diff (in full file mode) 240 self.actionNextDiff = a = QAction(qtlib.geticon('go-down'), 241 _('Next Diff'), self) 242 a.setShortcut('Alt+Down') 243 a.setToolTip('%s (%s)' % (a.text(), a.shortcut().toString())) 244 a.triggered.connect(self._nextDiff) 245 self.actionPrevDiff = a = QAction(qtlib.geticon('go-up'), 246 _('Previous Diff'), self) 247 a.setShortcut('Alt+Up') 248 a.setToolTip('%s (%s)' % (a.text(), a.shortcut().toString())) 249 a.triggered.connect(self._prevDiff) 250 251 self._parentToggleGroup = QActionGroup(self) 252 self._parentToggleGroup.triggered.connect(self._setParentRevision) 253 for text in '12': 254 a = self._parentToggleGroup.addAction(text) 255 a.setCheckable(True) 256 a.setShortcut('Ctrl+Shift+%s' % text) 257 258 self.actionFind = self.searchbar.toggleViewAction() 259 self.actionFind.setIcon(qtlib.geticon('edit-find')) 260 self.actionFind.setToolTip(_('Toggle display of text search bar')) 261 self.actionFind.triggered.connect(self._onSearchbarTriggered) 262 qtlib.newshortcutsforstdkey(QKeySequence.Find, self, 263 self._showSearchbar) 264 265 self.actionShelf = QAction('Shelve', self) 266 self.actionShelf.setIcon(qtlib.geticon('hg-shelve')) 267 self.actionShelf.setToolTip(_('Open shelve tool')) 268 self.actionShelf.setVisible(False) 269 self.actionShelf.triggered.connect(self._launchShelve) 270 271 self._actionAutoTextEncoding = a = QAction(_('&Auto Detect'), self) 272 a.setCheckable(True) 273 self._textEncodingGroup = fileencoding.createActionGroup(self) 274 self._textEncodingGroup.triggered.connect(self._applyTextEncoding) 275 276 tb = self.diffToolbar 277 tb.addActions(self._parentToggleGroup.actions()) 278 tb.addSeparator() 279 tb.addActions(self._modeToggleGroup.actions()[:-1]) 280 tb.addSeparator() 281 tb.addAction(self.actionNextDiff) 282 tb.addAction(self.actionPrevDiff) 283 tb.addAction(filec.gotoLineAction()) 284 tb.addSeparator() 285 tb.addAction(self.actionFind) 286 tb.addAction(self.actionShelf) 287 288 self._clearMarkup() 289 self._changeEffectiveMode(_NullMode) 290 291 repoagent.configChanged.connect(self._applyRepoConfig) 292 self._applyRepoConfig() 293 294 @property 295 def repo(self): 296 return self._repoagent.rawRepo() 297 298 @pyqtSlot() 299 def _launchShelve(self): 300 from tortoisehg.hgqt import shelve 301 # TODO: pass self._fd.canonicalFilePath() 302 dlg = shelve.ShelveDialog(self._repoagent, self) 303 dlg.finished.connect(dlg.deleteLater) 304 dlg.exec_() 305 self.shelveToolExited.emit() 306 307 def setShelveButtonVisible(self, visible): 308 self.actionShelf.setVisible(visible) 309 310 def loadSettings(self, qs, prefix): 311 self.sci.loadSettings(qs, prefix) 312 self._actionAutoTextEncoding.setChecked( 313 qtlib.readBool(qs, prefix + '/autotextencoding', True)) 314 enc = qtlib.readString(qs, prefix + '/textencoding') 315 if enc: 316 try: 317 # prefer repository-specific encoding if specified 318 enc = fileencoding.contentencoding(self.repo.ui, enc) 319 except LookupError: 320 enc = '' 321 if enc: 322 self._changeTextEncoding(enc) 323 324 def saveSettings(self, qs, prefix): 325 self.sci.saveSettings(qs, prefix) 326 qs.setValue(prefix + '/autotextencoding', self._autoTextEncoding()) 327 qs.setValue(prefix + '/textencoding', self._textEncoding()) 328 329 @pyqtSlot() 330 def _applyRepoConfig(self): 331 self.sci.setIndentationWidth(self.repo.tabwidth) 332 self.sci.setTabWidth(self.repo.tabwidth) 333 enc = fileencoding.contentencoding(self.repo.ui, self._textEncoding()) 334 self._changeTextEncoding(enc) 335 336 def isChangeSelectionEnabled(self): 337 chunkselc = self._chunkSelectionViewControl 338 controls = self._modeViewControlsMap[DiffMode] 339 return chunkselc in controls 340 341 def enableChangeSelection(self, enable): 342 'Enable the use of a selection margin when a diff view is active' 343 # Should only be called with True from the commit tool when it is in 344 # a 'commit' mode and False for other uses 345 if self.isChangeSelectionEnabled() == bool(enable): 346 return 347 chunkselc = self._chunkSelectionViewControl 348 controls = self._modeViewControlsMap[DiffMode] 349 if enable: 350 controls.append(chunkselc) 351 else: 352 controls.remove(chunkselc) 353 if self._effectiveMode() == DiffMode: 354 self._changeEffectiveMode(DiffMode) 355 356 @pyqtSlot(QAction) 357 def _setModeByAction(self, action): 358 'One of the mode toolbar buttons has been toggled' 359 mode = action.data() 360 self._lostMode = _NullMode 361 self._changeEffectiveMode(mode) 362 self._displayLoaded(self._fd) 363 364 def _effectiveMode(self): 365 a = self._modeToggleGroup.checkedAction() 366 return a.data() 367 368 def _changeEffectiveMode(self, mode): 369 self._modeActionMap[mode].setChecked(True) 370 371 newcontrols = list(self._modeViewControlsMap[mode]) 372 for c in reversed(self._activeViewControls): 373 if c not in newcontrols: 374 c.close() 375 for c in newcontrols: 376 if c not in self._activeViewControls: 377 c.open() 378 self._activeViewControls = newcontrols 379 380 def _restrictModes(self, available): 381 'Disable modes based on content constraints' 382 available.add(_NullMode) 383 for m, a in self._modeActionMap.items(): 384 a.setEnabled(m in available) 385 self._fallBackToAvailableMode() 386 387 def _fallBackToAvailableMode(self): 388 if self._lostMode and self._modeActionMap[self._lostMode].isEnabled(): 389 self._changeEffectiveMode(self._lostMode) 390 self._lostMode = _NullMode 391 return 392 curmode = self._effectiveMode() 393 if curmode and self._modeActionMap[curmode].isEnabled(): 394 return 395 fallbackmode = next(iter(a.data() 396 for a in self._modeToggleGroup.actions() 397 if a.isEnabled())) 398 if not self._lostMode: 399 self._lostMode = curmode 400 self._changeEffectiveMode(fallbackmode) 401 402 def _modeAction(self, mode): 403 if not mode: 404 raise ValueError('null mode cannot be set explicitly') 405 try: 406 return self._modeActionMap[mode] 407 except KeyError: 408 raise ValueError('invalid mode: %r' % mode) 409 410 def setMode(self, mode): 411 """Switch view to DiffMode/FileMode/AnnMode if available for the current 412 content; otherwise it will be switched later""" 413 action = self._modeAction(mode) 414 if action.isEnabled(): 415 if not action.isChecked(): 416 action.trigger() # implies _setModeByAction() 417 else: 418 self._lostMode = mode 419 420 @pyqtSlot(QAction) 421 def _setParentRevision(self, action): 422 fd = self._fd 423 ctx = fd.rawContext() 424 pctx = {'1': ctx.p1, '2': ctx.p2}[str(action.text())]() 425 self.display(fd.createRebased(pctx)) 426 427 def _updateFileDataActions(self): 428 fd = self._fd 429 ctx = fd.rawContext() 430 parents = ctx.parents() 431 ismerge = len(parents) == 2 432 self._parentToggleGroup.setVisible(ismerge) 433 tooltips = [_('Show changes from first parent'), 434 _('Show changes from second parent')] 435 for a, pctx, tooltip in zip(self._parentToggleGroup.actions(), 436 parents, tooltips): 437 firstline = hglib.longsummary(pctx.description()) 438 a.setToolTip('%s:\n%s [%d:%s] %s' 439 % (tooltip, hglib.tounicode(pctx.branch()), 440 pctx.rev(), pctx, firstline)) 441 a.setChecked(fd.baseRev() == pctx.rev()) 442 443 def _autoTextEncoding(self): 444 return self._actionAutoTextEncoding.isChecked() 445 446 def _textEncoding(self): 447 return fileencoding.checkedActionName(self._textEncodingGroup) 448 449 @pyqtSlot() 450 def _applyTextEncoding(self): 451 self._fd.setTextEncoding(self._textEncoding()) 452 self._displayLoaded(self._fd) 453 454 def _changeTextEncoding(self, enc): 455 fileencoding.checkActionByName(self._textEncodingGroup, enc) 456 if not self._fd.isNull(): 457 self._applyTextEncoding() 458 459 @pyqtSlot(str, int, int) 460 def _setSource(self, path, rev, line): 461 # BUG: not work for subrepo 462 self.revisionSelected.emit(rev) 463 ctx = self.repo[rev] 464 fd = filedata.createFileData(ctx, ctx.p1(), hglib.fromunicode(path)) 465 self.display(fd) 466 self.showLine(line) 467 468 def showLine(self, line): 469 if line < self.sci.lines(): 470 self.sci.setCursorPosition(line, 0) 471 472 def _moveAndScrollToLine(self, line): 473 self.sci.setCursorPosition(line, 0) 474 self.sci.verticalScrollBar().setValue(line) 475 476 def filePath(self): 477 return self._fd.filePath() 478 479 @pyqtSlot() 480 def clearDisplay(self): 481 self._displayLoaded(self._nullfd) 482 483 def _clearMarkup(self): 484 self.sci.clear() 485 self.sci.clearMarginText() 486 self.sci.markerDeleteAll() 487 self.blk.clear() 488 self.blksearch.clear() 489 # Setting the label to ' ' rather than clear() keeps the label 490 # from disappearing during refresh, and tool layouts bouncing 491 self.filenamelabel.setText(' ') 492 self.extralabel.hide() 493 self._updateDiffActions() 494 self._updateScrollBar() 495 496 @pyqtSlot() 497 def _forceDisplayFile(self): 498 self._fd.load(self.isChangeSelectionEnabled(), force=True) 499 self._displayLoaded(self._fd) 500 501 def display(self, fd): 502 if not fd.isLoaded(): 503 fd.load(self.isChangeSelectionEnabled()) 504 fd.setTextEncoding(self._textEncoding()) 505 if self._autoTextEncoding(): 506 fd.detectTextEncoding() 507 fileencoding.checkActionByName(self._textEncodingGroup, 508 fd.textEncoding()) 509 self._displayLoaded(fd) 510 511 def _displayLoaded(self, fd): 512 if self._fd.filePath() == fd.filePath(): 513 # Get the last visible line to restore it after reloading the editor 514 lastCursorPosition = self.sci.getCursorPosition() 515 lastScrollPosition = self.sci.firstVisibleLine() 516 else: 517 lastCursorPosition = (0, 0) 518 lastScrollPosition = 0 519 520 self._updateDisplay(fd) 521 522 # Recover the last cursor/scroll position 523 self.sci.setCursorPosition(*lastCursorPosition) 524 # Make sure that lastScrollPosition never exceeds the amount of 525 # lines on the editor 526 lastScrollPosition = min(lastScrollPosition, self.sci.lines() - 1) 527 self.sci.verticalScrollBar().setValue(lastScrollPosition) 528 529 def _updateDisplay(self, fd): 530 self._fd = fd 531 532 self._clearMarkup() 533 self._updateFileDataActions() 534 535 if fd.elabel: 536 self.extralabel.setText(fd.elabel) 537 self.extralabel.show() 538 else: 539 self.extralabel.hide() 540 self.filenamelabel.setText(fd.flabel) 541 542 availablemodes = set() 543 if fd.isValid(): 544 if fd.diff: 545 availablemodes.add(DiffMode) 546 if fd.contents: 547 availablemodes.add(FileMode) 548 if (fd.contents and (fd.rev() is None or fd.rev() >= 0) 549 and fd.fileStatus() != 'R'): 550 availablemodes.add(AnnMode) 551 self._restrictModes(availablemodes) 552 553 for c in self._activeViewControls: 554 c.display(fd) 555 556 self.highlightText(*self._lastSearch) 557 self.fileDisplayed.emit(fd.filePath(), fd.fileText()) 558 559 self.blksearch.syncPageStep() 560 self._updateScrollBar() 561 562 @pyqtSlot(str, bool, bool, bool) 563 def find(self, exp, icase=True, wrap=False, forward=True): 564 self.sci.find(exp, icase, wrap, forward) 565 566 @pyqtSlot(str, bool) 567 def highlightText(self, match, icase=False): 568 self._lastSearch = match, icase 569 self.sci.highlightText(match, icase) 570 blk = self.blksearch 571 blk.clear() 572 blk.setUpdatesEnabled(False) 573 blk.clear() 574 for l in self.sci.highlightLines: 575 blk.addBlock('s', l, l + 1) 576 blk.setVisible(bool(match)) 577 blk.setUpdatesEnabled(True) 578 579 def _loadSelectionIntoSearchbar(self): 580 text = self.sci.selectedText() 581 if text: 582 self.searchbar.setPattern(text) 583 584 @pyqtSlot(bool) 585 def _onSearchbarTriggered(self, checked): 586 if checked: 587 self._loadSelectionIntoSearchbar() 588 589 @pyqtSlot() 590 def _showSearchbar(self): 591 self._loadSelectionIntoSearchbar() 592 self.searchbar.show() 593 594 @pyqtSlot() 595 def _searchSelectedText(self): 596 self.searchbar.search(self.sci.selectedText()) 597 self.searchbar.show() 598 599 def verticalScrollBar(self): 600 return self.sci.verticalScrollBar() 601 602 def _findNextChunk(self): 603 mask = 1 << _ChunkStartMarker 604 line = self.sci.getCursorPosition()[0] 605 return self.sci.markerFindNext(line + 1, mask) 606 607 def _findPrevChunk(self): 608 mask = 1 << _ChunkStartMarker 609 line = self.sci.getCursorPosition()[0] - 1 610 if line < 0: 611 return -1 612 return self.sci.markerFindPrevious(line, mask) 613 614 @pyqtSlot() 615 def _nextDiff(self): 616 line = self._findNextChunk() 617 if line >= 0: 618 self._moveAndScrollToLine(line) 619 620 @pyqtSlot() 621 def _prevDiff(self): 622 line = self._findPrevChunk() 623 if line >= 0: 624 self._moveAndScrollToLine(line) 625 626 @pyqtSlot() 627 def _updateDiffActions(self): 628 self.actionNextDiff.setEnabled(self._findNextChunk() >= 0) 629 self.actionPrevDiff.setEnabled(self._findPrevChunk() >= 0) 630 631 @pyqtSlot(str, int, int) 632 def _editSelected(self, path, rev, line): 633 """Open editor to show the specified file""" 634 path = hglib.fromunicode(path) 635 base = visdiff.snapshot(self.repo, [path], self.repo[rev])[0] 636 files = [os.path.join(base, path)] 637 pattern = hglib.fromunicode(self.sci.selectedText()) 638 qtlib.editfiles(self.repo, files, line, pattern, self) 639 640 def _visualDiff(self, path, **opts): 641 path = hglib.fromunicode(path) 642 dlg = visdiff.visualdiff(self.repo.ui, self.repo, [path], opts) 643 if dlg: 644 dlg.exec_() 645 646 @pyqtSlot(str, int) 647 def _visualDiffRevision(self, path, rev): 648 self._visualDiff(path, change=rev) 649 650 @pyqtSlot(str, int) 651 def _visualDiffToLocal(self, path, rev): 652 self._visualDiff(path, rev=[str(rev)]) 653 654 @pyqtSlot(QPoint) 655 def _onMenuRequested(self, point): 656 menu = self._createContextMenu(point) 657 menu.exec_(self.sci.viewport().mapToGlobal(point)) 658 menu.setParent(None) 659 660 def _createContextMenu(self, point): 661 menu = self.sci.createEditorContextMenu() 662 m = menu.addMenu(_('E&ncoding')) 663 m.addAction(self._actionAutoTextEncoding) 664 m.addSeparator() 665 fileencoding.addActionsToMenu(m, self._textEncodingGroup) 666 667 line = self.sci.lineNearPoint(point) 668 669 selection = self.sci.selectedText() 670 def sreq(**opts): 671 return lambda: self.grepRequested.emit(selection, opts) 672 673 if self._effectiveMode() != AnnMode: 674 if selection: 675 menu.addSeparator() 676 menu.addAction(_('&Search in Current File'), 677 self._searchSelectedText) 678 menu.addAction(_('Search in All &History'), sreq(all=True)) 679 680 for c in self._activeViewControls: 681 c.setupContextMenu(menu, line) 682 return menu 683 684 @pyqtSlot() 685 def _updateScrollBar(self): 686 lexer = self.sci.lexer() 687 if lexer: 688 font = self.sci.lexer().font(0) 689 else: 690 font = self.sci.font() 691 fm = QFontMetrics(font) 692 693 lines = pycompat.unicode(self.sci.text()).splitlines() 694 if lines: 695 # assume that the longest line has the largest width; 696 # fm.width() is too slow to apply to each line. 697 longestline = max(lines, key=len) 698 maxWidth = fm.width(longestline) 699 else: 700 maxWidth = 0 701 # setScrollWidth() expects the value to be > 0 702 self.sci.setScrollWidth(max(maxWidth, 1)) 703 704 705class _AbstractViewControl(QObject): 706 """Provide the mode-specific view in HgFileView""" 707 708 def open(self): 709 raise NotImplementedError 710 711 def close(self): 712 raise NotImplementedError 713 714 def display(self, fd): 715 raise NotImplementedError 716 717 def setupContextMenu(self, menu, line): 718 pass 719 720 def _parentWidget(self): 721 # type: () -> Optional[QWidget] 722 p = self.parent() 723 assert p is None or isinstance(p, QWidget) 724 return p 725 726 727_diffHeaderRegExp = re.compile("^@@ -[0-9]+,[0-9]+ \+[0-9]+,[0-9]+ @@") 728 729class _DiffViewControl(_AbstractViewControl): 730 """Display the unified diff in HgFileView""" 731 732 chunkMarkersBuilt = pyqtSignal() 733 734 def __init__(self, sci, parent=None): 735 super(_DiffViewControl, self).__init__(parent) 736 self._sci = sci 737 self._buildtimer = QTimer(self) 738 self._buildtimer.timeout.connect(self._buildMarker) 739 self._linestoprocess = [] 740 self._firstlinetoprocess = 0 741 742 def open(self): 743 self._sci.markerDefine(qsci.Background, _ChunkStartMarker) 744 if qtlib.isDarkTheme(self._sci.palette()): 745 self._sci.setMarkerBackgroundColor(QColor('#204820'), 746 _ChunkStartMarker) 747 else: 748 self._sci.setMarkerBackgroundColor(QColor('#B0FFA0'), 749 _ChunkStartMarker) 750 self._sci.setLexer(lexers.difflexer(self)) 751 752 def close(self): 753 self._sci.markerDefine(qsci.Invisible, _ChunkStartMarker) 754 self._sci.setLexer(None) 755 self._buildtimer.stop() 756 757 def display(self, fd): 758 self._sci.setText(fd.diffText()) 759 self._startBuildMarker() 760 761 def _startBuildMarker(self): 762 self._linestoprocess = pycompat.unicode(self._sci.text()).splitlines() 763 self._firstlinetoprocess = 0 764 self._buildtimer.start() 765 766 @pyqtSlot() 767 def _buildMarker(self): 768 self._sci.setUpdatesEnabled(False) 769 770 # Process linesPerBlock lines at a time 771 linesPerBlock = 100 772 # Look for lines matching the "diff header" 773 for n, line in enumerate(self._linestoprocess[:linesPerBlock]): 774 if _diffHeaderRegExp.match(line): 775 diffLine = self._firstlinetoprocess + n 776 self._sci.markerAdd(diffLine, _ChunkStartMarker) 777 self._linestoprocess = self._linestoprocess[linesPerBlock:] 778 self._firstlinetoprocess += linesPerBlock 779 780 self._sci.setUpdatesEnabled(True) 781 782 if not self._linestoprocess: 783 self._buildtimer.stop() 784 self.chunkMarkersBuilt.emit() 785 786 787class _FileViewControl(_AbstractViewControl): 788 """Display the file content with chunk markers in HgFileView""" 789 790 chunkMarkersBuilt = pyqtSignal() 791 792 def __init__(self, ui, sci, blk, parent=None): 793 super(_FileViewControl, self).__init__(parent) 794 self._ui = ui 795 self._sci = sci 796 self._blk = blk 797 self._sci.setMarginLineNumbers(_LineNumberMargin, True) 798 self._sci.setMarginWidth(_LineNumberMargin, 0) 799 800 # define markers for colorize zones of diff 801 self._sci.markerDefine(qsci.Background, _InsertedLineMarker) 802 self._sci.markerDefine(qsci.Background, _ReplacedLineMarker) 803 if qtlib.isDarkTheme(self._sci.palette()): 804 self._sci.setMarkerBackgroundColor(QColor('#204820'), 805 _InsertedLineMarker) 806 self._sci.setMarkerBackgroundColor(QColor('#202050'), 807 _ReplacedLineMarker) 808 else: 809 self._sci.setMarkerBackgroundColor(QColor('#B0FFA0'), 810 _InsertedLineMarker) 811 self._sci.setMarkerBackgroundColor(QColor('#A0A0FF'), 812 _ReplacedLineMarker) 813 814 self._actionGotoLine = a = QAction(qtlib.geticon('go-jump'), 815 _('Go to Line'), self) 816 a.setEnabled(False) 817 a.setShortcut('Ctrl+J') 818 a.setToolTip('%s (%s)' % (a.text(), a.shortcut().toString())) 819 a.triggered.connect(self._gotoLineDialog) 820 821 self._buildtimer = QTimer(self) 822 self._buildtimer.timeout.connect(self._buildMarker) 823 self._opcodes = [] 824 825 def open(self): 826 self._blk.setVisible(True) 827 self._actionGotoLine.setEnabled(True) 828 829 def close(self): 830 self._blk.setVisible(False) 831 self._sci.setMarginWidth(_LineNumberMargin, 0) 832 self._sci.setLexer(None) 833 self._actionGotoLine.setEnabled(False) 834 self._buildtimer.stop() 835 836 def display(self, fd): 837 if fd.contents: 838 filename = fd.filePath() 839 lexer = lexers.getlexer(self._ui, filename, fd.contents, self) 840 self._sci.setLexer(lexer) 841 if lexer is None: 842 self._sci.setFont(qtlib.getfont('fontlog').font()) 843 self._sci.setText(fd.fileText()) 844 845 self._sci.setMarginsFont(self._sci.font()) 846 width = len(str(self._sci.lines())) + 2 # 2 for margin 847 self._sci.setMarginWidth(_LineNumberMargin, 'M' * width) 848 self._blk.syncPageStep() 849 850 if fd.contents and fd.olddata: 851 self._startBuildMarker(fd) 852 else: 853 self._buildtimer.stop() # in case previous request not finished 854 855 def _startBuildMarker(self, fd): 856 # use the difflib.SequenceMatcher, which returns a set of opcodes 857 # that must be parsed 858 olddata = fd.olddata.splitlines() 859 newdata = fd.contents.splitlines() 860 diff = difflib.SequenceMatcher(None, olddata, newdata) 861 self._opcodes = diff.get_opcodes() 862 self._buildtimer.start() 863 864 @pyqtSlot() 865 def _buildMarker(self): 866 self._sci.setUpdatesEnabled(False) 867 self._blk.setUpdatesEnabled(False) 868 869 for tag, alo, ahi, blo, bhi in self._opcodes[:30]: 870 if tag in ('replace', 'insert'): 871 self._sci.markerAdd(blo, _ChunkStartMarker) 872 if tag == 'replace': 873 self._blk.addBlock('x', blo, bhi) 874 for i in range(blo, bhi): 875 self._sci.markerAdd(i, _ReplacedLineMarker) 876 elif tag == 'insert': 877 self._blk.addBlock('+', blo, bhi) 878 for i in range(blo, bhi): 879 self._sci.markerAdd(i, _InsertedLineMarker) 880 elif tag in ('equal', 'delete'): 881 pass 882 else: 883 raise ValueError('unknown tag %r' % (tag,)) 884 self._opcodes = self._opcodes[30:] 885 886 self._sci.setUpdatesEnabled(True) 887 self._blk.setUpdatesEnabled(True) 888 889 if not self._opcodes: 890 self._buildtimer.stop() 891 self.chunkMarkersBuilt.emit() 892 893 def gotoLineAction(self): 894 return self._actionGotoLine 895 896 @pyqtSlot() 897 def _gotoLineDialog(self): 898 last = self._sci.lines() 899 if last == 0: 900 return 901 cur = self._sci.getCursorPosition()[0] + 1 902 line, ok = QInputDialog.getInt(self._parentWidget(), _('Go to Line'), 903 _('Enter line number (1 - %d)') % last, 904 cur, 1, last) 905 if ok: 906 self._sci.setCursorPosition(line - 1, 0) 907 self._sci.ensureLineVisible(line - 1) 908 self._sci.setFocus() 909 910 911class _MessageViewControl(_AbstractViewControl): 912 """Display error message or repository history in HgFileView""" 913 914 forceDisplayRequested = pyqtSignal() 915 916 def __init__(self, sci, parent=None): 917 super(_MessageViewControl, self).__init__(parent) 918 self._sci = sci 919 self._forceviewindicator = None 920 921 def open(self): 922 self._sci.setLexer(None) 923 self._sci.setFont(qtlib.getfont('fontlog').font()) 924 925 def close(self): 926 pass 927 928 def display(self, fd): 929 if not fd.isValid(): 930 errormsg = fd.error or '' 931 self._sci.setText(errormsg) 932 forcedisplaymsg = filedata.forcedisplaymsg 933 linkstart = errormsg.find(forcedisplaymsg) 934 if linkstart >= 0: 935 # add the link to force to view the data anyway 936 self._setupForceViewIndicator() 937 self._sci.fillIndicatorRange( 938 0, linkstart, 0, linkstart + len(forcedisplaymsg), 939 self._forceviewindicator) 940 elif fd.ucontents: 941 # subrepo summary and perhaps other data 942 self._sci.setText(fd.ucontents) 943 944 def _setupForceViewIndicator(self): 945 if self._forceviewindicator is not None: 946 return 947 self._forceviewindicator = self._sci.indicatorDefine( 948 self._sci.PlainIndicator) 949 self._sci.setIndicatorDrawUnder(True, self._forceviewindicator) 950 self._sci.setIndicatorForegroundColor( 951 QColor('blue'), self._forceviewindicator) 952 # delay until next event-loop in order to complete mouse release 953 self._sci.SCN_INDICATORRELEASE.connect(self._requestForceDisplay, 954 Qt.QueuedConnection) 955 956 @pyqtSlot() 957 def _requestForceDisplay(self): 958 self._sci.setText(_('Please wait while the file is opened ...')) 959 # Wait a little to ensure that the "wait message" is displayed 960 QTimer.singleShot(10, self.forceDisplayRequested) 961 962 963class _AnnotateViewControl(_AbstractViewControl): 964 """Display annotation margin and colorize file content in HgFileView""" 965 966 showMessage = pyqtSignal(str) 967 968 editSelectedRequested = pyqtSignal(str, int, int) 969 grepRequested = pyqtSignal(str, dict) 970 searchSelectedTextRequested = pyqtSignal() 971 setSourceRequested = pyqtSignal(str, int, int) 972 visualDiffRevisionRequested = pyqtSignal(str, int) 973 visualDiffToLocalRequested = pyqtSignal(str, int) 974 975 def __init__(self, repoagent, sci, fd, parent=None): 976 super(_AnnotateViewControl, self).__init__(parent) 977 self._repoagent = repoagent 978 self._cmdsession = cmdcore.nullCmdSession() 979 self._sci = sci 980 self._sci.setMarginType(_AnnotateMargin, qsci.TextMarginRightJustified) 981 self._sci.setMarginSensitivity(_AnnotateMargin, True) 982 self._sci.marginClicked.connect(self._onMarginClicked) 983 984 self._fd = fd 985 self._links = [] # by line 986 self._revmarkers = {} # by rev 987 self._lastrev = -1 988 989 self._lastmarginclick = QTime.currentTime() 990 self._lastmarginclick.addMSecs(-QApplication.doubleClickInterval()) 991 992 self._initAnnotateOptionActions() 993 self._loadAnnotateSettings() 994 995 self._isdarktheme = qtlib.isDarkTheme(self._sci.palette()) 996 997 def open(self): 998 self._sci.viewport().installEventFilter(self) 999 1000 def close(self): 1001 self._sci.viewport().removeEventFilter(self) 1002 self._sci.setMarginWidth(_AnnotateMargin, 0) 1003 self._sci.markerDeleteAll() 1004 self._cmdsession.abort() 1005 1006 def eventFilter(self, watched, event): 1007 # Python wrapper is deleted immediately before QEvent.Destroy 1008 try: 1009 sciviewport = self._sci.viewport() 1010 except RuntimeError: 1011 sciviewport = None 1012 if watched is sciviewport: 1013 if event.type() == QEvent.MouseMove: 1014 line = self._sci.lineNearPoint(event.pos()) 1015 self._emitRevisionHintAtLine(line) 1016 return False 1017 return super(_AnnotateViewControl, self).eventFilter(watched, event) 1018 1019 def _loadAnnotateSettings(self): 1020 s = QSettings() 1021 wb = "Annotate/" 1022 for a in self._annoptactions: 1023 a.setChecked(qtlib.readBool(s, wb + a.data())) 1024 if not any(a.isChecked() for a in self._annoptactions): 1025 self._annoptactions[-1].setChecked(True) # 'rev' by default 1026 1027 def _saveAnnotateSettings(self): 1028 s = QSettings() 1029 wb = "Annotate/" 1030 for a in self._annoptactions: 1031 s.setValue(wb + a.data(), a.isChecked()) 1032 1033 def _initAnnotateOptionActions(self): 1034 self._annoptactions = [] 1035 for name, field in [(_('Show &Author'), 'author'), 1036 (_('Show &Date'), 'date'), 1037 (_('Show &Revision'), 'rev')]: 1038 a = QAction(name, self, checkable=True) 1039 a.setData(field) 1040 a.triggered.connect(self._updateAnnotateOption) 1041 self._annoptactions.append(a) 1042 1043 @pyqtSlot() 1044 def _updateAnnotateOption(self): 1045 # make sure at least one option is checked 1046 if not any(a.isChecked() for a in self._annoptactions): 1047 self.sender().setChecked(True) 1048 1049 self._updateView() 1050 self._saveAnnotateSettings() 1051 1052 def _buildRevMarginTexts(self): 1053 def getauthor(fctx): 1054 return hglib.tounicode(hglib.username(fctx.user())) 1055 def getdate(fctx): 1056 return hglib.tounicode(dateutil.shortdate(fctx.date())) 1057 if self._fd.rev() is None: 1058 p1rev = self._fd.parentRevs()[0] 1059 revfmt = '%%%dd%%c' % len(str(p1rev)) 1060 def getrev(fctx): 1061 if fctx.rev() is None: 1062 return revfmt % (p1rev, '+') 1063 else: 1064 return revfmt % (fctx.rev(), ' ') 1065 else: 1066 revfmt = '%%%dd' % len(str(self._fd.rev())) 1067 def getrev(fctx): 1068 return revfmt % fctx.rev() 1069 1070 aformat = [str(a.data()) for a in self._annoptactions 1071 if a.isChecked()] 1072 annfields = { 1073 'rev': getrev, 1074 'author': getauthor, 1075 'date': getdate, 1076 } 1077 annfunc = [annfields[n] for n in aformat] 1078 1079 uniqfctxs = set(fctx for fctx, _origline in self._links) 1080 return dict((fctx.rev(), ' : '.join(f(fctx) for f in annfunc)) 1081 for fctx in uniqfctxs) 1082 1083 def _emitRevisionHintAtLine(self, line): 1084 if line < 0 or line >= len(self._links): 1085 return 1086 fctx = self._links[line][0] 1087 if fctx.rev() != self._lastrev: 1088 filename = hglib.fromunicode(self._fd.canonicalFilePath()) 1089 s = hglib.get_revision_desc(fctx, filename) 1090 self.showMessage.emit(s) 1091 self._lastrev = fctx.rev() 1092 1093 def _repoAgentForFile(self): 1094 rpath = self._fd.repoRootPath() 1095 if not rpath: 1096 return self._repoagent 1097 return self._repoagent.subRepoAgent(rpath) 1098 1099 def display(self, fd): 1100 if self._fd == fd and self._links: 1101 self._updateView() 1102 return 1103 self._fd = fd 1104 del self._links[:] 1105 self._cmdsession.abort() 1106 repoagent = self._repoAgentForFile() 1107 cmdline = hglib.buildcmdargs('annotate', fd.canonicalFilePath(), 1108 rev=hglib.escaperev(fd.rev(), 'wdir()'), 1109 text=True, file=True, 1110 number=True, line_number=True, T='pickle') 1111 self._cmdsession = sess = repoagent.runCommand(cmdline, self) 1112 sess.setCaptureOutput(True) 1113 sess.commandFinished.connect(self._onAnnotateFinished) 1114 1115 @pyqtSlot(int) 1116 def _onAnnotateFinished(self, ret): 1117 sess = self._cmdsession 1118 if not sess.isFinished(): 1119 # new request is already running 1120 return 1121 if ret != 0: 1122 return 1123 repo = self._repoAgentForFile().rawRepo() 1124 data = util.pickle.loads(bytes(sess.readAll())) 1125 links = [] 1126 fctxcache = {} # (path, rev): fctx 1127 for l in data[0][b'lines']: 1128 path, rev, lineno = l[b'path'], l[b'rev'], l[b'lineno'] 1129 try: 1130 fctx = fctxcache[path, rev] 1131 except KeyError: 1132 fctx = fctxcache[path, rev] = repo[rev][path] 1133 links.append((fctx, lineno)) 1134 self._links = links 1135 self._updateView() 1136 1137 def _updateView(self): 1138 if not self._links: 1139 return 1140 revtexts = self._buildRevMarginTexts() 1141 self._updaterevmargin(revtexts) 1142 self._updatemarkers() 1143 self._updatemarginwidth(revtexts) 1144 1145 def _updaterevmargin(self, revtexts): 1146 """Update the content of margin area showing revisions""" 1147 s = self._margin_style 1148 # Workaround to set style of the current sci widget. 1149 # QsciStyle sends style data only to the first sci widget. 1150 # See qscintilla2/Qt4/qscistyle.cpp 1151 self._sci.SendScintilla(qsci.SCI_STYLESETBACK, 1152 s.style(), s.paper()) 1153 self._sci.SendScintilla(qsci.SCI_STYLESETFONT, 1154 s.style(), 1155 pycompat.unicode(s.font().family()).encode('utf-8')) 1156 self._sci.SendScintilla(qsci.SCI_STYLESETSIZE, 1157 s.style(), s.font().pointSize()) 1158 for i, (fctx, _origline) in enumerate(self._links): 1159 self._sci.setMarginText(i, revtexts[fctx.rev()], s) 1160 1161 def _updatemarkers(self): 1162 """Update markers which colorizes each line""" 1163 self._redefinemarkers() 1164 for i, (fctx, _origline) in enumerate(self._links): 1165 m = self._revmarkers.get(fctx.rev()) 1166 if m is not None: 1167 self._sci.markerAdd(i, m) 1168 1169 def _redefinemarkers(self): 1170 """Redefine line markers according to the current revs""" 1171 curdate = self._fd.rawContext().date()[0] 1172 1173 # make sure to colorize at least 1 year 1174 mindate = curdate - 365 * 24 * 60 * 60 1175 1176 self._revmarkers.clear() 1177 filectxs = iter(fctx for fctx, _origline in self._links) 1178 maxcolors = 32 - _FirstAnnotateLineMarker 1179 palette = colormap.makeannotatepalette(filectxs, curdate, 1180 maxcolors=maxcolors, maxhues=8, 1181 maxsaturations=16, 1182 mindate=mindate, 1183 isdarktheme=self._isdarktheme) 1184 for i, (color, fctxs) in enumerate(palette.items()): 1185 m = _FirstAnnotateLineMarker + i 1186 self._sci.markerDefine(qsci.Background, m) 1187 self._sci.setMarkerBackgroundColor(QColor(color), m) 1188 for fctx in fctxs: 1189 self._revmarkers[fctx.rev()] = m 1190 1191 @util.propertycache 1192 def _margin_style(self): 1193 """Style for margin area""" 1194 s = Qsci.QsciStyle(qscilib.STYLE_FILEVIEW_MARGIN) 1195 s.setPaper(QApplication.palette().color(QPalette.Window)) 1196 s.setFont(self._sci.font()) 1197 return s 1198 1199 def _updatemarginwidth(self, revtexts): 1200 self._sci.setMarginsFont(self._sci.font()) 1201 # add 2 for margin 1202 maxwidth = 2 + max(len(s) for s in revtexts.values()) 1203 self._sci.setMarginWidth(_AnnotateMargin, 'M' * maxwidth) 1204 1205 def setupContextMenu(self, menu, line): 1206 menu.addSeparator() 1207 annoptsmenu = menu.addMenu(_('Annotate Op&tions')) 1208 annoptsmenu.addActions(self._annoptactions) 1209 1210 if line < 0 or line >= len(self._links): 1211 return 1212 1213 menu.addSeparator() 1214 1215 fctx, line = self._links[line] 1216 selection = self._sci.selectedText() 1217 if selection: 1218 def sreq(**opts): 1219 return lambda: self.grepRequested.emit(selection, opts) 1220 menu.addSeparator() 1221 annsearchmenu = menu.addMenu(_('Search Selected Text')) 1222 a = annsearchmenu.addAction(_('In Current &File')) 1223 a.triggered.connect(self.searchSelectedTextRequested) 1224 annsearchmenu.addAction(_('In &Current Revision'), sreq(rev='.')) 1225 annsearchmenu.addAction(_('In &Original Revision'), 1226 sreq(rev=fctx.rev())) 1227 annsearchmenu.addAction(_('In All &History'), sreq(all=True)) 1228 1229 data = [hglib.tounicode(fctx.path()), fctx.rev(), line] 1230 1231 def annorig(): 1232 self.setSourceRequested.emit(*data) 1233 def editorig(): 1234 self.editSelectedRequested.emit(*data) 1235 def difflocal(): 1236 self.visualDiffToLocalRequested.emit(data[0], data[1]) 1237 def diffparent(): 1238 self.visualDiffRevisionRequested.emit(data[0], data[1]) 1239 1240 menu.addSeparator() 1241 anngotomenu = menu.addMenu(_('Go to')) 1242 annviewmenu = menu.addMenu(_('View File at')) 1243 anndiffmenu = menu.addMenu(_('Diff File to')) 1244 anngotomenu.addAction(_('&Originating Revision'), annorig) 1245 annviewmenu.addAction(_('&Originating Revision'), editorig) 1246 anndiffmenu.addAction(_('&Local'), difflocal) 1247 anndiffmenu.addAction(_('&Parent Revision'), diffparent) 1248 for pfctx in fctx.parents(): 1249 pdata = [hglib.tounicode(pfctx.path()), pfctx.changectx().rev(), 1250 line] 1251 def annparent(data): 1252 self.setSourceRequested.emit(*data) 1253 def editparent(data): 1254 self.editSelectedRequested.emit(*data) 1255 for name, func, smenu in [(_('&Parent Revision (%d)') % pdata[1], 1256 annparent, anngotomenu), 1257 (_('&Parent Revision (%d)') % pdata[1], 1258 editparent, annviewmenu)]: 1259 def add(name, func): 1260 action = smenu.addAction(name) 1261 action.data = pdata 1262 action.run = lambda: func(action.data) 1263 action.triggered.connect(action.run) 1264 add(name, func) 1265 1266 #@pyqtSlot(int, int, Qt.KeyboardModifiers) 1267 def _onMarginClicked(self, margin, line, state): 1268 if margin != _AnnotateMargin: 1269 return 1270 1271 lastclick = self._lastmarginclick 1272 if (state == Qt.ControlModifier 1273 or lastclick.elapsed() < QApplication.doubleClickInterval()): 1274 if line >= len(self._links): 1275 # empty line next to the last line 1276 return 1277 fctx, line = self._links[line] 1278 self.setSourceRequested.emit( 1279 hglib.tounicode(fctx.path()), fctx.rev(), line) 1280 else: 1281 lastclick.restart() 1282 1283 # mimic the default "border selection" behavior, 1284 # which is disabled when you use setMarginSensitivity() 1285 if state == Qt.ShiftModifier: 1286 r = self._sci.getSelection() 1287 sellinetop, selchartop, sellinebottom, selcharbottom = r 1288 if sellinetop <= line: 1289 sline = sellinetop 1290 eline = line + 1 1291 else: 1292 sline = line 1293 eline = sellinebottom 1294 if selcharbottom != 0: 1295 eline += 1 1296 else: 1297 sline = line 1298 eline = line + 1 1299 self._sci.setSelection(sline, 0, eline, 0) 1300 1301 1302class _ChunkSelectionViewControl(_AbstractViewControl): 1303 """Display chunk selection margin and colorize chunks in HgFileView""" 1304 1305 chunkSelectionChanged = pyqtSignal() 1306 1307 def __init__(self, sci, fd, parent=None): 1308 super(_ChunkSelectionViewControl, self).__init__(parent) 1309 self._sci = sci 1310 p = qtlib.getcheckboxpixmap(QStyle.State_On, QColor('#B0FFA0'), sci) 1311 self._sci.markerDefine(p, _IncludedChunkStartMarker) 1312 p = qtlib.getcheckboxpixmap(QStyle.State_Off, QColor('#B0FFA0'), sci) 1313 self._sci.markerDefine(p, _ExcludedChunkStartMarker) 1314 1315 self._sci.markerDefine(qsci.Background, _ExcludedLineMarker) 1316 if qtlib.isDarkTheme(self._sci.palette()): 1317 bg, fg = QColor(44, 44, 44), QColor(86, 86, 86) 1318 else: 1319 bg, fg = QColor('lightgrey'), QColor('darkgrey') 1320 self._sci.setMarkerBackgroundColor(bg, _ExcludedLineMarker) 1321 self._sci.setMarkerForegroundColor(fg, _ExcludedLineMarker) 1322 self._sci.setMarginType(_ChunkSelectionMargin, qsci.SymbolMargin) 1323 self._sci.setMarginMarkerMask(_ChunkSelectionMargin, 1324 _ChunkSelectionMarkerMask) 1325 self._sci.setMarginSensitivity(_ChunkSelectionMargin, True) 1326 self._sci.marginClicked.connect(self._onMarginClicked) 1327 1328 self._actmarkexcluded = a = QAction(_('&Mark Excluded Changes'), self) 1329 a.setCheckable(True) 1330 a.setChecked(qtlib.readBool(QSettings(), 'changes-mark-excluded')) 1331 a.triggered.connect(self._updateChunkIndicatorMarks) 1332 self._excludeindicator = -1 1333 self._updateChunkIndicatorMarks(a.isChecked()) 1334 self._sci.setIndicatorDrawUnder(True, self._excludeindicator) 1335 self._sci.setIndicatorForegroundColor(QColor('gray'), 1336 self._excludeindicator) 1337 1338 self._toggleshortcut = a = QShortcut(Qt.Key_Space, sci) 1339 a.setContext(Qt.WidgetShortcut) 1340 a.setEnabled(False) 1341 a.activated.connect(self._toggleCurrentChunk) 1342 1343 self._fd = fd 1344 self._chunkatline = {} 1345 1346 def open(self): 1347 self._sci.setMarginWidth(_ChunkSelectionMargin, 15) 1348 self._toggleshortcut.setEnabled(True) 1349 1350 def close(self): 1351 self._sci.setMarginWidth(_ChunkSelectionMargin, 0) 1352 self._toggleshortcut.setEnabled(False) 1353 1354 def display(self, fd): 1355 self._fd = fd 1356 self._chunkatline.clear() 1357 if not fd.changes: 1358 return 1359 for chunk in fd.changes.hunks: 1360 self._chunkatline[chunk.lineno] = chunk 1361 self._updateMarker(chunk) 1362 1363 def _updateMarker(self, chunk): 1364 excludemsg = ' ' + _('(excluded from the next commit)') 1365 # markerAdd() does not check if the specified marker is already 1366 # present, but markerDelete() does 1367 m = self._sci.markersAtLine(chunk.lineno) 1368 inclmarked = m & (1 << _IncludedChunkStartMarker) 1369 exclmarked = m & (1 << _ExcludedChunkStartMarker) 1370 1371 if chunk.excluded and not exclmarked: 1372 self._sci.setReadOnly(False) 1373 llen = self._sci.lineLength(chunk.lineno) # in bytes 1374 self._sci.insertAt(excludemsg, chunk.lineno, llen - 1) 1375 self._sci.setReadOnly(True) 1376 1377 self._sci.markerDelete(chunk.lineno, _IncludedChunkStartMarker) 1378 self._sci.markerAdd(chunk.lineno, _ExcludedChunkStartMarker) 1379 for i in pycompat.xrange(chunk.linecount - 1): 1380 self._sci.markerAdd(chunk.lineno + i + 1, _ExcludedLineMarker) 1381 self._sci.fillIndicatorRange(chunk.lineno + 1, 0, 1382 chunk.lineno + chunk.linecount, 0, 1383 self._excludeindicator) 1384 1385 if not chunk.excluded and exclmarked: 1386 self._sci.setReadOnly(False) 1387 llen = self._sci.lineLength(chunk.lineno) # in bytes 1388 mlen = len(excludemsg.encode('utf-8')) # in bytes 1389 pos = self._sci.positionFromLineIndex(chunk.lineno, llen - mlen - 1) 1390 self._sci.SendScintilla(qsci.SCI_SETTARGETSTART, pos) 1391 self._sci.SendScintilla(qsci.SCI_SETTARGETEND, pos + mlen) 1392 self._sci.SendScintilla(qsci.SCI_REPLACETARGET, 0, b'') 1393 self._sci.setReadOnly(True) 1394 1395 if not chunk.excluded and not inclmarked: 1396 self._sci.markerDelete(chunk.lineno, _ExcludedChunkStartMarker) 1397 self._sci.markerAdd(chunk.lineno, _IncludedChunkStartMarker) 1398 for i in pycompat.xrange(chunk.linecount - 1): 1399 self._sci.markerDelete(chunk.lineno + i + 1, 1400 _ExcludedLineMarker) 1401 self._sci.clearIndicatorRange(chunk.lineno + 1, 0, 1402 chunk.lineno + chunk.linecount, 0, 1403 self._excludeindicator) 1404 1405 #@pyqtSlot(int, int, Qt.KeyboardModifier) 1406 def _onMarginClicked(self, margin, line, state): 1407 if margin != _ChunkSelectionMargin: 1408 return 1409 if line not in self._chunkatline: 1410 return 1411 if state & Qt.ShiftModifier: 1412 excluded = self._getChunkAtLine(line) 1413 cl = self._currentChunkLine() 1414 end = max(line, cl) 1415 l = min(line, cl) 1416 lines = [] 1417 while l < end: 1418 assert l >= 0 1419 lines.append(l) 1420 l = self._sci.markerFindNext(l + 1, _ChunkSelectionMarkerMask) 1421 lines.append(end) 1422 self._setChunkAtLines(lines, not excluded) 1423 else: 1424 self._toggleChunkAtLine(line) 1425 1426 self._sci.setCursorPosition(line, 0) 1427 1428 def _getChunkAtLine(self, line): 1429 return self._chunkatline[line].excluded 1430 1431 def _setChunkAtLines(self, lines, excluded): 1432 for l in lines: 1433 chunk = self._chunkatline[l] 1434 self._fd.setChunkExcluded(chunk, excluded) 1435 self._updateMarker(chunk) 1436 self.chunkSelectionChanged.emit() 1437 1438 def _toggleChunkAtLine(self, line): 1439 excluded = self._getChunkAtLine(line) 1440 self._setChunkAtLines([line], not excluded) 1441 1442 @pyqtSlot() 1443 def _toggleCurrentChunk(self): 1444 line = self._currentChunkLine() 1445 if line >= 0: 1446 self._toggleChunkAtLine(line) 1447 1448 def _currentChunkLine(self): 1449 line = self._sci.getCursorPosition()[0] 1450 return self._sci.markerFindPrevious(line, _ChunkSelectionMarkerMask) 1451 1452 def setupContextMenu(self, menu, line): 1453 menu.addAction(self._actmarkexcluded) 1454 1455 @pyqtSlot(bool) 1456 def _updateChunkIndicatorMarks(self, checked): 1457 ''' 1458 This method has some pre-requisites: 1459 - self.excludeindicator MUST be set to -1 before calling this 1460 method for the first time 1461 ''' 1462 indicatortypes = (qsci.HiddenIndicator, qsci.StrikeIndicator) 1463 self._excludeindicator = self._sci.indicatorDefine( 1464 indicatortypes[checked], 1465 self._excludeindicator) 1466 QSettings().setValue('changes-mark-excluded', checked) 1467