PageRenderTime 38ms CodeModel.GetById 9ms app.highlight 24ms RepoModel.GetById 1ms app.codeStats 0ms

/tortoisehg/hgqt/annotate.py

https://bitbucket.org/tortoisehg/hgtk/
Python | 419 lines | 338 code | 50 blank | 31 comment | 47 complexity | ec6c48ff6c9528cc4c41f4365333033a MD5 | raw file
  1# annotate.py - File annotation 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
  8import os
  9
 10from mercurial import ui, error, util
 11
 12from tortoisehg.hgqt import visdiff, qtlib, qscilib, wctxactions, thgrepo, lexers
 13from tortoisehg.util import paths, hglib, colormap, thread2
 14from tortoisehg.hgqt.i18n import _
 15from tortoisehg.hgqt.grep import SearchWidget
 16
 17from PyQt4.QtCore import *
 18from PyQt4.QtGui import *
 19from PyQt4.Qsci import QsciScintilla, QsciStyle
 20
 21# Technical Debt
 22#  Pass search parameters to grep
 23#  forward/backward history buttons
 24#  menu options for viewing appropriate changesets
 25
 26class AnnotateView(qscilib.Scintilla):
 27    revisionHint = pyqtSignal(QString)
 28
 29    searchRequested = pyqtSignal(QString)
 30    """Emitted (pattern) when user request to search content"""
 31
 32    editSelected = pyqtSignal(unicode, object, int)
 33    """Emitted (path, rev, line) when user requests to open editor"""
 34
 35    grepRequested = pyqtSignal(QString, dict)
 36    """Emitted (pattern, **opts) when user request to search changelog"""
 37
 38    sourceChanged = pyqtSignal(unicode, object)
 39    """Emitted (path, rev) when the content source changed"""
 40
 41    def __init__(self, repo, parent=None, **opts):
 42        super(AnnotateView, self).__init__(parent)
 43        self.setReadOnly(True)
 44        self.setMarginLineNumbers(1, True)
 45        self.setMarginType(2, QsciScintilla.TextMarginRightJustified)
 46        self.setMouseTracking(True)
 47        self.setFont(qtlib.getfont('fontdiff').font())
 48        self.setContextMenuPolicy(Qt.CustomContextMenu)
 49        self.customContextMenuRequested.connect(self.menuRequest)
 50
 51        self.repo = repo
 52        self.repo.configChanged.connect(self.configChanged)
 53        self.configChanged()
 54        self._rev = None
 55        self.annfile = None
 56        self._annotation_enabled = bool(opts.get('annotationEnabled', False))
 57
 58        self._links = []  # by line
 59        self._revmarkers = {}  # by rev
 60        self._lastrev = None
 61
 62        self._thread = _AnnotateThread(self)
 63        self._thread.finished.connect(self.fillModel)
 64
 65    def configChanged(self):
 66        self.setIndentationWidth(self.repo.tabwidth)
 67        self.setTabWidth(self.repo.tabwidth)
 68
 69    def keyPressEvent(self, event):
 70        if event.key() == Qt.Key_Escape:
 71            self._thread.abort()
 72            return
 73        return super(AnnotateView, self).keyPressEvent(event)
 74
 75    def mouseMoveEvent(self, event):
 76        self._emitRevisionHintAtLine(self.lineAt(event.pos()))
 77        super(AnnotateView, self).mouseMoveEvent(event)
 78
 79    def _emitRevisionHintAtLine(self, line):
 80        if line < 0:
 81            return
 82        try:
 83            fctx = self._links[line][0]
 84            if fctx.rev() != self._lastrev:
 85                s = hglib.get_revision_desc(fctx,
 86                                            hglib.fromunicode(self.annfile))
 87                self.revisionHint.emit(s)
 88                self._lastrev = fctx.rev()
 89        except IndexError:
 90            pass
 91
 92    @pyqtSlot(QPoint)
 93    def menuRequest(self, point):
 94        menu = self.createStandardContextMenu()
 95        line = self.lineAt(point)
 96        point = self.mapToGlobal(point)
 97        if line < 0 or not self.isAnnotationEnabled():
 98            return menu.exec_(point)
 99
