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