/tortoisehg/hgqt/repomodel.py

https://bitbucket.org/tortoisehg/hgtk/ · Python · 553 lines · 498 code · 33 blank · 22 comment · 54 complexity · 3014438ee78748d4c2156cb514457aa5 MD5 · raw file

  1. # Copyright (c) 2009-2010 LOGILAB S.A. (Paris, FRANCE).
  2. # http://www.logilab.fr/ -- mailto:contact@logilab.fr
  3. #
  4. # This program is free software; you can redistribute it and/or modify it under
  5. # the terms of the GNU General Public License as published by the Free Software
  6. # Foundation; either version 2 of the License, or (at your option) any later
  7. # version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  12. #
  13. # You should have received a copy of the GNU General Public License along with
  14. # this program; if not, write to the Free Software Foundation, Inc.,
  15. # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
  16. from mercurial import util, error
  17. from mercurial.util import propertycache
  18. from tortoisehg.util import hglib
  19. from tortoisehg.hgqt.graph import Graph
  20. from tortoisehg.hgqt.graph import revision_grapher
  21. from tortoisehg.hgqt import qtlib
  22. from tortoisehg.hgqt.i18n import _
  23. from PyQt4.QtCore import *
  24. from PyQt4.QtGui import *
  25. nullvariant = QVariant()
  26. # TODO: Remove these two when we adopt GTK author color scheme
  27. COLORS = [ "blue", "darkgreen", "red", "green", "darkblue", "purple",
  28. "cyan", Qt.darkYellow, "magenta", "darkred", "darkmagenta",
  29. "darkcyan", "gray", "yellow", ]
  30. COLORS = [str(QColor(x).name()) for x in COLORS]
  31. ALLCOLUMNS = ('Graph', 'Rev', 'Branch', 'Description', 'Author', 'Tags', 'Node',
  32. 'Age', 'LocalTime', 'UTCTime', 'Changes')
  33. UNAPPLIED_PATCH_COLOR = '#999999'
  34. def get_color(n, ignore=()):
  35. """
  36. Return a color at index 'n' rotating in the available
  37. colors. 'ignore' is a list of colors not to be chosen.
  38. """
  39. ignore = [str(QColor(x).name()) for x in ignore]
  40. colors = [x for x in COLORS if x not in ignore]
  41. if not colors: # ghh, no more available colors...
  42. colors = COLORS
  43. return colors[n % len(colors)]
  44. class HgRepoListModel(QAbstractTableModel):
  45. """
  46. Model used for displaying the revisions of a Hg *local* repository
  47. """
  48. showMessage = pyqtSignal(unicode)
  49. filled = pyqtSignal()
  50. loaded = pyqtSignal()
  51. _columns = ('Graph', 'Rev', 'Branch', 'Description', 'Author', 'Age', 'Tags',)
  52. _stretchs = {'Description': 1, }
  53. _mqtags = ('qbase', 'qtip', 'qparent')
  54. def __init__(self, repo, branch, revset, rfilter, parent):
  55. """
  56. repo is a hg repo instance
  57. """
  58. QAbstractTableModel.__init__(self, parent)
  59. self._cache = []
  60. self.graph = None
  61. self.timerHandle = None
  62. self.dotradius = 8
  63. self.rowheight = 20
  64. self.rowcount = 0
  65. self.repo = repo
  66. self.revset = revset
  67. self.filterbyrevset = rfilter
  68. # To be deleted
  69. self._user_colors = {}
  70. self._branch_colors = {}
  71. self._columnmap = {
  72. 'Rev': lambda ctx, gnode: type(ctx.rev()) is int and \
  73. str(ctx.rev()) or "",
  74. 'Node': lambda ctx, gnode: str(ctx),
  75. 'Graph': lambda ctx, gnode: "",
  76. 'Description': self.getlog,
  77. 'Author': self.getauthor,
  78. 'Tags': self.gettags,
  79. 'Branch': self.getbranch,
  80. 'Filename': lambda ctx, gnode: gnode.extra[0],
  81. 'Age': lambda ctx, gnode: hglib.age(ctx.date()),
  82. 'LocalTime':lambda ctx, gnode: hglib.displaytime(ctx.date()),
  83. 'UTCTime': lambda ctx, gnode: hglib.utctime(ctx.date()),
  84. 'Changes': self.getchanges,
  85. }
  86. if repo:
  87. self.reloadConfig()
  88. self.updateColumns()
  89. self.setBranch(branch)
  90. def setBranch(self, branch=None, allparents=True):
  91. self.filterbranch = branch
  92. self.invalidateCache()
  93. if self.revset and self.filterbyrevset:
  94. grapher = revision_grapher(self.repo, revset=self.revset)
  95. self.graph = Graph(self.repo, grapher, include_mq=False)
  96. else:
  97. grapher = revision_grapher(self.repo, branch=branch,
  98. allparents=allparents)
  99. self.graph = Graph(self.repo, grapher, include_mq=True)
  100. self.rowcount = 0
  101. self.layoutChanged.emit()
  102. self.ensureBuilt(row=0)
  103. self.showMessage.emit('')
  104. QTimer.singleShot(0, lambda: self.filled.emit())
  105. def reloadConfig(self):
  106. _ui = self.repo.ui
  107. self.fill_step = int(_ui.config('tortoisehg', 'graphlimit', 500))
  108. self.authorcolor = _ui.configbool('tortoisehg', 'authorcolor')
  109. self.maxauthor = 'author name'
  110. def updateColumns(self):
  111. s = QSettings()
  112. cols = s.value('workbench/columns').toStringList()
  113. cols = [str(col) for col in cols]
  114. # Fixup older names for columns
  115. if 'Log' in cols:
  116. cols[cols.index('Log')] = 'Description'
  117. s.setValue('workbench/columns', cols)
  118. if 'ID' in cols:
  119. cols[cols.index('ID')] = 'Rev'
  120. s.setValue('workbench/columns', cols)
  121. validcols = [col for col in cols if col in ALLCOLUMNS]
  122. if validcols:
  123. self._columns = tuple(validcols)
  124. self.invalidateCache()
  125. self.layoutChanged.emit()
  126. def invalidate(self):
  127. self.reloadConfig()
  128. self.invalidateCache()
  129. self.layoutChanged.emit()
  130. def branch(self):
  131. return self.filterbranch
  132. def ensureBuilt(self, rev=None, row=None):
  133. """
  134. Make sure rev data is available (graph element created).
  135. """
  136. if self.graph.isfilled():
  137. return
  138. required = 0
  139. buildrev = rev
  140. n = len(self.graph)
  141. if rev is not None:
  142. if n and self.graph[-1].rev <= rev:
  143. buildrev = None
  144. else:
  145. required = self.fill_step/2
  146. elif row is not None and row > (n - self.fill_step / 2):
  147. required = row - n + self.fill_step
  148. if required or buildrev:
  149. self.graph.build_nodes(nnodes=required, rev=buildrev)
  150. self.updateRowCount()
  151. if self.rowcount >= len(self.graph):
  152. return # no need to update row count
  153. if row and row > self.rowcount:
  154. # asked row was already built, but views where not aware of this
  155. self.updateRowCount()
  156. elif rev is not None and rev <= self.graph[self.rowcount].rev:
  157. # asked rev was already built, but views where not aware of this
  158. self.updateRowCount()
  159. def loadall(self):
  160. self.timerHandle = self.startTimer(1)
  161. def timerEvent(self, event):
  162. if event.timerId() == self.timerHandle:
  163. self.showMessage.emit(_('filling (%d)')%(len(self.graph)))
  164. if self.graph.isfilled():
  165. self.killTimer(self.timerHandle)
  166. self.timerHandle = None
  167. self.showMessage.emit('')
  168. self.loaded.emit()
  169. # we only fill the graph data structures without telling
  170. # views until the model is loaded, to keep maximal GUI
  171. # reactivity
  172. elif not self.graph.build_nodes():
  173. self.killTimer(self.timerHandle)
  174. self.timerHandle = None
  175. self.updateRowCount()
  176. self.showMessage.emit('')
  177. self.loaded.emit()
  178. def updateRowCount(self):
  179. currentlen = self.rowcount
  180. newlen = len(self.graph)
  181. sauthors = [hglib.username(user) for user in list(self.graph.authors)]
  182. sauthors.append(self.maxauthor)
  183. self.maxauthor = sorted(sauthors, key=lambda x: len(x))[-1]
  184. if newlen > self.rowcount:
  185. self.beginInsertRows(QModelIndex(), currentlen, newlen-1)
  186. self.rowcount = newlen
  187. self.endInsertRows()
  188. def rowCount(self, parent):
  189. if parent.isValid():
  190. return 0
  191. return self.rowcount
  192. def columnCount(self, parent):
  193. if parent.isValid():
  194. return 0
  195. return len(self._columns)
  196. def maxWidthValueForColumn(self, col):
  197. if self.graph is None:
  198. return 'XXXX'
  199. column = self._columns[col]
  200. if column == 'Rev':
  201. return str(len(self.repo))
  202. if column == 'Node':
  203. return str(self.repo['.'])
  204. if column in ('Age', 'LocalTime', 'UTCTime'):
  205. return hglib.displaytime(util.makedate())
  206. if column == 'Tags':
  207. try:
  208. return sorted(self.repo.tags().keys(), key=lambda x: len(x))[-1][:10]
  209. except IndexError:
  210. pass
  211. if column == 'Branch':
  212. try:
  213. return sorted(self.repo.branchtags().keys(), key=lambda x: len(x))[-1]
  214. except IndexError:
  215. pass
  216. if column == 'Author':
  217. return self.maxauthor
  218. if column == 'Filename':
  219. return self.filename
  220. if column == 'Graph':
  221. res = self.col2x(self.graph.max_cols)
  222. return min(res, 150)
  223. if column == 'Changes':
  224. return 'Changes'
  225. # Fall through for Description
  226. return None
  227. def user_color(self, user):
  228. 'deprecated, please replace with hgtk color scheme'
  229. if user not in self._user_colors:
  230. self._user_colors[user] = get_color(len(self._user_colors),
  231. self._user_colors.values())
  232. return self._user_colors[user]
  233. def namedbranch_color(self, branch):
  234. 'deprecated, please replace with hgtk color scheme'
  235. if branch not in self._branch_colors:
  236. self._branch_colors[branch] = get_color(len(self._branch_colors))
  237. return self._branch_colors[branch]
  238. def col2x(self, col):
  239. return 2 * self.dotradius * col + self.dotradius/2 + 8
  240. def graphctx(self, ctx, gnode):
  241. w = self.col2x(gnode.cols) + 10
  242. h = self.rowheight
  243. dot_y = h / 2
  244. pix = QPixmap(w, h)
  245. pix.fill(QColor(0,0,0,0))
  246. painter = QPainter(pix)
  247. painter.setRenderHint(QPainter.Antialiasing)
  248. pen = QPen(Qt.blue)
  249. pen.setWidth(2)
  250. painter.setPen(pen)
  251. lpen = QPen(pen)
  252. lpen.setColor(Qt.black)
  253. painter.setPen(lpen)
  254. for y1, y4, lines in ((dot_y, dot_y + h, gnode.bottomlines),
  255. (dot_y - h, dot_y, gnode.toplines)):
  256. y2 = y1 + 1 * (y4 - y1)/4
  257. ymid = (y1 + y4)/2
  258. y3 = y1 + 3 * (y4 - y1)/4
  259. for start, end, color in lines:
  260. lpen = QPen(pen)
  261. lpen.setColor(QColor(get_color(color)))
  262. lpen.setWidth(2)
  263. painter.setPen(lpen)
  264. x1 = self.col2x(start)
  265. x2 = self.col2x(end)
  266. path = QPainterPath()
  267. path.moveTo(x1, y1)
  268. path.cubicTo(x1, y2,
  269. x1, y2,
  270. (x1 + x2)/2, ymid)
  271. path.cubicTo(x2, y3,
  272. x2, y3,
  273. x2, y4)
  274. painter.drawPath(path)
  275. # Draw node
  276. dot_color = QColor(self.namedbranch_color(ctx.branch()))
  277. dotcolor = dot_color.lighter()
  278. pencolor = dot_color.darker()
  279. white = QColor("white")
  280. fillcolor = gnode.rev is None and white or dotcolor
  281. pen = QPen(pencolor)
  282. pen.setWidthF(1.5)
  283. painter.setPen(pen)
  284. radius = self.dotradius
  285. centre_x = self.col2x(gnode.x)
  286. centre_y = h/2
  287. def circle(r):
  288. rect = QRectF(centre_x - r,
  289. centre_y - r,
  290. 2 * r, 2 * r)
  291. painter.drawEllipse(rect)
  292. def diamond(r):
  293. poly = QPolygonF([QPointF(centre_x - r, centre_y),
  294. QPointF(centre_x, centre_y - r),
  295. QPointF(centre_x + r, centre_y),
  296. QPointF(centre_x, centre_y + r),
  297. QPointF(centre_x - r, centre_y),])
  298. painter.drawPolygon(poly)
  299. if ctx.thgmqappliedpatch(): # diamonds for patches
  300. if ctx.thgwdparent():
  301. painter.setBrush(white)
  302. diamond(2 * 0.9 * radius / 1.5)
  303. painter.setBrush(fillcolor)
  304. diamond(radius / 1.5)
  305. elif ctx.thgmqunappliedpatch():
  306. patchcolor = QColor('#dddddd')
  307. painter.setBrush(patchcolor)
  308. painter.setPen(patchcolor)
  309. diamond(radius / 1.5)
  310. else: # circles for normal revisions
  311. if ctx.thgwdparent():
  312. painter.setBrush(white)
  313. circle(0.9 * radius)
  314. painter.setBrush(fillcolor)
  315. circle(0.5 * radius)
  316. painter.end()
  317. return QVariant(pix)
  318. def invalidateCache(self):
  319. self._cache = []
  320. for a in ('_roleoffsets',):
  321. if hasattr(self, a):
  322. delattr(self, a)
  323. @propertycache
  324. def _roleoffsets(self):
  325. return {Qt.DisplayRole : 0,
  326. Qt.ForegroundRole : len(self._columns),
  327. Qt.DecorationRole : len(self._columns) * 2}
  328. def data(self, index, role):
  329. if not index.isValid():
  330. return nullvariant
  331. if role in self._roleoffsets:
  332. offset = self._roleoffsets[role]
  333. else:
  334. return nullvariant
  335. row = index.row()
  336. self.ensureBuilt(row=row)
  337. graphlen = len(self.graph)
  338. cachelen = len(self._cache)
  339. if graphlen > cachelen:
  340. self._cache.extend([None,] * (graphlen-cachelen))
  341. data = self._cache[row]
  342. if data is None:
  343. data = [None,] * (self._roleoffsets[Qt.DecorationRole]+1)
  344. column = self._columns[index.column()]
  345. if role == Qt.DecorationRole:
  346. if column != 'Graph':
  347. return nullvariant
  348. if data[offset] is None:
  349. gnode = self.graph[row]
  350. ctx = self.repo.changectx(gnode.rev)
  351. data[offset] = self.graphctx(ctx, gnode)
  352. self._cache[row] = data
  353. return data[offset]
  354. else:
  355. idx = index.column() + offset
  356. if data[idx] is None:
  357. try:
  358. result = self.rawdata(row, column, role)
  359. except util.Abort:
  360. result = nullvariant
  361. data[idx] = result
  362. self._cache[row] = data
  363. return data[idx]
  364. def rawdata(self, row, column, role):
  365. gnode = self.graph[row]
  366. ctx = self.repo.changectx(gnode.rev)
  367. if role == Qt.DisplayRole:
  368. text = self._columnmap[column](ctx, gnode)
  369. if not isinstance(text, (QString, unicode)):
  370. text = hglib.tounicode(text)
  371. return QVariant(text)
  372. elif role == Qt.ForegroundRole:
  373. if ctx.thgmqunappliedpatch():
  374. return QColor(UNAPPLIED_PATCH_COLOR)
  375. if column == 'Author':
  376. if self.authorcolor:
  377. return QVariant(QColor(self.user_color(ctx.user())))
  378. return nullvariant
  379. if column == 'Branch':
  380. return QVariant(QColor(self.namedbranch_color(ctx.branch())))
  381. return nullvariant
  382. def flags(self, index):
  383. if not index.isValid():
  384. return 0
  385. if not self.revset:
  386. return Qt.ItemIsSelectable | Qt.ItemIsEnabled
  387. row = index.row()
  388. self.ensureBuilt(row=row)
  389. gnode = self.graph[row]
  390. ctx = self.repo.changectx(gnode.rev)
  391. if ctx.node() not in self.revset:
  392. return Qt.ItemFlags(0)
  393. return Qt.ItemIsSelectable | Qt.ItemIsEnabled
  394. def headerData(self, section, orientation, role):
  395. if orientation == Qt.Horizontal:
  396. if role == Qt.DisplayRole:
  397. return QVariant(self._columns[section])
  398. if role == Qt.TextAlignmentRole:
  399. return QVariant(Qt.AlignLeft)
  400. return nullvariant
  401. def rowFromRev(self, rev):
  402. row = self.graph.index(rev)
  403. if row == -1:
  404. row = None
  405. return row
  406. def indexFromRev(self, rev):
  407. if self.graph is None:
  408. return None
  409. self.ensureBuilt(rev=rev)
  410. row = self.rowFromRev(rev)
  411. if row is not None:
  412. return self.index(row, 0)
  413. return None
  414. def clear(self):
  415. 'empty the list'
  416. self.graph = None
  417. self.datacache = {}
  418. self.layoutChanged.emit()
  419. def getbranch(self, ctx, gnode):
  420. return hglib.tounicode(ctx.branch())
  421. def gettags(self, ctx, gnode):
  422. if ctx.rev() is None:
  423. return ''
  424. tags = [t for t in ctx.tags() if t not in self._mqtags]
  425. return hglib.tounicode(','.join(tags))
  426. def getauthor(self, ctx, gnode):
  427. try:
  428. return hglib.username(ctx.user())
  429. except error.Abort:
  430. return _('Mercurial User')
  431. def getlog(self, ctx, gnode):
  432. if ctx.rev() is None:
  433. # The Unicode symbol is a black star:
  434. return u'\u2605 ' + _('Working Directory') + u' \u2605'
  435. msg = ctx.longsummary()
  436. if ctx.thgmqunappliedpatch():
  437. effects = qtlib.geteffect('log.unapplied_patch')
  438. text = qtlib.applyeffects(' %s ' % ctx._patchname, effects)
  439. # qtlib.markup(msg, fg=UNAPPLIED_PATCH_COLOR)
  440. return hglib.tounicode(text + ' ' + msg)
  441. parts = []
  442. if ctx.thgbranchhead():
  443. branchu = hglib.tounicode(ctx.branch())
  444. effects = qtlib.geteffect('log.branch')
  445. parts.append(qtlib.applyeffects(u' %s ' % branchu, effects))
  446. # in the near future, I expect bookmarks to be available from the
  447. # repository via a separate API, making this logic more efficient.
  448. bookmarks = self.repo.bookmarks.keys()
  449. curbookmark = self.repo.bookmarkcurrent
  450. for tag in ctx.thgtags():
  451. if self.repo.thgmqtag(tag):
  452. style = 'log.patch'
  453. elif tag == curbookmark:
  454. style = 'log.curbookmark'
  455. elif tag in bookmarks:
  456. style = 'log.bookmark'
  457. else:
  458. style = 'log.tag'
  459. tagu = hglib.tounicode(tag)
  460. effects = qtlib.geteffect(style)
  461. parts.append(qtlib.applyeffects(u' %s ' % tagu, effects))
  462. if msg:
  463. if ctx.thgwdparent():
  464. msg = qtlib.markup(msg, weight='bold')
  465. else:
  466. msg = qtlib.markup(msg)
  467. parts.append(hglib.tounicode(msg))
  468. return ' '.join(parts)
  469. def getchanges(self, ctx, gnode):
  470. """Return the MAR status for the given ctx."""
  471. changes = []
  472. M, A, R = ctx.changesToParent(0)
  473. def addtotal(files, style):
  474. effects = qtlib.geteffect(style)
  475. text = qtlib.applyeffects(' %s ' % len(files), effects)
  476. changes.append(text)
  477. if M:
  478. addtotal(M, 'log.modified')
  479. if A:
  480. addtotal(A, 'log.added')
  481. if R:
  482. addtotal(R, 'log.removed')
  483. return ''.join(changes)