dag: Handle creating a patch for a single commit
[git-cola.git] / cola / dag / view.py
blob34b49c2dc8f3db5b71bb7efc1202ea879b4400a7
1 import collections
2 import math
3 import sys
4 import time
5 import urllib
7 from PyQt4 import QtGui
8 from PyQt4 import QtCore
9 from PyQt4 import QtNetwork
10 from PyQt4.QtCore import Qt
11 from PyQt4.QtCore import SIGNAL
13 import cola
14 from cola import difftool
15 from cola import gitcmds
16 from cola import observable
17 from cola import qtutils
18 from cola import resources
19 from cola import signals
20 from cola.compat import hashlib
21 from cola.dag.model import archive
22 from cola.dag.model import RepoReader
23 from cola.prefs import diff_font
24 from cola.qt import DiffSyntaxHighlighter
25 from cola.qt import GitLogLineEdit
26 from cola.widgets import defs
27 from cola.widgets import standard
28 from cola.widgets.createbranch import create_new_branch
29 from cola.widgets.createtag import create_tag
30 from cola.widgets.archive import GitArchiveDialog
31 from cola.widgets.browse import BrowseDialog
34 class GravatarLabel(QtGui.QLabel):
35 def __init__(self, parent=None):
36 super(GravatarLabel, self).__init__(parent)
38 self.email = None
39 self.response = None
40 self.timeout = 0
41 self.imgsize = 48
43 self.network = QtNetwork.QNetworkAccessManager()
44 self.connect(self.network,
45 SIGNAL('finished(QNetworkReply*)'),
46 self.network_finished)
48 def url_for_email(self, email):
49 email_hash = hashlib.md5(email).hexdigest()
50 default_url = 'http://git-cola.github.com/images/git-64x64.jpg'
51 encoded_url = urllib.quote(default_url, '')
52 query = '?s=%d&d=%s' % (self.imgsize, encoded_url)
53 url = 'http://gravatar.com/avatar/' + email_hash + query
54 return url
56 def get_email(self, email):
57 if (self.timeout > 0 and
58 (int(time.time()) - self.timeout) < (5 * 60)):
59 self.set_pixmap_from_response()
60 return
61 if email == self.email and self.response is not None:
62 self.set_pixmap_from_response()
63 return
64 self.email = email
65 self.get(self.url_for_email(email))
67 def get(self, url):
68 self.network.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
70 def default_pixmap_as_bytes(self):
71 xres = self.imgsize
72 pixmap = QtGui.QPixmap(resources.icon('git.svg'))
73 pixmap = pixmap.scaledToHeight(xres, Qt.SmoothTransformation)
74 byte_array = QtCore.QByteArray()
75 buf = QtCore.QBuffer(byte_array)
76 buf.open(QtCore.QIODevice.WriteOnly)
77 pixmap.save(buf, 'PNG')
78 buf.close()
79 return byte_array
81 def network_finished(self, reply):
82 header = QtCore.QByteArray('Location')
83 raw_header = reply.rawHeader(header)
84 if raw_header:
85 location = unicode(QtCore.QString(raw_header)).strip()
86 request_location = unicode(self.url_for_email(self.email))
87 relocated = location != request_location
88 else:
89 relocated = False
91 if reply.error() == QtNetwork.QNetworkReply.NoError:
92 if relocated:
93 # We could do get_url(urllib.unquote(location)) to
94 # download the default image.
95 # Save bandwidth by using a pixmap.
96 self.response = self.default_pixmap_as_bytes()
97 else:
98 self.response = reply.readAll()
99 self.timeout = 0
100 else:
101 self.response = self.default_pixmap_as_bytes()
102 self.timeout = int(time.time())
103 self.set_pixmap_from_response()
105 def set_pixmap_from_response(self):
106 pixmap = QtGui.QPixmap()
107 pixmap.loadFromData(self.response)
108 self.setPixmap(pixmap)
111 class TextLabel(QtGui.QLabel):
112 def __init__(self, parent=None):
113 super(TextLabel, self).__init__(parent)
114 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
115 Qt.LinksAccessibleByMouse)
116 self.setOpenExternalLinks(True)
117 self._text = ''
118 self._elide = False
120 def elide(self):
121 self._elide = True
123 def setText(self, text):
124 if self._elide:
125 self._text = text
126 width = self.width()
127 fm = QtGui.QFontMetrics(self.font())
128 text = fm.elidedText(text, Qt.ElideRight, width-2)
129 super(TextLabel, self).setText(text)
131 def resizeEvent(self, event):
132 super(TextLabel, self).resizeEvent(event)
133 if self._elide:
134 self.setText(self._text)
137 class DiffWidget(QtGui.QWidget):
138 def __init__(self, notifier, parent):
139 QtGui.QWidget.__init__(self, parent)
141 author_font = QtGui.QFont(self.font())
142 author_font.setPointSize(int(author_font.pointSize() * 1.1))
144 summary_font = QtGui.QFont(author_font)
145 summary_font.setWeight(QtGui.QFont.Bold)
147 policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding,
148 QtGui.QSizePolicy.Minimum)
150 self.gravatar_label = GravatarLabel()
152 self.author_label = TextLabel()
153 self.author_label.setTextFormat(Qt.RichText)
154 self.author_label.setFont(author_font)
155 self.author_label.setSizePolicy(policy)
156 self.author_label.setAlignment(Qt.AlignBottom)
158 self.summary_label = TextLabel()
159 self.summary_label.setTextFormat(Qt.PlainText)
160 self.summary_label.setFont(summary_font)
161 self.summary_label.setSizePolicy(policy)
162 self.summary_label.setAlignment(Qt.AlignTop)
163 self.summary_label.elide()
165 self.diff = QtGui.QTextEdit()
166 self.diff.setLineWrapMode(QtGui.QTextEdit.NoWrap)
167 self.diff.setReadOnly(True)
168 self.diff.setFont(diff_font())
169 self.highlighter = DiffSyntaxHighlighter(self.diff.document())
171 self.info_layout = QtGui.QVBoxLayout()
172 self.info_layout.setMargin(0)
173 self.info_layout.setSpacing(0)
174 self.info_layout.addWidget(self.author_label)
175 self.info_layout.addWidget(self.summary_label)
177 self.logo_layout = QtGui.QHBoxLayout()
178 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
179 self.logo_layout.setSpacing(defs.button_spacing)
180 self.logo_layout.addWidget(self.gravatar_label)
181 self.logo_layout.addLayout(self.info_layout)
183 self.main_layout = QtGui.QVBoxLayout()
184 self.main_layout.setMargin(0)
185 self.main_layout.setSpacing(defs.spacing)
186 self.main_layout.addLayout(self.logo_layout)
187 self.main_layout.addWidget(self.diff)
188 self.setLayout(self.main_layout)
190 sig = signals.commits_selected
191 notifier.add_observer(sig, self.commits_selected)
193 def commits_selected(self, commits):
194 if len(commits) != 1:
195 return
196 commit = commits[0]
197 sha1 = commit.sha1
198 self.diff.setText(gitcmds.diff_info(sha1))
200 email = commit.email or ''
201 summary = commit.summary or ''
202 author = commit.author or ''
204 template_args = {
205 'author': author,
206 'email': email,
207 'summary': summary
210 self.gravatar_label.get_email(email)
211 author_text = ("""%(author)s &lt;"""
212 """<a href="mailto:%(email)s">"""
213 """%(email)s</a>&gt;"""
214 % template_args)
216 self.author_label.setText(author_text)
217 self.summary_label.setText(summary)
220 class ViewerMixin(object):
221 def __init__(self):
222 self.selected = None
223 self.clicked = None
224 self.menu_actions = self.context_menu_actions()
226 def diff_selected_this(self):
227 clicked_sha1 = self.clicked.sha1
228 selected_sha1 = self.selected.sha1
229 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
231 def diff_this_selected(self):
232 clicked_sha1 = self.clicked.sha1
233 selected_sha1 = self.selected.sha1
234 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
236 def cherry_pick(self):
237 sha1 = self.clicked.sha1
238 cola.notifier().broadcast(signals.cherry_pick, [sha1])
240 def copy_to_clipboard(self):
241 clicked_sha1 = self.clicked.sha1
242 qtutils.set_clipboard(clicked_sha1)
244 def create_branch(self):
245 sha1 = self.clicked.sha1
246 create_new_branch(revision=sha1)
248 def create_tag(self):
249 sha1 = self.clicked.sha1
250 create_tag(revision=sha1)
252 def create_tarball(self):
253 ref = self.clicked.sha1
254 shortref = ref[:7]
255 GitArchiveDialog.save(ref, shortref, self)
257 def save_blob_dialog(self):
258 return BrowseDialog.browse(self.clicked.sha1)
260 def context_menu_actions(self):
261 return {
262 'diff_this_selected':
263 qtutils.add_action(self, 'Diff this -> selected',
264 self.diff_this_selected),
265 'diff_selected_this':
266 qtutils.add_action(self, 'Diff selected -> this',
267 self.diff_selected_this),
268 'create_branch':
269 qtutils.add_action(self, 'Create Branch',
270 self.create_branch),
271 'create_patch':
272 qtutils.add_action(self, 'Create Patch',
273 self.create_patch),
274 'create_tag':
275 qtutils.add_action(self, 'Create Tag',
276 self.create_tag),
277 'create_tarball':
278 qtutils.add_action(self, 'Save As Tarball/Zip...',
279 self.create_tarball),
280 'cherry_pick':
281 qtutils.add_action(self, 'Cherry Pick',
282 self.cherry_pick),
283 'save_blob':
284 qtutils.add_action(self, 'Grab File...',
285 self.save_blob_dialog),
286 'copy':
287 qtutils.add_action(self, 'Copy SHA-1',
288 self.copy_to_clipboard,
289 QtGui.QKeySequence.Copy),
292 def update_menu_actions(self, event):
293 clicked = self.itemAt(event.pos())
294 selected_items = self.selectedItems()
295 has_single_selection = len(selected_items) == 1
297 has_selection = bool(selected_items)
298 can_diff = bool(clicked and has_single_selection and
299 clicked is not selected_items[0])
301 self.clicked = clicked.commit
302 if can_diff:
303 self.selected = selected_items[0].commit
304 else:
305 self.selected = None
307 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
308 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
310 self.menu_actions['create_branch'].setEnabled(has_single_selection)
311 self.menu_actions['create_tag'].setEnabled(has_single_selection)
313 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
314 self.menu_actions['create_patch'].setEnabled(has_selection)
315 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
317 self.menu_actions['save_blob'].setEnabled(has_single_selection)
318 self.menu_actions['copy'].setEnabled(has_single_selection)
320 def context_menu_event(self, event):
321 self.update_menu_actions(event)
322 menu = QtGui.QMenu(self)
323 menu.addAction(self.menu_actions['diff_this_selected'])
324 menu.addAction(self.menu_actions['diff_selected_this'])
325 menu.addSeparator()
326 menu.addAction(self.menu_actions['create_branch'])
327 menu.addAction(self.menu_actions['create_tag'])
328 menu.addSeparator()
329 menu.addAction(self.menu_actions['cherry_pick'])
330 menu.addAction(self.menu_actions['create_patch'])
331 menu.addAction(self.menu_actions['create_tarball'])
332 menu.addSeparator()
333 menu.addAction(self.menu_actions['save_blob'])
334 menu.addAction(self.menu_actions['copy'])
335 menu.exec_(self.mapToGlobal(event.pos()))
338 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
339 def __init__(self, commit, parent=None):
340 QtGui.QListWidgetItem.__init__(self, parent)
341 self.commit = commit
342 self.setText(0, commit.summary)
343 self.setText(1, commit.author)
344 self.setText(2, commit.authdate)
347 class CommitTreeWidget(QtGui.QTreeWidget, ViewerMixin):
348 def __init__(self, notifier, parent):
349 QtGui.QTreeWidget.__init__(self, parent)
350 ViewerMixin.__init__(self)
352 self.setSelectionMode(self.ContiguousSelection)
353 self.setUniformRowHeights(True)
354 self.setAllColumnsShowFocus(True)
355 self.setAlternatingRowColors(True)
356 self.setRootIsDecorated(False)
357 self.setHeaderLabels(['Summary', 'Author', 'Age'])
359 self.sha1map = {}
360 self.notifier = notifier
361 self.selecting = False
362 self.commits = []
364 self.action_up = qtutils.add_action(self, 'Go Up', self.go_up,
365 QtCore.Qt.Key_K)
367 self.action_down = qtutils.add_action(self, 'Go Down', self.go_down,
368 QtCore.Qt.Key_J)
370 sig = signals.commits_selected
371 notifier.add_observer(sig, self.commits_selected)
373 self.connect(self, SIGNAL('itemSelectionChanged()'),
374 self.selection_changed)
376 def contextMenuEvent(self, event):
377 self.context_menu_event(event)
379 def mousePressEvent(self, event):
380 if event.buttons() == QtCore.Qt.RightButton:
381 event.accept()
382 return
383 if event.modifiers() == QtCore.Qt.MetaModifier:
384 event.accept()
385 return
386 super(CommitTreeWidget, self).mousePressEvent(event)
388 def go_up(self):
389 self.goto(self.itemAbove)
391 def go_down(self):
392 self.goto(self.itemBelow)
394 def goto(self, finder):
395 items = self.selectedItems()
396 item = items and items[0] or None
397 if item is None:
398 return
399 found = finder(item)
400 if found:
401 self.select([found.commit.sha1], block_signals=False)
403 def set_selecting(self, selecting):
404 self.selecting = selecting
406 def selection_changed(self):
407 items = self.selectedItems()
408 if not items:
409 return
410 self.set_selecting(True)
411 sig = signals.commits_selected
412 self.notifier.notify_observers(sig, [i.commit for i in items])
413 self.set_selecting(False)
415 def commits_selected(self, commits):
416 if self.selecting:
417 return
418 self.clicked = commits and commits[0] or None
419 self.select([commit.sha1 for commit in commits])
421 def select(self, sha1s, block_signals=True):
422 self.clearSelection()
423 for sha1 in sha1s:
424 try:
425 item = self.sha1map[sha1]
426 except KeyError:
427 continue
428 block = self.blockSignals(block_signals)
429 self.scrollToItem(item)
430 item.setSelected(True)
431 self.blockSignals(block)
433 def adjust_columns(self):
434 width = self.width()-20
435 zero = width*2/3
436 onetwo = width/6
437 self.setColumnWidth(0, zero)
438 self.setColumnWidth(1, onetwo)
439 self.setColumnWidth(2, onetwo)
441 def clear(self):
442 QtGui.QTreeWidget.clear(self)
443 self.sha1map.clear()
444 self.commits = []
446 def add_commits(self, commits):
447 self.commits.extend(commits)
448 items = []
449 for c in reversed(commits):
450 item = CommitTreeWidgetItem(c)
451 items.append(item)
452 self.sha1map[c.sha1] = item
453 for tag in c.tags:
454 self.sha1map[tag] = item
455 self.insertTopLevelItems(0, items)
457 def create_patch(self):
458 items = self.selectedItems()
459 if not items:
460 return
461 items.reverse()
462 sha1s = [item.commit.sha1 for item in items]
463 all_sha1s = [c.sha1 for c in self.commits]
464 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
467 class DAGView(standard.Widget):
468 """The git-dag widget."""
470 def __init__(self, model, dag, parent=None, args=None):
471 super(DAGView, self).__init__(parent)
473 self.setAttribute(QtCore.Qt.WA_MacMetalStyle)
474 self.setMinimumSize(1, 1)
476 # change when widgets are added/removed
477 self.widget_version = 1
478 self.model = model
479 self.dag = dag
481 self.commits = {}
482 self.commit_list = []
484 self.old_count = None
485 self.old_ref = None
487 self.revtext = GitLogLineEdit(parent=self)
489 self.maxresults = QtGui.QSpinBox()
490 self.maxresults.setMinimum(1)
491 self.maxresults.setMaximum(99999)
492 self.maxresults.setPrefix('git log -')
493 self.maxresults.setSuffix('')
495 self.displaybutton = QtGui.QPushButton()
496 self.displaybutton.setText('Display')
498 self.zoom_in = QtGui.QPushButton()
499 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
500 self.zoom_in.setFlat(True)
502 self.zoom_out = QtGui.QPushButton()
503 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
504 self.zoom_out.setFlat(True)
506 self.top_layout = QtGui.QHBoxLayout()
507 self.top_layout.setMargin(defs.margin)
508 self.top_layout.setSpacing(defs.button_spacing)
510 self.top_layout.addWidget(self.maxresults)
511 self.top_layout.addWidget(self.revtext)
512 self.top_layout.addWidget(self.displaybutton)
513 self.top_layout.addStretch()
514 self.top_layout.addWidget(self.zoom_out)
515 self.top_layout.addWidget(self.zoom_in)
517 self.notifier = notifier = observable.Observable()
518 self.notifier.refs_updated = refs_updated = 'refs_updated'
519 self.notifier.add_observer(refs_updated, self.display)
521 self.graphview = GraphView(notifier, self)
522 self.treewidget = CommitTreeWidget(notifier, self)
523 self.diffwidget = DiffWidget(notifier, self)
525 for signal in (archive,):
526 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
527 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
529 self.splitter = QtGui.QSplitter()
530 self.splitter.setOrientation(QtCore.Qt.Horizontal)
531 self.splitter.setChildrenCollapsible(True)
532 self.splitter.setHandleWidth(defs.handle_width)
534 self.left_splitter = QtGui.QSplitter()
535 self.left_splitter.setOrientation(QtCore.Qt.Vertical)
536 self.left_splitter.setChildrenCollapsible(True)
537 self.left_splitter.setHandleWidth(defs.handle_width)
538 self.left_splitter.setStretchFactor(0, 1)
539 self.left_splitter.setStretchFactor(1, 1)
540 self.left_splitter.insertWidget(0, self.treewidget)
541 self.left_splitter.insertWidget(1, self.diffwidget)
543 self.splitter.insertWidget(0, self.left_splitter)
544 self.splitter.insertWidget(1, self.graphview)
546 self.splitter.setStretchFactor(0, 1)
547 self.splitter.setStretchFactor(1, 1)
549 self.main_layout = QtGui.QVBoxLayout()
550 self.main_layout.setMargin(0)
551 self.main_layout.setSpacing(0)
552 self.main_layout.addLayout(self.top_layout)
553 self.main_layout.addWidget(self.splitter)
554 self.setLayout(self.main_layout)
556 # Also re-loads dag.* from the saved state
557 if not qtutils.apply_state(self):
558 self.resize_to_desktop()
560 # Update fields affected by model
561 self.revtext.setText(dag.ref)
562 self.maxresults.setValue(dag.count)
563 self.update_window_title()
565 self.thread = ReaderThread(self, dag)
567 self.thread.connect(self.thread, self.thread.commits_ready,
568 self.add_commits)
570 self.thread.connect(self.thread, self.thread.done,
571 self.thread_done)
573 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
574 self.splitter_moved)
576 self.connect(self.zoom_in, SIGNAL('pressed()'),
577 self.graphview.zoom_in)
579 self.connect(self.zoom_out, SIGNAL('pressed()'),
580 self.graphview.zoom_out)
582 self.connect(self.treewidget, SIGNAL('diff_commits'),
583 self.diff_commits)
585 self.connect(self.graphview, SIGNAL('diff_commits'),
586 self.diff_commits)
588 self.connect(self.maxresults, SIGNAL('editingFinished()'),
589 self.display)
591 self.connect(self.displaybutton, SIGNAL('pressed()'),
592 self.display)
594 self.connect(self.revtext, SIGNAL('ref_changed'),
595 self.display)
597 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
598 self.text_changed)
600 # The model is updated in another thread so use
601 # signals/slots to bring control back to the main GUI thread
602 self.model.add_observer(self.model.message_updated,
603 self.emit_model_updated)
605 self.connect(self, SIGNAL('model_updated'),
606 self.model_updated)
608 qtutils.add_close_action(self)
610 def text_changed(self, txt):
611 self.dag.ref = unicode(txt)
612 self.update_window_title()
614 def update_window_title(self):
615 project = self.model.project
616 if self.dag.ref:
617 self.setWindowTitle('%s: %s' % (project, self.dag.ref))
618 else:
619 self.setWindowTitle(project)
621 def export_state(self):
622 state = super(DAGView, self).export_state()
623 state['count'] = self.dag.count
624 return state
626 def apply_state(self, state):
627 try:
628 super(DAGView, self).apply_state(state)
629 except:
630 pass
631 try:
632 count = state['count']
633 except KeyError:
634 pass
635 else:
636 if not self.dag.overridden('count'):
637 self.dag.set_count(count)
639 def emit_model_updated(self):
640 self.emit(SIGNAL('model_updated'))
642 def model_updated(self):
643 if self.dag.ref:
644 self.revtext.update_matches()
645 return
646 if not self.model.currentbranch:
647 return
648 self.revtext.setText(self.model.currentbranch)
649 self.display()
651 def display(self):
652 new_ref = unicode(self.revtext.text())
653 if not new_ref:
654 return
655 new_count = self.maxresults.value()
656 old_ref = self.old_ref
657 old_count = self.old_count
658 if old_ref == new_ref and old_count == new_count:
659 return
661 self.setEnabled(False)
663 self.old_ref = new_ref
664 self.old_count = new_count
666 self.stop()
667 self.clear()
668 self.dag.set_ref(new_ref)
669 self.dag.set_count(self.maxresults.value())
670 self.start()
672 def show(self):
673 super(DAGView, self).show()
674 self.splitter.setSizes([self.width()/2, self.width()/2])
675 self.left_splitter.setSizes([self.height()/4, self.height()*3/4])
676 self.treewidget.adjust_columns()
678 def resizeEvent(self, e):
679 super(DAGView, self).resizeEvent(e)
680 self.treewidget.adjust_columns()
682 def splitter_moved(self, pos, idx):
683 self.treewidget.adjust_columns()
685 def clear(self):
686 self.graphview.clear()
687 self.treewidget.clear()
688 self.commits.clear()
689 self.commit_list = []
691 def add_commits(self, commits):
692 self.commit_list.extend(commits)
693 # Keep track of commits
694 for commit_obj in commits:
695 self.commits[commit_obj.sha1] = commit_obj
696 for tag in commit_obj.tags:
697 self.commits[tag] = commit_obj
698 self.graphview.add_commits(commits)
699 self.treewidget.add_commits(commits)
701 def thread_done(self):
702 self.setEnabled(True)
703 try:
704 commit_obj = self.commit_list[-1]
705 except IndexError:
706 return
707 sig = signals.commits_selected
708 self.notifier.notify_observers(sig, [commit_obj])
709 self.graphview.update_scene_rect()
710 self.graphview.view_fit()
712 def closeEvent(self, event):
713 self.revtext.close_popup()
714 self.stop()
715 qtutils.save_state(self)
716 return super(DAGView, self).closeEvent(event)
718 def pause(self):
719 self.thread.mutex.lock()
720 self.thread.stop = True
721 self.thread.mutex.unlock()
723 def stop(self):
724 self.thread.abort = True
725 self.thread.wait()
727 def start(self):
728 self.thread.abort = False
729 self.thread.stop = False
730 self.thread.start()
732 def resume(self):
733 self.thread.mutex.lock()
734 self.thread.stop = False
735 self.thread.mutex.unlock()
736 self.thread.condition.wakeOne()
738 def resize_to_desktop(self):
739 desktop = QtGui.QApplication.instance().desktop()
740 width = desktop.width()
741 height = desktop.height()
742 self.resize(width, height)
744 def diff_commits(self, a, b):
745 paths = self.dag.paths()
746 if paths:
747 difftool.launch([a, b, '--'] + paths)
748 else:
749 difftool.diff_commits(self, a, b)
752 class ReaderThread(QtCore.QThread):
754 commits_ready = SIGNAL('commits_ready')
755 done = SIGNAL('done')
757 def __init__(self, parent, dag):
758 QtCore.QThread.__init__(self, parent)
759 self.dag = dag
760 self.abort = False
761 self.stop = False
762 self.mutex = QtCore.QMutex()
763 self.condition = QtCore.QWaitCondition()
765 def run(self):
766 repo = RepoReader(self.dag)
767 repo.reset()
768 commits = []
769 for c in repo:
770 self.mutex.lock()
771 if self.stop:
772 self.condition.wait(self.mutex)
773 self.mutex.unlock()
774 if self.abort:
775 repo.reset()
776 return
777 commits.append(c)
778 if len(commits) >= 512:
779 self.emit(self.commits_ready, commits)
780 commits = []
782 if commits:
783 self.emit(self.commits_ready, commits)
784 self.emit(self.done)
787 class Cache(object):
788 pass
791 class Edge(QtGui.QGraphicsItem):
792 item_type = QtGui.QGraphicsItem.UserType + 1
793 arrow_size = 2.0
794 arrow_extra = (arrow_size+1.0)/2.0
796 pen = QtGui.QPen(QtCore.Qt.gray, 1.0,
797 QtCore.Qt.DotLine,
798 QtCore.Qt.SquareCap,
799 QtCore.Qt.BevelJoin)
801 def __init__(self, source, dest,
802 extra=arrow_extra,
803 arrow_size=arrow_size):
805 QtGui.QGraphicsItem.__init__(self)
807 self.setAcceptedMouseButtons(QtCore.Qt.NoButton)
808 self.source = source
809 self.dest = dest
810 self.setZValue(-2)
812 dest_pt = Commit.item_bbox.center()
814 self.source_pt = self.mapFromItem(self.source, dest_pt)
815 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
816 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
818 width = self.dest_pt.x() - self.source_pt.x()
819 height = self.dest_pt.y() - self.source_pt.y()
820 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
821 self.bound = rect.normalized().adjusted(-extra, -extra, extra, extra)
823 def type(self):
824 return self.item_type
826 def boundingRect(self):
827 return self.bound
829 def paint(self, painter, option, widget,
830 arrow_size=arrow_size,
831 gray=QtCore.Qt.gray):
832 # Draw the line
833 painter.setPen(self.pen)
834 painter.drawLine(self.line)
837 class Commit(QtGui.QGraphicsItem):
838 item_type = QtGui.QGraphicsItem.UserType + 2
839 width = 46.
840 height = 24.
842 item_shape = QtGui.QPainterPath()
843 item_shape.addRect(width/-2., height/-2., width, height)
844 item_bbox = item_shape.boundingRect()
846 inner_rect = QtGui.QPainterPath()
847 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
848 inner_rect = inner_rect.boundingRect()
850 selected_color = QtGui.QColor.fromRgb(255, 255, 0)
851 outline_color = QtGui.QColor.fromRgb(64, 96, 192)
854 text_options = QtGui.QTextOption()
855 text_options.setAlignment(QtCore.Qt.AlignCenter)
857 commit_pen = QtGui.QPen()
858 commit_pen.setWidth(1.0)
859 commit_pen.setColor(outline_color)
861 cached_commit_color = QtGui.QColor.fromRgb(128, 222, 255)
862 cached_commit_selected_color = QtGui.QColor.fromRgb(32, 64, 255)
863 cached_merge_color = QtGui.QColor.fromRgb(255, 255, 255)
865 def __init__(self, commit,
866 notifier,
867 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
868 cursor=QtCore.Qt.PointingHandCursor,
869 xpos=width/2. + 1.,
870 commit_color=cached_commit_color,
871 commit_selected_color=cached_commit_selected_color,
872 merge_color=cached_merge_color):
874 QtGui.QGraphicsItem.__init__(self)
876 self.setZValue(0)
877 self.setFlag(selectable)
878 self.setCursor(cursor)
880 self.commit = commit
881 self.notifier = notifier
883 if commit.tags:
884 self.label = label = Label(commit)
885 label.setParentItem(self)
886 label.setPos(xpos, 0.)
887 else:
888 self.label = None
890 if len(commit.parents) > 1:
891 self.commit_color = merge_color
892 else:
893 self.commit_color = commit_color
894 self.text_pen = QtCore.Qt.black
895 self.sha1_text = commit.sha1[:8]
897 self.pressed = False
898 self.dragged = False
901 # Overridden Qt methods
904 def blockSignals(self, blocked):
905 self.notifier.notification_enabled = not blocked
907 def itemChange(self, change, value):
908 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
909 # Broadcast selection to other widgets
910 selected_items = self.scene().selectedItems()
911 commits = [item.commit for item in selected_items]
912 self.scene().parent().set_selecting(True)
913 sig = signals.commits_selected
914 self.notifier.notify_observers(sig, commits)
915 self.scene().parent().set_selecting(False)
917 # Cache the pen for use in paint()
918 if value.toPyObject():
919 self.commit_color = self.cached_commit_selected_color
920 self.text_pen = QtCore.Qt.white
921 color = self.selected_color
922 else:
923 self.text_pen = QtCore.Qt.black
924 if len(self.commit.parents) > 1:
925 self.commit_color = self.cached_merge_color
926 else:
927 self.commit_color = self.cached_commit_color
928 color = self.outline_color
929 commit_pen = QtGui.QPen()
930 commit_pen.setWidth(1.0)
931 commit_pen.setColor(color)
932 self.commit_pen = commit_pen
934 return QtGui.QGraphicsItem.itemChange(self, change, value)
936 def type(self):
937 return self.item_type
939 def boundingRect(self, rect=item_bbox):
940 return rect
942 def shape(self):
943 return self.item_shape
945 def paint(self, painter, option, widget,
946 inner=inner_rect,
947 text_opts=text_options,
948 cache=Cache):
950 # Do not draw outside the exposed rect
951 painter.setClipRect(option.exposedRect)
953 # Draw ellipse
954 painter.setPen(self.commit_pen)
955 painter.setBrush(self.commit_color)
956 painter.drawEllipse(inner)
958 # Draw text
959 try:
960 font = cache.font
961 except AttributeError:
962 font = cache.font = painter.font()
963 font.setPointSize(5)
964 painter.setFont(font)
965 painter.setPen(self.text_pen)
966 painter.drawText(inner, self.sha1_text, text_opts)
968 def mousePressEvent(self, event):
969 QtGui.QGraphicsItem.mousePressEvent(self, event)
970 self.pressed = True
971 self.selected = self.isSelected()
973 def mouseMoveEvent(self, event):
974 if self.pressed:
975 self.dragged = True
976 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
978 def mouseReleaseEvent(self, event):
979 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
980 if (not self.dragged and
981 self.selected and
982 event.button() == QtCore.Qt.LeftButton):
983 return
984 self.pressed = False
985 self.dragged = False
988 class Label(QtGui.QGraphicsItem):
989 item_type = QtGui.QGraphicsItem.UserType + 3
991 width = 72
992 height = 18
994 item_shape = QtGui.QPainterPath()
995 item_shape.addRect(0, 0, width, height)
996 item_bbox = item_shape.boundingRect()
998 text_options = QtGui.QTextOption()
999 text_options.setAlignment(QtCore.Qt.AlignCenter)
1000 text_options.setAlignment(QtCore.Qt.AlignVCenter)
1002 def __init__(self, commit,
1003 other_color=QtGui.QColor.fromRgb(255, 255, 64),
1004 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
1005 QtGui.QGraphicsItem.__init__(self)
1006 self.setZValue(-1)
1008 # Starts with enough space for two tags. Any more and the commit
1009 # needs to be taller to accomodate.
1010 self.commit = commit
1011 height = len(commit.tags) * self.height/2. + 4. # +6 padding
1013 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
1014 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
1015 self.tag_text = '\n'.join(commit.tags)
1017 if 'HEAD' in commit.tags:
1018 self.color = head_color
1019 else:
1020 self.color = other_color
1022 self.pen = QtGui.QPen()
1023 self.pen.setColor(self.color.darker())
1024 self.pen.setWidth(1.0)
1026 def type(self):
1027 return self.item_type
1029 def boundingRect(self, rect=item_bbox):
1030 return rect
1032 def shape(self):
1033 return self.item_shape
1035 def paint(self, painter, option, widget,
1036 text_opts=text_options,
1037 black=QtCore.Qt.black,
1038 cache=Cache):
1039 # Draw tags
1040 painter.setBrush(self.color)
1041 painter.setPen(self.pen)
1042 painter.drawRoundedRect(self.label_box, 4, 4)
1043 try:
1044 font = cache.font
1045 except AttributeError:
1046 font = cache.font = painter.font()
1047 font.setPointSize(5)
1048 painter.setFont(font)
1049 painter.setPen(black)
1050 painter.drawText(self.text_box, self.tag_text, text_opts)
1053 class GraphView(QtGui.QGraphicsView, ViewerMixin):
1054 def __init__(self, notifier, parent):
1055 QtGui.QGraphicsView.__init__(self, parent)
1056 ViewerMixin.__init__(self)
1057 try:
1058 from PyQt4 import QtOpenGL
1059 glformat = QtOpenGL.QGLFormat(QtOpenGL.QGL.SampleBuffers)
1060 self.glwidget = QtOpenGL.QGLWidget(glformat)
1061 self.setViewport(self.glwidget)
1062 except:
1063 pass
1065 self.x_off = 132
1066 self.y_off = 32
1067 self.x_max = 0
1068 self.y_min = 0
1070 self.selection_list = []
1071 self.notifier = notifier
1072 self.commits = []
1073 self.items = {}
1074 self.saved_matrix = QtGui.QMatrix(self.matrix())
1076 self.x_offsets = collections.defaultdict(int)
1078 self.is_panning = False
1079 self.pressed = False
1080 self.selecting = False
1081 self.last_mouse = [0, 0]
1082 self.zoom = 2
1083 self.setDragMode(self.RubberBandDrag)
1085 scene = QtGui.QGraphicsScene(self)
1086 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
1087 self.setScene(scene)
1090 self.setRenderHint(QtGui.QPainter.Antialiasing)
1091 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
1092 self.setViewportUpdateMode(self.SmartViewportUpdate)
1093 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
1094 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1095 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
1096 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
1098 self.action_zoom_in = (
1099 qtutils.add_action(self, 'Zoom In',
1100 self.zoom_in,
1101 QtCore.Qt.Key_Plus,
1102 QtCore.Qt.Key_Equal))
1104 self.action_zoom_out = (
1105 qtutils.add_action(self, 'Zoom Out',
1106 self.zoom_out,
1107 QtCore.Qt.Key_Minus))
1109 self.action_zoom_fit = (
1110 qtutils.add_action(self, 'Zoom to Fit',
1111 self.view_fit,
1112 QtCore.Qt.Key_F))
1114 self.action_select_parent = (
1115 qtutils.add_action(self, 'Select Parent',
1116 self.select_parent,
1117 QtCore.Qt.Key_J))
1119 self.action_select_oldest_parent = (
1120 qtutils.add_action(self, 'Select Oldest Parent',
1121 self.select_oldest_parent,
1122 'Shift+J'))
1124 self.action_select_child = (
1125 qtutils.add_action(self, 'Select Child',
1126 self.select_child,
1127 QtCore.Qt.Key_K))
1129 self.action_select_child = (
1130 qtutils.add_action(self, 'Select Nth Child',
1131 self.select_nth_child,
1132 'Shift+K'))
1134 sig = signals.commits_selected
1135 notifier.add_observer(sig, self.commits_selected)
1137 def clear(self):
1138 self.scene().clear()
1139 self.selection_list = []
1140 self.items.clear()
1141 self.x_offsets.clear()
1142 self.x_max = 0
1143 self.y_min = 0
1144 self.commits = []
1146 def zoom_in(self):
1147 self.scale_view(1.5)
1149 def zoom_out(self):
1150 self.scale_view(1.0/1.5)
1152 def commits_selected(self, commits):
1153 if self.selecting:
1154 return
1155 self.clicked = commits and commits[0] or None
1156 self.select([commit.sha1 for commit in commits])
1158 def contextMenuEvent(self, event):
1159 self.context_menu_event(event)
1161 def select(self, sha1s):
1162 """Select the item for the SHA-1"""
1163 self.scene().clearSelection()
1164 for sha1 in sha1s:
1165 try:
1166 item = self.items[sha1]
1167 except KeyError:
1168 continue
1169 item.blockSignals(True)
1170 item.setSelected(True)
1171 item.blockSignals(False)
1172 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1173 self.ensureVisible(item_rect)
1175 def selected_item(self):
1176 """Return the currently selected item"""
1177 selected_items = self.selectedItems()
1178 if not selected_items:
1179 return None
1180 return selected_items[0]
1182 def selectedItems(self):
1183 """Return the currently selected items"""
1184 return self.scene().selectedItems()
1186 def get_item_by_generation(self, commits, criteria_fn):
1187 """Return the item for the commit matching criteria"""
1188 if not commits:
1189 return None
1190 generation = None
1191 for commit in commits:
1192 if (generation is None or
1193 criteria_fn(generation, commit.generation)):
1194 sha1 = commit.sha1
1195 generation = commit.generation
1196 try:
1197 return self.items[sha1]
1198 except KeyError:
1199 return None
1201 def oldest_item(self, commits):
1202 """Return the item for the commit with the oldest generation number"""
1203 return self.get_item_by_generation(commits, lambda a, b: a > b)
1205 def newest_item(self, commits):
1206 """Return the item for the commit with the newest generation number"""
1207 return self.get_item_by_generation(commits, lambda a, b: a < b)
1209 def create_patch(self):
1210 items = self.selectedItems()
1211 if not items:
1212 return
1213 selected_commits = self.sort_by_generation([n.commit for n in items])
1214 sha1s = [c.sha1 for c in selected_commits]
1215 all_sha1s = [c.sha1 for c in self.commits]
1216 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
1218 def select_parent(self):
1219 """Select the parent with the newest generation number"""
1220 selected_item = self.selected_item()
1221 if selected_item is None:
1222 return
1223 parent_item = self.newest_item(selected_item.commit.parents)
1224 if parent_item is None:
1225 return
1226 selected_item.setSelected(False)
1227 parent_item.setSelected(True)
1228 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1230 def select_oldest_parent(self):
1231 """Select the parent with the oldest generation number"""
1232 selected_item = self.selected_item()
1233 if selected_item is None:
1234 return
1235 parent_item = self.oldest_item(selected_item.commit.parents)
1236 if parent_item is None:
1237 return
1238 selected_item.setSelected(False)
1239 parent_item.setSelected(True)
1240 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1242 def select_child(self):
1243 """Select the child with the oldest generation number"""
1244 selected_item = self.selected_item()
1245 if selected_item is None:
1246 return
1247 child_item = self.oldest_item(selected_item.commit.children)
1248 if child_item is None:
1249 return
1250 selected_item.setSelected(False)
1251 child_item.setSelected(True)
1252 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1254 def select_nth_child(self):
1255 """Select the Nth child with the newest generation number (N > 1)"""
1256 selected_item = self.selected_item()
1257 if selected_item is None:
1258 return
1259 if len(selected_item.commit.children) > 1:
1260 children = selected_item.commit.children[1:]
1261 else:
1262 children = selected_item.commit.children
1263 child_item = self.newest_item(children)
1264 if child_item is None:
1265 return
1266 selected_item.setSelected(False)
1267 child_item.setSelected(True)
1268 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1270 def view_fit(self):
1271 """Fit selected items into the viewport"""
1273 items = self.scene().selectedItems()
1274 if not items:
1275 rect = self.scene().itemsBoundingRect()
1276 else:
1277 x_min = sys.maxint
1278 y_min = sys.maxint
1279 x_max = -sys.maxint
1280 ymax = -sys.maxint
1281 for item in items:
1282 pos = item.pos()
1283 item_rect = item.boundingRect()
1284 x_off = item_rect.width()
1285 y_off = item_rect.height()
1286 x_min = min(x_min, pos.x())
1287 y_min = min(y_min, pos.y())
1288 x_max = max(x_max, pos.x()+x_off)
1289 ymax = max(ymax, pos.y()+y_off)
1290 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1291 adjust = Commit.width
1292 rect.setX(rect.x() - adjust)
1293 rect.setY(rect.y() - adjust)
1294 rect.setHeight(rect.height() + adjust)
1295 rect.setWidth(rect.width() + adjust)
1296 self.fitInView(rect, QtCore.Qt.KeepAspectRatio)
1297 self.scene().invalidate()
1299 def save_selection(self, event):
1300 if event.button() != QtCore.Qt.LeftButton:
1301 return
1302 elif QtCore.Qt.ShiftModifier != event.modifiers():
1303 return
1304 self.selection_list = self.selectedItems()
1306 def restore_selection(self, event):
1307 if QtCore.Qt.ShiftModifier != event.modifiers():
1308 return
1309 for item in self.selection_list:
1310 item.setSelected(True)
1312 def handle_event(self, event_handler, event):
1313 self.update()
1314 self.save_selection(event)
1315 event_handler(self, event)
1316 self.restore_selection(event)
1318 def mousePressEvent(self, event):
1319 if event.button() == QtCore.Qt.MidButton:
1320 pos = event.pos()
1321 self.mouse_start = [pos.x(), pos.y()]
1322 self.saved_matrix = QtGui.QMatrix(self.matrix())
1323 self.is_panning = True
1324 return
1325 if event.button() == QtCore.Qt.RightButton:
1326 event.ignore()
1327 return
1328 if event.button() == QtCore.Qt.LeftButton:
1329 self.pressed = True
1330 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1332 def mouseMoveEvent(self, event):
1333 pos = self.mapToScene(event.pos())
1334 if self.is_panning:
1335 self.pan(event)
1336 return
1337 self.last_mouse[0] = pos.x()
1338 self.last_mouse[1] = pos.y()
1339 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1341 def set_selecting(self, selecting):
1342 self.selecting = selecting
1344 def mouseReleaseEvent(self, event):
1345 self.pressed = False
1346 if event.button() == QtCore.Qt.MidButton:
1347 self.is_panning = False
1348 return
1349 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1350 self.selection_list = []
1352 def pan(self, event):
1353 pos = event.pos()
1354 dx = pos.x() - self.mouse_start[0]
1355 dy = pos.y() - self.mouse_start[1]
1357 if dx == 0 and dy == 0:
1358 return
1360 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1361 delta = self.mapToScene(rect).boundingRect()
1363 tx = delta.width()
1364 if dx < 0.0:
1365 tx = -tx
1367 ty = delta.height()
1368 if dy < 0.0:
1369 ty = -ty
1371 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1372 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1373 self.setMatrix(matrix)
1375 def wheelEvent(self, event):
1376 """Handle Qt mouse wheel events."""
1377 if event.modifiers() == QtCore.Qt.ControlModifier:
1378 self.wheel_zoom(event)
1379 else:
1380 self.wheel_pan(event)
1382 def wheel_zoom(self, event):
1383 """Handle mouse wheel zooming."""
1384 zoom = math.pow(2.0, event.delta() / 512.0)
1385 factor = (self.matrix()
1386 .scale(zoom, zoom)
1387 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1388 .width())
1389 if factor < 0.014 or factor > 42.0:
1390 return
1391 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1392 self.zoom = zoom
1393 self.scale(zoom, zoom)
1395 def wheel_pan(self, event):
1396 """Handle mouse wheel panning."""
1398 if event.delta() < 0:
1399 s = -133.
1400 else:
1401 s = 133.
1402 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1403 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1405 if event.orientation() == QtCore.Qt.Vertical:
1406 matrix = self.matrix().translate(0, s * factor)
1407 else:
1408 matrix = self.matrix().translate(s * factor, 0)
1409 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1410 self.setMatrix(matrix)
1412 def scale_view(self, scale):
1413 factor = (self.matrix().scale(scale, scale)
1414 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1415 .width())
1416 if factor < 0.07 or factor > 100:
1417 return
1418 self.zoom = scale
1420 adjust_scrollbars = True
1421 scrollbar = self.verticalScrollBar()
1422 if scrollbar:
1423 value = scrollbar.value()
1424 min_ = scrollbar.minimum()
1425 max_ = scrollbar.maximum()
1426 range_ = max_ - min_
1427 distance = value - min_
1428 nonzero_range = float(range_) != 0.0
1429 if nonzero_range:
1430 scrolloffset = distance/float(range_)
1431 else:
1432 adjust_scrollbars = False
1434 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1435 self.scale(scale, scale)
1437 scrollbar = self.verticalScrollBar()
1438 if scrollbar and adjust_scrollbars:
1439 min_ = scrollbar.minimum()
1440 max_ = scrollbar.maximum()
1441 range_ = max_ - min_
1442 value = min_ + int(float(range_) * scrolloffset)
1443 scrollbar.setValue(value)
1445 def add_commits(self, commits):
1446 """Traverse commits and add them to the view."""
1447 self.commits.extend(commits)
1448 scene = self.scene()
1449 for commit in commits:
1450 item = Commit(commit, self.notifier)
1451 self.items[commit.sha1] = item
1452 for ref in commit.tags:
1453 self.items[ref] = item
1454 scene.addItem(item)
1456 self.layout_commits(commits)
1457 self.link(commits)
1459 def link(self, commits):
1460 """Create edges linking commits with their parents"""
1461 scene = self.scene()
1462 for commit in commits:
1463 try:
1464 commit_item = self.items[commit.sha1]
1465 except KeyError:
1466 # TODO - Handle truncated history viewing
1467 pass
1468 for parent in commit.parents:
1469 try:
1470 parent_item = self.items[parent.sha1]
1471 except KeyError:
1472 # TODO - Handle truncated history viewing
1473 continue
1474 edge = Edge(parent_item, commit_item)
1475 scene.addItem(edge)
1477 def layout_commits(self, nodes):
1478 positions = self.position_nodes(nodes)
1479 for sha1, (x, y) in positions.items():
1480 item = self.items[sha1]
1481 item.setPos(x, y)
1483 def position_nodes(self, nodes):
1484 positions = {}
1486 x_max = self.x_max
1487 y_min = self.y_min
1488 x_off = self.x_off
1489 y_off = self.y_off
1490 x_offsets = self.x_offsets
1492 for node in nodes:
1493 generation = node.generation
1494 sha1 = node.sha1
1496 if len(node.children) > 1:
1497 # This is a fan-out so sweep over child generations and
1498 # shift them to the right to avoid overlapping edges
1499 child_gens = [c.generation for c in node.children]
1500 maxgen = reduce(max, child_gens)
1501 mingen = reduce(min, child_gens)
1502 if maxgen > mingen:
1503 for g in xrange(generation+1, maxgen):
1504 x_offsets[g] += x_off
1506 if len(node.parents) == 1:
1507 # Align nodes relative to their parents
1508 parent_gen = node.parents[0].generation
1509 parent_off = x_offsets[parent_gen]
1510 x_offsets[generation] = max(parent_off-x_off,
1511 x_offsets[generation])
1513 cur_xoff = x_offsets[generation]
1514 next_xoff = cur_xoff
1515 next_xoff += x_off
1516 x_offsets[generation] = next_xoff
1518 x_pos = cur_xoff
1519 y_pos = -generation * y_off
1520 positions[sha1] = (x_pos, y_pos)
1522 x_max = max(x_max, x_pos)
1523 y_min = min(y_min, y_pos)
1526 self.x_max = x_max
1527 self.y_min = y_min
1529 return positions
1531 def update_scene_rect(self):
1532 y_min = self.y_min
1533 x_max = self.x_max
1534 self.scene().setSceneRect(-Commit.width*3/4,
1535 y_min-self.y_off/2,
1536 x_max + int(self.x_off * 1.1),
1537 abs(y_min)+self.y_off)
1539 def sort_by_generation(commits):
1540 if len(commits) < 2:
1541 return commits
1542 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1543 return commits