/tortoisehg/hgqt/annotate.py

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