100        fctx, line = self._links[line]
101        data = [hglib.tounicode(fctx.path()), fctx.rev(), line]
102
103        if self.hasSelectedText():
104            selection = self.selectedText()
105            def sreq(**opts):
106                return lambda: self.grepRequested.emit(selection, opts)
107            def sann():
108                self.searchRequested.emit(selection)
109            menu.addSeparator()
110            for name, func in [(_('Search in original revision'),
111                                sreq(rev=fctx.rev())),
112                               (_('Search in working revision'),
113                                sreq(rev='.')),
114                               (_('Search in current annotation'), sann),
115                               (_('Search in history'), sreq(all=True))]:
116                def add(name, func):
117                    action = menu.addAction(name)
118                    action.triggered.connect(func)
119                add(name, func)
120
121        def annorig():
122            self.setSource(*data)
123        def editorig():
124            self.editSelected.emit(*data)
125        menu.addSeparator()
126        for name, func in [(_('Annotate originating revision'), annorig),
127                           (_('View originating revision'), editorig)]:
128            def add(name, func):
129                action = menu.addAction(name)
130                action.triggered.connect(func)
131            add(name, func)
132        for pfctx in fctx.parents():
133            pdata = [hglib.tounicode(pfctx.path()), pfctx.changectx().rev(),
134                     line]
135            def annparent(data):
136                self.setSource(*data)
137            def editparent(data):
138                self.editSelected.emit(*data)
139            for name, func in [(_('Annotate parent revision %d') % pdata[1],
140                                  annparent),
141                               (_('View parent revision %d') % pdata[1],
142                                  editparent)]:
143                def add(name, func):
144                    action = menu.addAction(name)
145                    action.data = pdata
146                    action.run = lambda: func(action.data)
147                    action.triggered.connect(action.run)
148                add(name, func)
149        menu.exec_(point)
150
151    @property
152    def rev(self):
153        """Returns the current revision number"""
154        return self._rev
155
156    @pyqtSlot(unicode, object, int)
157    def setSource(self, wfile, rev, line=None):
158        """Change the content to the specified file at rev [unicode]
159
160        line is counted from 1.
161        """
162        if self.annfile == wfile and self.rev == rev:
163            if line:
164                self.setCursorPosition(int(line) - 1, 0)
165            return
166
167        try:
168            ctx = self.repo[rev]
169            fctx = ctx[hglib.fromunicode(wfile)]
170        except error.LookupError:
171            qtlib.ErrorMsgBox(_('Unable to annotate'),
172                    _('%s is not found in revision %d') % (wfile, ctx.rev()))
173            return
174        self._rev = ctx.rev()
175        self.clear()
176        self.annfile = wfile
177        self.setText(hglib.tounicode(fctx.data()))
178        if line:
179            self.setCursorPosition(int(line) - 1, 0)
180        self._updatelexer(fctx)
181        self._updatemarginwidth()
182        self.sourceChanged.emit(wfile, self._rev)
183        self._updateannotation()
184
185    def _updateannotation(self):
186        if not self.isAnnotationEnabled() or not self.annfile:
187            return
188        ctx = self.repo[self._rev]
189        fctx = ctx[hglib.fromunicode(self.annfile)]
190        self._thread.abort()
191        self._thread.start(fctx)
192
193    @pyqtSlot()
194    def fillModel(self):
195        self._thread.wait()
196        if self._thread.data is None:
197            return
198
199        self._links = list(self._thread.data)
200
201        self._updaterevmargin()
202        self._updatemarkers()
203        self._updatemarginwidth()
204
205    def clear(self):
206        super(AnnotateView, self).clear()
207        self.clearMarginText()
208        self.markerDeleteAll()
209        self.annfile = None
210
211    @pyqtSlot(bool)
212    def setAnnotationEnabled(self, enabled):
213        """Enable / disable annotation"""
214        enabled = bool(enabled)
215        if enabled == self.isAnnotationEnabled():
216            return
217        self._annotation_enabled = enabled
218        self._updateannotation()
219        self._updatemarginwidth()
220        self.setMouseTracking(enabled)
221        if not self.isAnnotationEnabled():
222            self.annfile = None
223            self.markerDeleteAll()
224
225    def isAnnotationEnabled(self):
226        """True if annotation enabled and available"""
227        if self.rev is None:
228            return False  # annotate working copy is not supported
229        return self._annotation_enabled
230
231    def _updatelexer(self, fctx):
232        """Update the lexer according to the given file"""
233        lex = lexers.get_lexer(fctx.path(), hglib.tounicode(fctx.data()), self)
234        self.setLexer(lex)
235
236    def _updaterevmargin(self):
237        """Update the content of margin area showing revisions"""
238        style = self._margin_style()
239        for i, (fctx, _origline) in enumerate(self._links):
240            self.setMarginText(i, str(fctx.rev()), style)
241
242    def _updatemarkers(self):
243        """Update markers which colorizes each line"""
244        self._redefinemarkers()
245        for i, (fctx, _origline) in enumerate(self._links):
246            m = self._revmarkers.get(fctx.rev())
247            if m is not None:
248                self.markerAdd(i, m)
249
250    def _redefinemarkers(self):
251        """Redefine line markers according to the current revs"""
252        curdate = self.repo[self._rev].date()[0]
253
254        # make sure to colorize at least 1 year
255        mindate = curdate - 365 * 24 * 60 * 60
256
257        self._revmarkers.clear()
258        filectxs = iter(fctx for fctx, _origline in self._links)
259        palette = colormap.makeannotatepalette(filectxs, curdate,
260                                               maxcolors=32, maxhues=8,
261                                               maxsaturations=16,
262                                               mindate=mindate)
263        for i, (color, fctxs) in enumerate(palette.iteritems()):
264            self.markerDefine(QsciScintilla.Background, i)
265            self.setMarkerBackgroundColor(QColor(color), i)
266            for fctx in fctxs:
267                self._revmarkers[fctx.rev()] = i
268
269    def _margin_style(self):
270        """Style for margin area"""
271        s = QsciStyle()
272        s.setPaper(QApplication.palette().color(QPalette.Window))
273        s.setFont(self.font())
274
275        # Workaround to set style of the current sci widget.
276        # QsciStyle sends style data only to the first sci widget.
277        # See qscintilla2/Qt4/qscistyle.cpp
278        self.SendScintilla(QsciScintilla.SCI_STYLESETBACK,
279                           s.style(), s.paper())
280        self.SendScintilla(QsciScintilla.SCI_STYLESETFONT,
281                           s.style(), s.font().family().toAscii().data())
282        self.SendScintilla(QsciScintilla.SCI_STYLESETSIZE,
283                           s.style(), s.font().pointSize())
284        return s
285
286    @pyqtSlot()
287    def _updatemarginwidth(self):
288        self.setMarginsFont(self.font())
289        def lentext(s):
290            return 'M' * (len(str(s)) + 2)  # 2 for margin
291        self.setMarginWidth(1, lentext(self.lines()))
292        if self.isAnnotationEnabled() and self._links:
293            maxrev = max(fctx.rev() for fctx, _origline in self._links)
294            self.setMarginWidth(2, lentext(maxrev))
295        else:
296            self.setMarginWidth(2, 0)
297
298class _AnnotateThread(QThread):
299    'Background thread for annotating a file at a revision'
300    def __init__(self, parent=None):
301        super(_AnnotateThread, self).__init__(parent)
302
303    @pyqtSlot(object)
304    def start(self, fctx):
305        self._fctx = fctx
306        super(_AnnotateThread, self).start()
307        self.data = None
308
309    @pyqtSlot()
310    def abort(self):
311        try:
312            thread2._async_raise(self._threadid, KeyboardInterrupt)
313            self.wait()
314        except AttributeError, ValueError:
315            pass
316
317    def run(self):
318        assert self.currentThread() != qApp.thread()
319        self._threadid = self.currentThreadId()
320        try:
321            data = []
322            for (fctx, line), _text in self._fctx.annotate(True, True):
323                data.append((fctx, line))
324            self.data = data
325        except KeyboardInterrupt:
326            pass
327        finally:
328            del self._threadid
329            del self._fctx
330
331class AnnotateDialog(QMainWindow):
332    def __init__(self, *pats, **opts):
333        super(AnnotateDialog,self).__init__(opts.get('parent'), Qt.Window)
334
335        root = opts.get('root') or paths.find_root()
336        repo = thgrepo.repository(ui.ui(), path=root)
337        # TODO: handle repo not found
338
339        av = AnnotateView(repo, self, annotationEnabled=True)
340        self.setCentralWidget(av)
341        self.av = av
342
343        status = QStatusBar()
344        self.setStatusBar(status)
345        av.revisionHint.connect(status.showMessage)
346        av.editSelected.connect(self.editSelected)
347        av.grepRequested.connect(self._openSearchWidget)
348
349        self._searchbar = qscilib.SearchToolBar()
350        self.addToolBar(self._searchbar)
351        self._searchbar.setPattern(hglib.tounicode(opts.get('pattern', '')))
352        self._searchbar.searchRequested.connect(self.av.find)
353        self._searchbar.conditionChanged.connect(self.av.highlightText)
354        av.searchRequested.connect(self._searchbar.search)
355        QShortcut(QKeySequence.Find, self,
356            lambda: self._searchbar.setFocus(Qt.OtherFocusReason))
357
358        self.av.sourceChanged.connect(
359            lambda *args: self.setWindowTitle(_('Annotate %s@%d') % args))
360
361        self.searchwidget = opts.get('searchwidget')
362
363        self.opts = opts
364        line = opts.get('line')
365        if line and isinstance(line, str):
366            line = int(line)
367
368        self.repo = repo
369
370        self.restoreSettings()
371
372        # run heavy operation after the dialog visible
373        path = hglib.tounicode(pats[0])
374        rev = opts.get('rev') or '.'
375        QTimer.singleShot(0, lambda: av.setSource(path, rev, line))
376
377    def closeEvent(self, event):
378        self.storeSettings()
379        super(AnnotateDialog, self).closeEvent(event)
380
381    def editSelected(self, wfile, rev, line):
382        pattern = hglib.fromunicode(self._searchbar._le.text()) or None
383        wfile = hglib.fromunicode(wfile)
384        repo = self.repo
385        try:
386            ctx = repo[rev]
387            fctx = ctx[wfile]
388        except Exception, e:
389            self.statusBar().showMessage(hglib.tounicode(str(e)))
390
391        base, _ = visdiff.snapshot(repo, [wfile], repo[rev])
392        files = [os.path.join(base, wfile)]
393        wctxactions.edit(self, repo.ui, repo, files, line, pattern)
394
395    @pyqtSlot(unicode, dict)
396    def _openSearchWidget(self, pattern, opts):
397        opts = dict((str(k), str(v)) for k, v in opts.iteritems())
398        if self.searchwidget is None:
399            self.searchwidget = SearchWidget([pattern], repo=self.repo,
400                                             **opts)
401            self.searchwidget.show()
402        else:
403            self.searchwidget.setSearch(pattern, **opts)
404            self.searchwidget.show()
405            self.searchwidget.raise_()
406
407    def storeSettings(self):
408        s = QSettings()
409        s.setValue('annotate/geom', self.saveGeometry())
410        self.av.saveSettings(s, 'annotate/av')
411
412    def restoreSettings(self):
413        s = QSettings()
414        self.restoreGeometry(s.value('annotate/geom').toByteArray())
415        self.av.loadSettings(s, 'annotate/av')
416
417def run(ui, *pats, **opts):
418    pats = hglib.canonpaths(pats)
419    return AnnotateDialog(*pats, **opts)