/tortoisehg/hgqt/annotate.py
Python | 419 lines | 400 code | 8 blank | 11 comment | 0 complexity | ec6c48ff6c9528cc4c41f4365333033a MD5 | raw file
Possible License(s): GPL-2.0
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)