dag: Search when 'enter' is pressed
[git-cola.git] / cola / dag / view.py
blob407df674dd4c5155a39fece04b8475ca2d698b28
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.widgets import completion
24 from cola.widgets import defs
25 from cola.widgets.createbranch import create_new_branch
26 from cola.widgets.createtag import create_tag
27 from cola.widgets.archive import GitArchiveDialog
28 from cola.widgets.browse import BrowseDialog
29 from cola.widgets.standard import Widget
30 from cola.widgets.text import DiffTextEdit
33 class GravatarLabel(QtGui.QLabel):
34 def __init__(self, parent=None):
35 QtGui.QLabel.__init__(self, parent)
37 self.email = None
38 self.response = None
39 self.timeout = 0
40 self.imgsize = 48
42 self.network = QtNetwork.QNetworkAccessManager()
43 self.connect(self.network,
44 SIGNAL('finished(QNetworkReply*)'),
45 self.network_finished)
47 def url_for_email(self, email):
48 email_hash = hashlib.md5(email).hexdigest()
49 default_url = 'http://git-cola.github.com/images/git-64x64.jpg'
50 encoded_url = urllib.quote(default_url, '')
51 query = '?s=%d&d=%s' % (self.imgsize, encoded_url)
52 url = 'http://gravatar.com/avatar/' + email_hash + query
53 return url
55 def get_email(self, email):
56 if (self.timeout > 0 and
57 (int(time.time()) - self.timeout) < (5 * 60)):
58 self.set_pixmap_from_response()
59 return
60 if email == self.email and self.response is not None:
61 self.set_pixmap_from_response()
62 return
63 self.email = email
64 self.get(self.url_for_email(email))
66 def get(self, url):
67 self.network.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
69 def default_pixmap_as_bytes(self):
70 xres = self.imgsize
71 pixmap = QtGui.QPixmap(resources.icon('git.svg'))
72 pixmap = pixmap.scaledToHeight(xres, Qt.SmoothTransformation)
73 byte_array = QtCore.QByteArray()
74 buf = QtCore.QBuffer(byte_array)
75 buf.open(QtCore.QIODevice.WriteOnly)
76 pixmap.save(buf, 'PNG')
77 buf.close()
78 return byte_array
80 def network_finished(self, reply):
81 header = QtCore.QByteArray('Location')
82 raw_header = reply.rawHeader(header)
83 if raw_header:
84 location = unicode(QtCore.QString(raw_header)).strip()
85 request_location = unicode(self.url_for_email(self.email))
86 relocated = location != request_location
87 else:
88 relocated = False
90 if reply.error() == QtNetwork.QNetworkReply.NoError:
91 if relocated:
92 # We could do get_url(urllib.unquote(location)) to
93 # download the default image.
94 # Save bandwidth by using a pixmap.
95 self.response = self.default_pixmap_as_bytes()
96 else:
97 self.response = reply.readAll()
98 self.timeout = 0
99 else:
100 self.response = self.default_pixmap_as_bytes()
101 self.timeout = int(time.time())
102 self.set_pixmap_from_response()
104 def set_pixmap_from_response(self):
105 pixmap = QtGui.QPixmap()
106 pixmap.loadFromData(self.response)
107 self.setPixmap(pixmap)
110 class TextLabel(QtGui.QLabel):
111 def __init__(self, parent=None):
112 QtGui.QLabel.__init__(self, parent)
113 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
114 Qt.LinksAccessibleByMouse)
115 self._display = ''
116 self._template = ''
117 self._text = ''
118 self._elide = False
119 self._metrics = QtGui.QFontMetrics(self.font())
120 self.setOpenExternalLinks(True)
122 def setFont(self, font):
123 self._metrics = QtGui.QFontMetrics(font)
124 QtGui.QLabel.setFont(self, font)
126 def elide(self):
127 self._elide = True
129 def setPlainText(self, text):
130 self.setTemplate(text, text)
132 def setTemplate(self, text, template):
133 self._display = text
134 self._text = text
135 self._template = template
136 self.update_text(self.width())
137 self.setText(self._display)
139 def update_text(self, width):
140 self._display = self._text
141 if not self._elide:
142 return
143 text = self._metrics.elidedText(self._template,
144 Qt.ElideRight, width-2)
145 if unicode(text) != self._template:
146 self._display = text
148 def resizeEvent(self, event):
149 if self._elide:
150 self.update_text(event.size().width())
151 block = self.blockSignals(True)
152 self.setText(self._display)
153 self.blockSignals(block)
154 QtGui.QLabel.resizeEvent(self, event)
157 class DiffWidget(QtGui.QWidget):
158 def __init__(self, notifier, parent):
159 QtGui.QWidget.__init__(self, parent)
161 author_font = QtGui.QFont(self.font())
162 author_font.setPointSize(int(author_font.pointSize() * 1.1))
164 summary_font = QtGui.QFont(author_font)
165 summary_font.setWeight(QtGui.QFont.Bold)
167 policy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding,
168 QtGui.QSizePolicy.Minimum)
170 self.gravatar_label = GravatarLabel()
172 self.author_label = TextLabel()
173 self.author_label.setTextFormat(Qt.RichText)
174 self.author_label.setFont(author_font)
175 self.author_label.setSizePolicy(policy)
176 self.author_label.setAlignment(Qt.AlignBottom)
177 self.author_label.elide()
179 self.summary_label = TextLabel()
180 self.summary_label.setTextFormat(Qt.PlainText)
181 self.summary_label.setFont(summary_font)
182 self.summary_label.setSizePolicy(policy)
183 self.summary_label.setAlignment(Qt.AlignTop)
184 self.summary_label.elide()
186 self.diff = DiffTextEdit(self, whitespace=False)
188 self.info_layout = QtGui.QVBoxLayout()
189 self.info_layout.setMargin(0)
190 self.info_layout.setSpacing(0)
191 self.info_layout.addWidget(self.author_label)
192 self.info_layout.addWidget(self.summary_label)
194 self.logo_layout = QtGui.QHBoxLayout()
195 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
196 self.logo_layout.setSpacing(defs.button_spacing)
197 self.logo_layout.addWidget(self.gravatar_label)
198 self.logo_layout.addLayout(self.info_layout)
200 self.main_layout = QtGui.QVBoxLayout()
201 self.main_layout.setMargin(0)
202 self.main_layout.setSpacing(defs.spacing)
203 self.main_layout.addLayout(self.logo_layout)
204 self.main_layout.addWidget(self.diff)
205 self.setLayout(self.main_layout)
207 sig = signals.commits_selected
208 notifier.add_observer(sig, self.commits_selected)
210 def commits_selected(self, commits):
211 if len(commits) != 1:
212 return
213 commit = commits[0]
214 sha1 = commit.sha1
215 self.diff.setText(gitcmds.diff_info(sha1))
217 email = commit.email or ''
218 summary = commit.summary or ''
219 author = commit.author or ''
221 template_args = {
222 'author': author,
223 'email': email,
224 'summary': summary
227 self.gravatar_label.get_email(email)
228 author_text = ("""%(author)s &lt;"""
229 """<a href="mailto:%(email)s">"""
230 """%(email)s</a>&gt;"""
231 % template_args)
233 author_template = '%(author)s <%(email)s>' % template_args
234 self.author_label.setTemplate(author_text, author_template)
235 self.summary_label.setPlainText(summary)
238 class ViewerMixin(object):
239 def __init__(self):
240 self.selected = None
241 self.clicked = None
242 self.menu_actions = self.context_menu_actions()
244 def selected_sha1(self):
245 item = self.selected_item()
246 if item is None:
247 return None
248 return item.commit.sha1
250 def diff_selected_this(self):
251 clicked_sha1 = self.clicked.sha1
252 selected_sha1 = self.selected.sha1
253 self.emit(SIGNAL('diff_commits'), selected_sha1, clicked_sha1)
255 def diff_this_selected(self):
256 clicked_sha1 = self.clicked.sha1
257 selected_sha1 = self.selected.sha1
258 self.emit(SIGNAL('diff_commits'), clicked_sha1, selected_sha1)
260 def cherry_pick(self):
261 sha1 = self.selected_sha1()
262 if sha1 is None:
263 return
264 cola.notifier().broadcast(signals.cherry_pick, [sha1])
266 def copy_to_clipboard(self):
267 sha1 = self.selected_sha1()
268 if sha1 is None:
269 return
270 qtutils.set_clipboard(sha1)
272 def create_branch(self):
273 sha1 = self.selected_sha1()
274 if sha1 is None:
275 return
276 create_new_branch(revision=sha1)
278 def create_tag(self):
279 sha1 = self.selected_sha1()
280 if sha1 is None:
281 return
282 create_tag(revision=sha1)
284 def create_tarball(self):
285 sha1 = self.selected_sha1()
286 if sha1 is None:
287 return
288 short_sha1 = sha1[:7]
289 GitArchiveDialog.save(sha1, short_sha1, self)
291 def save_blob_dialog(self):
292 sha1 = self.selected_sha1()
293 if sha1 is None:
294 return
295 return BrowseDialog.browse(sha1)
297 def context_menu_actions(self):
298 return {
299 'diff_this_selected':
300 qtutils.add_action(self, 'Diff this -> selected',
301 self.diff_this_selected),
302 'diff_selected_this':
303 qtutils.add_action(self, 'Diff selected -> this',
304 self.diff_selected_this),
305 'create_branch':
306 qtutils.add_action(self, 'Create Branch',
307 self.create_branch),
308 'create_patch':
309 qtutils.add_action(self, 'Create Patch',
310 self.create_patch),
311 'create_tag':
312 qtutils.add_action(self, 'Create Tag',
313 self.create_tag),
314 'create_tarball':
315 qtutils.add_action(self, 'Save As Tarball/Zip...',
316 self.create_tarball),
317 'cherry_pick':
318 qtutils.add_action(self, 'Cherry Pick',
319 self.cherry_pick),
320 'save_blob':
321 qtutils.add_action(self, 'Grab File...',
322 self.save_blob_dialog),
323 'copy':
324 qtutils.add_action(self, 'Copy SHA-1',
325 self.copy_to_clipboard,
326 QtGui.QKeySequence.Copy),
329 def update_menu_actions(self, event):
330 selected_items = self.selectedItems()
331 clicked = self.itemAt(event.pos())
332 if clicked is None:
333 self.clicked = None
334 else:
335 self.clicked = clicked.commit
337 has_single_selection = len(selected_items) == 1
338 has_selection = bool(selected_items)
339 can_diff = bool(clicked and has_single_selection and
340 clicked is not selected_items[0].commit)
342 if can_diff:
343 self.selected = selected_items[0].commit
344 else:
345 self.selected = None
347 self.menu_actions['diff_this_selected'].setEnabled(can_diff)
348 self.menu_actions['diff_selected_this'].setEnabled(can_diff)
350 self.menu_actions['create_branch'].setEnabled(has_single_selection)
351 self.menu_actions['create_tag'].setEnabled(has_single_selection)
353 self.menu_actions['cherry_pick'].setEnabled(has_single_selection)
354 self.menu_actions['create_patch'].setEnabled(has_selection)
355 self.menu_actions['create_tarball'].setEnabled(has_single_selection)
357 self.menu_actions['save_blob'].setEnabled(has_single_selection)
358 self.menu_actions['copy'].setEnabled(has_single_selection)
360 def context_menu_event(self, event):
361 self.update_menu_actions(event)
362 menu = QtGui.QMenu(self)
363 menu.addAction(self.menu_actions['diff_this_selected'])
364 menu.addAction(self.menu_actions['diff_selected_this'])
365 menu.addSeparator()
366 menu.addAction(self.menu_actions['create_branch'])
367 menu.addAction(self.menu_actions['create_tag'])
368 menu.addSeparator()
369 menu.addAction(self.menu_actions['cherry_pick'])
370 menu.addAction(self.menu_actions['create_patch'])
371 menu.addAction(self.menu_actions['create_tarball'])
372 menu.addSeparator()
373 menu.addAction(self.menu_actions['save_blob'])
374 menu.addAction(self.menu_actions['copy'])
375 menu.exec_(self.mapToGlobal(event.pos()))
378 class CommitTreeWidgetItem(QtGui.QTreeWidgetItem):
379 def __init__(self, commit, parent=None):
380 QtGui.QListWidgetItem.__init__(self, parent)
381 self.commit = commit
382 self.setText(0, commit.summary)
383 self.setText(1, commit.author)
384 self.setText(2, commit.authdate)
387 class CommitTreeWidget(QtGui.QTreeWidget, ViewerMixin):
388 def __init__(self, notifier, parent):
389 QtGui.QTreeWidget.__init__(self, parent)
390 ViewerMixin.__init__(self)
392 self.setSelectionMode(self.ContiguousSelection)
393 self.setUniformRowHeights(True)
394 self.setAllColumnsShowFocus(True)
395 self.setAlternatingRowColors(True)
396 self.setRootIsDecorated(False)
397 self.setHeaderLabels(['Summary', 'Author', 'Age'])
399 self.sha1map = {}
400 self.notifier = notifier
401 self.selecting = False
402 self.commits = []
404 self.action_up = qtutils.add_action(self, 'Go Up', self.go_up,
405 Qt.Key_K)
407 self.action_down = qtutils.add_action(self, 'Go Down', self.go_down,
408 Qt.Key_J)
410 sig = signals.commits_selected
411 notifier.add_observer(sig, self.commits_selected)
413 self.connect(self, SIGNAL('itemSelectionChanged()'),
414 self.selection_changed)
416 def contextMenuEvent(self, event):
417 self.context_menu_event(event)
419 def mousePressEvent(self, event):
420 if event.button() == Qt.RightButton:
421 event.accept()
422 return
423 QtGui.QTreeWidget.mousePressEvent(self, event)
425 def go_up(self):
426 self.goto(self.itemAbove)
428 def go_down(self):
429 self.goto(self.itemBelow)
431 def goto(self, finder):
432 items = self.selectedItems()
433 item = items and items[0] or None
434 if item is None:
435 return
436 found = finder(item)
437 if found:
438 self.select([found.commit.sha1], block_signals=False)
440 def set_selecting(self, selecting):
441 self.selecting = selecting
443 def selection_changed(self):
444 items = self.selectedItems()
445 if not items:
446 return
447 self.set_selecting(True)
448 sig = signals.commits_selected
449 self.notifier.notify_observers(sig, [i.commit for i in items])
450 self.set_selecting(False)
452 def commits_selected(self, commits):
453 if self.selecting:
454 return
455 self.select([commit.sha1 for commit in commits])
457 def select(self, sha1s, block_signals=True):
458 self.clearSelection()
459 for sha1 in sha1s:
460 try:
461 item = self.sha1map[sha1]
462 except KeyError:
463 continue
464 block = self.blockSignals(block_signals)
465 self.scrollToItem(item)
466 item.setSelected(True)
467 self.blockSignals(block)
469 def adjust_columns(self):
470 width = self.width()-20
471 zero = width*2/3
472 onetwo = width/6
473 self.setColumnWidth(0, zero)
474 self.setColumnWidth(1, onetwo)
475 self.setColumnWidth(2, onetwo)
477 def clear(self):
478 QtGui.QTreeWidget.clear(self)
479 self.sha1map.clear()
480 self.commits = []
482 def add_commits(self, commits):
483 self.commits.extend(commits)
484 items = []
485 for c in reversed(commits):
486 item = CommitTreeWidgetItem(c)
487 items.append(item)
488 self.sha1map[c.sha1] = item
489 for tag in c.tags:
490 self.sha1map[tag] = item
491 self.insertTopLevelItems(0, items)
493 def create_patch(self):
494 items = self.selectedItems()
495 if not items:
496 return
497 items.reverse()
498 sha1s = [item.commit.sha1 for item in items]
499 all_sha1s = [c.sha1 for c in self.commits]
500 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
503 class DAGView(Widget):
504 """The git-dag widget."""
506 def __init__(self, model, dag, parent=None, args=None):
507 Widget.__init__(self, parent)
509 self.setAttribute(Qt.WA_MacMetalStyle)
510 self.setMinimumSize(1, 1)
512 # change when widgets are added/removed
513 self.widget_version = 1
514 self.model = model
515 self.dag = dag
517 self.commits = {}
518 self.commit_list = []
520 self.old_count = None
521 self.old_ref = None
523 self.revtext = completion.GitLogLineEdit(parent=self)
525 self.maxresults = QtGui.QSpinBox()
526 self.maxresults.setMinimum(1)
527 self.maxresults.setMaximum(99999)
528 self.maxresults.setPrefix('git log -')
529 self.maxresults.setSuffix('')
531 self.displaybutton = QtGui.QPushButton()
532 self.displaybutton.setText('Display')
534 self.zoom_in = QtGui.QPushButton()
535 self.zoom_in.setIcon(qtutils.theme_icon('zoom-in.png'))
536 self.zoom_in.setFlat(True)
538 self.zoom_out = QtGui.QPushButton()
539 self.zoom_out.setIcon(qtutils.theme_icon('zoom-out.png'))
540 self.zoom_out.setFlat(True)
542 self.top_layout = QtGui.QHBoxLayout()
543 self.top_layout.setMargin(defs.margin)
544 self.top_layout.setSpacing(defs.button_spacing)
546 self.top_layout.addWidget(self.maxresults)
547 self.top_layout.addWidget(self.revtext)
548 self.top_layout.addWidget(self.displaybutton)
549 self.top_layout.addStretch()
550 self.top_layout.addWidget(self.zoom_out)
551 self.top_layout.addWidget(self.zoom_in)
553 self.notifier = notifier = observable.Observable()
554 self.notifier.refs_updated = refs_updated = 'refs_updated'
555 self.notifier.add_observer(refs_updated, self.display)
557 self.graphview = GraphView(notifier, self)
558 self.treewidget = CommitTreeWidget(notifier, self)
559 self.diffwidget = DiffWidget(notifier, self)
561 for signal in (archive,):
562 qtutils.relay_signal(self, self.graphview, SIGNAL(signal))
563 qtutils.relay_signal(self, self.treewidget, SIGNAL(signal))
565 self.splitter = QtGui.QSplitter()
566 self.splitter.setOrientation(Qt.Horizontal)
567 self.splitter.setChildrenCollapsible(True)
568 self.splitter.setHandleWidth(defs.handle_width)
570 self.left_splitter = QtGui.QSplitter()
571 self.left_splitter.setOrientation(Qt.Vertical)
572 self.left_splitter.setChildrenCollapsible(True)
573 self.left_splitter.setHandleWidth(defs.handle_width)
574 self.left_splitter.setStretchFactor(0, 1)
575 self.left_splitter.setStretchFactor(1, 1)
576 self.left_splitter.insertWidget(0, self.treewidget)
577 self.left_splitter.insertWidget(1, self.diffwidget)
579 self.splitter.insertWidget(0, self.left_splitter)
580 self.splitter.insertWidget(1, self.graphview)
582 self.splitter.setStretchFactor(0, 1)
583 self.splitter.setStretchFactor(1, 1)
585 self.main_layout = QtGui.QVBoxLayout()
586 self.main_layout.setMargin(0)
587 self.main_layout.setSpacing(0)
588 self.main_layout.addLayout(self.top_layout)
589 self.main_layout.addWidget(self.splitter)
590 self.setLayout(self.main_layout)
592 # Also re-loads dag.* from the saved state
593 if not qtutils.apply_state(self):
594 self.resize_to_desktop()
596 # Update fields affected by model
597 self.revtext.setText(dag.ref)
598 self.maxresults.setValue(dag.count)
599 self.update_window_title()
601 self.thread = ReaderThread(dag, self)
603 self.thread.connect(self.thread, self.thread.commits_ready,
604 self.add_commits)
606 self.thread.connect(self.thread, self.thread.done,
607 self.thread_done)
609 self.connect(self.splitter, SIGNAL('splitterMoved(int,int)'),
610 self.splitter_moved)
612 self.connect(self.zoom_in, SIGNAL('pressed()'),
613 self.graphview.zoom_in)
615 self.connect(self.zoom_out, SIGNAL('pressed()'),
616 self.graphview.zoom_out)
618 self.connect(self.treewidget, SIGNAL('diff_commits'),
619 self.diff_commits)
621 self.connect(self.graphview, SIGNAL('diff_commits'),
622 self.diff_commits)
624 self.connect(self.maxresults, SIGNAL('editingFinished()'),
625 self.display)
627 self.connect(self.displaybutton, SIGNAL('pressed()'),
628 self.display)
630 self.connect(self.revtext, SIGNAL('ref_changed'),
631 self.display)
633 self.connect(self.revtext, SIGNAL('textChanged(QString)'),
634 self.text_changed)
636 self.connect(self.revtext, SIGNAL('returnPressed()'),
637 self.display)
639 # The model is updated in another thread so use
640 # signals/slots to bring control back to the main GUI thread
641 self.model.add_observer(self.model.message_updated,
642 self.emit_model_updated)
644 self.connect(self, SIGNAL('model_updated'),
645 self.model_updated)
647 qtutils.add_close_action(self)
649 def text_changed(self, txt):
650 self.dag.ref = unicode(txt)
651 self.update_window_title()
653 def update_window_title(self):
654 project = self.model.project
655 if self.dag.ref:
656 self.setWindowTitle('%s: %s - DAG' % (project, self.dag.ref))
657 else:
658 self.setWindowTitle(project + ' - DAG')
660 def export_state(self):
661 state = Widget.export_state(self)
662 state['count'] = self.dag.count
663 return state
665 def apply_state(self, state):
666 Widget.apply_state(self, state)
667 try:
668 count = state['count']
669 except KeyError:
670 pass
671 else:
672 if not self.dag.overridden('count'):
673 self.dag.set_count(count)
675 def emit_model_updated(self):
676 self.emit(SIGNAL('model_updated'))
678 def model_updated(self):
679 if self.dag.ref:
680 self.revtext.update_matches()
681 return
682 if not self.model.currentbranch:
683 return
684 self.revtext.setText(self.model.currentbranch)
685 self.display()
687 def display(self):
688 new_ref = unicode(self.revtext.text())
689 if not new_ref:
690 return
691 new_count = self.maxresults.value()
692 old_ref = self.old_ref
693 old_count = self.old_count
694 if old_ref == new_ref and old_count == new_count:
695 return
697 self.old_ref = new_ref
698 self.old_count = new_count
700 self.setEnabled(False)
701 self.thread.stop()
702 self.clear()
703 self.dag.set_ref(new_ref)
704 self.dag.set_count(self.maxresults.value())
705 self.thread.start()
707 def show(self):
708 Widget.show(self)
709 self.splitter.setSizes([self.width()/2, self.width()/2])
710 self.left_splitter.setSizes([self.height()/4, self.height()*3/4])
711 self.treewidget.adjust_columns()
713 def resizeEvent(self, e):
714 Widget.resizeEvent(self, e)
715 self.treewidget.adjust_columns()
717 def splitter_moved(self, pos, idx):
718 self.treewidget.adjust_columns()
720 def clear(self):
721 self.graphview.clear()
722 self.treewidget.clear()
723 self.commits.clear()
724 self.commit_list = []
726 def add_commits(self, commits):
727 self.commit_list.extend(commits)
728 # Keep track of commits
729 for commit_obj in commits:
730 self.commits[commit_obj.sha1] = commit_obj
731 for tag in commit_obj.tags:
732 self.commits[tag] = commit_obj
733 self.graphview.add_commits(commits)
734 self.treewidget.add_commits(commits)
736 def thread_done(self):
737 self.setEnabled(True)
738 self.graphview.setFocus()
739 try:
740 commit_obj = self.commit_list[-1]
741 except IndexError:
742 return
743 sig = signals.commits_selected
744 self.notifier.notify_observers(sig, [commit_obj])
745 self.graphview.update_scene_rect()
746 self.graphview.set_initial_view()
748 def closeEvent(self, event):
749 self.revtext.close_popup()
750 self.thread.stop()
751 qtutils.save_state(self)
752 return Widget.closeEvent(self, event)
754 def resize_to_desktop(self):
755 desktop = QtGui.QApplication.instance().desktop()
756 width = desktop.width()
757 height = desktop.height()
758 self.resize(width, height)
760 def diff_commits(self, a, b):
761 paths = self.dag.paths()
762 if paths:
763 difftool.launch([a, b, '--'] + paths)
764 else:
765 difftool.diff_commits(self, a, b)
768 class ReaderThread(QtCore.QThread):
770 commits_ready = SIGNAL('commits_ready')
771 done = SIGNAL('done')
773 def __init__(self, dag, parent):
774 QtCore.QThread.__init__(self, parent)
775 self.dag = dag
776 self._abort = False
777 self._stop = False
778 self._mutex = QtCore.QMutex()
779 self._condition = QtCore.QWaitCondition()
781 def run(self):
782 repo = RepoReader(self.dag)
783 repo.reset()
784 commits = []
785 for c in repo:
786 self._mutex.lock()
787 if self._stop:
788 self._condition.wait(self._mutex)
789 self._mutex.unlock()
790 if self._abort:
791 repo.reset()
792 return
793 commits.append(c)
794 if len(commits) >= 512:
795 self.emit(self.commits_ready, commits)
796 commits = []
798 if commits:
799 self.emit(self.commits_ready, commits)
800 self.emit(self.done)
802 def start(self):
803 self._abort = False
804 self._stop = False
805 QtCore.QThread.start(self)
807 def pause(self):
808 self._mutex.lock()
809 self._stop = True
810 self._mutex.unlock()
812 def resume(self):
813 self._mutex.lock()
814 self._stop = False
815 self._mutex.unlock()
816 self._condition.wakeOne()
818 def stop(self):
819 self._abort = True
820 self.wait()
823 class Cache(object):
824 pass
827 class Edge(QtGui.QGraphicsItem):
828 item_type = QtGui.QGraphicsItem.UserType + 1
830 def __init__(self, source, dest):
832 QtGui.QGraphicsItem.__init__(self)
834 self.setAcceptedMouseButtons(Qt.NoButton)
835 self.source = source
836 self.dest = dest
837 self.setZValue(-2)
839 dest_pt = Commit.item_bbox.center()
841 self.source_pt = self.mapFromItem(self.source, dest_pt)
842 self.dest_pt = self.mapFromItem(self.dest, dest_pt)
843 self.line = QtCore.QLineF(self.source_pt, self.dest_pt)
845 width = self.dest_pt.x() - self.source_pt.x()
846 height = self.dest_pt.y() - self.source_pt.y()
847 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height))
848 self.bound = rect.normalized()
850 # Choose a new color for new branch edges
851 if self.source.x() < self.dest.x():
852 color = EdgeColor.next()
853 line = Qt.SolidLine
854 elif self.source.x() != self.dest.x():
855 color = EdgeColor.current()
856 line = Qt.SolidLine
857 else:
858 color = EdgeColor.current()
859 line = Qt.DotLine
861 self.pen = QtGui.QPen(color, 1.0, line, Qt.SquareCap, Qt.BevelJoin)
863 def type(self):
864 return self.item_type
866 def boundingRect(self):
867 return self.bound
869 def paint(self, painter, option, widget):
870 # Draw the line
871 painter.setPen(self.pen)
872 painter.drawLine(self.line)
874 class EdgeColor(object):
875 """An edge color factory"""
877 current_color_index = 0
878 # TODO: Make this configurable, e.g.
879 # colors = [
880 # QtGui.QColor.fromRgb(0xff, 0x30, 0x30), # red
881 # QtGui.QColor.fromRgb(0x30, 0xff, 0x30), # green
882 # QtGui.QColor.fromRgb(0x30, 0x30, 0xff), # blue
883 # QtGui.QColor.fromRgb(0xff, 0xff, 0x30), # yellow
885 colors = [
886 QtGui.QColor.fromRgb(0xff, 0xff, 0xff), # white
887 QtGui.QColor.fromRgb(0x30, 0x80, 0xff), # blue
888 QtGui.QColor.fromRgb(0x80, 0x80, 0xff), # indigo
891 @classmethod
892 def next(cls):
893 cls.current_color_index += 1
894 cls.current_color_index %= len(cls.colors)
895 return cls.colors[cls.current_color_index]
897 @classmethod
898 def current(cls):
899 return cls.colors[cls.current_color_index]
901 class Commit(QtGui.QGraphicsItem):
902 item_type = QtGui.QGraphicsItem.UserType + 2
903 width = 46.
904 height = 24.
906 item_shape = QtGui.QPainterPath()
907 item_shape.addRect(width/-2., height/-2., width, height)
908 item_bbox = item_shape.boundingRect()
910 inner_rect = QtGui.QPainterPath()
911 inner_rect.addRect(width/-2.+2., height/-2.+2, width-4., height-4.)
912 inner_rect = inner_rect.boundingRect()
914 text_options = QtGui.QTextOption()
915 text_options.setAlignment(Qt.AlignCenter)
917 commit_color = QtGui.QColor.fromRgb(0x0, 0x80, 0xff)
918 commit_selected_color = QtGui.QColor.fromRgb(0xff, 0x8a, 0x22)
919 merge_color = QtGui.QColor.fromRgb(0xff, 0xff, 0xff)
921 outline_color = commit_color.darker()
922 selected_outline_color = commit_selected_color.darker()
924 commit_pen = QtGui.QPen()
925 commit_pen.setWidth(1.0)
926 commit_pen.setColor(outline_color)
928 def __init__(self, commit,
929 notifier,
930 selectable=QtGui.QGraphicsItem.ItemIsSelectable,
931 cursor=Qt.PointingHandCursor,
932 xpos=width/2. + 1.,
933 cached_commit_color=commit_color,
934 cached_commit_selected_color=commit_selected_color,
935 cached_merge_color=merge_color):
937 QtGui.QGraphicsItem.__init__(self)
939 self.setZValue(0)
940 self.setFlag(selectable)
941 self.setCursor(cursor)
943 self.commit = commit
944 self.notifier = notifier
946 if commit.tags:
947 self.label = label = Label(commit)
948 label.setParentItem(self)
949 label.setPos(xpos, 0.)
950 else:
951 self.label = None
953 if len(commit.parents) > 1:
954 self.brush = cached_merge_color
955 self.text_pen = Qt.black
956 else:
957 self.brush = cached_commit_color
958 self.text_pen = Qt.white
959 self.sha1_text = commit.sha1[:8]
961 self.pressed = False
962 self.dragged = False
965 # Overridden Qt methods
968 def blockSignals(self, blocked):
969 self.notifier.notification_enabled = not blocked
971 def itemChange(self, change, value):
972 if change == QtGui.QGraphicsItem.ItemSelectedHasChanged:
973 # Broadcast selection to other widgets
974 selected_items = self.scene().selectedItems()
975 commits = [item.commit for item in selected_items]
976 self.scene().parent().set_selecting(True)
977 sig = signals.commits_selected
978 self.notifier.notify_observers(sig, commits)
979 self.scene().parent().set_selecting(False)
981 # Cache the pen for use in paint()
982 if value.toPyObject():
983 self.brush = self.commit_selected_color
984 color = self.selected_outline_color
985 self.text_pen = Qt.white
986 else:
987 if len(self.commit.parents) > 1:
988 self.brush = self.merge_color
989 self.text_pen = Qt.black
990 else:
991 self.brush = self.commit_color
992 self.text_pen = Qt.white
993 color = self.outline_color
994 commit_pen = QtGui.QPen()
995 commit_pen.setWidth(1.0)
996 commit_pen.setColor(color)
997 self.commit_pen = commit_pen
999 return QtGui.QGraphicsItem.itemChange(self, change, value)
1001 def type(self):
1002 return self.item_type
1004 def boundingRect(self, rect=item_bbox):
1005 return rect
1007 def shape(self):
1008 return self.item_shape
1010 def paint(self, painter, option, widget,
1011 inner=inner_rect,
1012 text_opts=text_options,
1013 cache=Cache):
1015 # Do not draw outside the exposed rect
1016 painter.setClipRect(option.exposedRect)
1018 # Draw ellipse
1019 painter.setPen(self.commit_pen)
1020 painter.setBrush(self.brush)
1021 painter.drawEllipse(inner)
1023 # Draw text
1024 try:
1025 font = cache.font
1026 except AttributeError:
1027 font = cache.font = painter.font()
1028 font.setPointSize(5)
1029 painter.setFont(font)
1030 painter.setPen(self.text_pen)
1031 painter.drawText(inner, self.sha1_text, text_opts)
1033 def mousePressEvent(self, event):
1034 QtGui.QGraphicsItem.mousePressEvent(self, event)
1035 self.pressed = True
1036 self.selected = self.isSelected()
1038 def mouseMoveEvent(self, event):
1039 if self.pressed:
1040 self.dragged = True
1041 QtGui.QGraphicsItem.mouseMoveEvent(self, event)
1043 def mouseReleaseEvent(self, event):
1044 QtGui.QGraphicsItem.mouseReleaseEvent(self, event)
1045 if (not self.dragged and
1046 self.selected and
1047 event.button() == Qt.LeftButton):
1048 return
1049 self.pressed = False
1050 self.dragged = False
1053 class Label(QtGui.QGraphicsItem):
1054 item_type = QtGui.QGraphicsItem.UserType + 3
1056 width = 72
1057 height = 18
1059 item_shape = QtGui.QPainterPath()
1060 item_shape.addRect(0, 0, width, height)
1061 item_bbox = item_shape.boundingRect()
1063 text_options = QtGui.QTextOption()
1064 text_options.setAlignment(Qt.AlignCenter)
1065 text_options.setAlignment(Qt.AlignVCenter)
1067 def __init__(self, commit,
1068 other_color=QtGui.QColor.fromRgb(255, 255, 64),
1069 head_color=QtGui.QColor.fromRgb(64, 255, 64)):
1070 QtGui.QGraphicsItem.__init__(self)
1071 self.setZValue(-1)
1073 # Starts with enough space for two tags. Any more and the commit
1074 # needs to be taller to accomodate.
1075 self.commit = commit
1076 height = len(commit.tags) * self.height/2. + 4. # +6 padding
1078 self.label_box = QtCore.QRectF(0., -height/2., self.width, height)
1079 self.text_box = QtCore.QRectF(2., -height/2., self.width-4., height)
1080 self.tag_text = '\n'.join(commit.tags)
1082 if 'HEAD' in commit.tags:
1083 self.color = head_color
1084 else:
1085 self.color = other_color
1087 self.pen = QtGui.QPen()
1088 self.pen.setColor(self.color.darker())
1089 self.pen.setWidth(1.0)
1091 def type(self):
1092 return self.item_type
1094 def boundingRect(self, rect=item_bbox):
1095 return rect
1097 def shape(self):
1098 return self.item_shape
1100 def paint(self, painter, option, widget,
1101 text_opts=text_options,
1102 black=Qt.black,
1103 cache=Cache):
1104 # Draw tags
1105 painter.setBrush(self.color)
1106 painter.setPen(self.pen)
1107 painter.drawRoundedRect(self.label_box, 4, 4)
1108 try:
1109 font = cache.font
1110 except AttributeError:
1111 font = cache.font = painter.font()
1112 font.setPointSize(5)
1113 painter.setFont(font)
1114 painter.setPen(black)
1115 painter.drawText(self.text_box, self.tag_text, text_opts)
1118 class GraphView(QtGui.QGraphicsView, ViewerMixin):
1120 x_off = 132
1121 y_off = 32
1122 x_max = 0
1123 y_min = 0
1125 def __init__(self, notifier, parent):
1126 QtGui.QGraphicsView.__init__(self, parent)
1127 ViewerMixin.__init__(self)
1128 try:
1129 from PyQt4 import QtOpenGL
1130 glformat = QtOpenGL.QGLFormat(QtOpenGL.QGL.SampleBuffers)
1131 self.glwidget = QtOpenGL.QGLWidget(glformat)
1132 self.setViewport(self.glwidget)
1133 except:
1134 pass
1137 self.selection_list = []
1138 self.notifier = notifier
1139 self.commits = []
1140 self.items = {}
1141 self.saved_matrix = QtGui.QMatrix(self.matrix())
1143 self.x_offsets = collections.defaultdict(int)
1145 self.is_panning = False
1146 self.pressed = False
1147 self.selecting = False
1148 self.last_mouse = [0, 0]
1149 self.zoom = 2
1150 self.setDragMode(self.RubberBandDrag)
1152 scene = QtGui.QGraphicsScene(self)
1153 scene.setItemIndexMethod(QtGui.QGraphicsScene.NoIndex)
1154 self.setScene(scene)
1156 self.setRenderHint(QtGui.QPainter.Antialiasing)
1157 self.setOptimizationFlag(self.DontAdjustForAntialiasing, True)
1158 self.setViewportUpdateMode(self.SmartViewportUpdate)
1159 self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
1160 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1161 self.setResizeAnchor(QtGui.QGraphicsView.NoAnchor)
1162 self.setBackgroundBrush(QtGui.QColor.fromRgb(0, 0, 0))
1164 qtutils.add_action(self, 'Zoom In',
1165 self.zoom_in, Qt.Key_Plus, Qt.Key_Equal)
1167 qtutils.add_action(self, 'Zoom Out',
1168 self.zoom_out, Qt.Key_Minus)
1170 qtutils.add_action(self, 'Zoom to Fit',
1171 self.fit_view_to_selection, Qt.Key_F)
1173 qtutils.add_action(self, 'Select Parent',
1174 self.select_parent, 'Shift+J')
1176 qtutils.add_action(self, 'Select Oldest Parent',
1177 self.select_oldest_parent, Qt.Key_J)
1179 qtutils.add_action(self, 'Select Child',
1180 self.select_child, 'Shift+K')
1182 qtutils.add_action(self, 'Select Newest Child',
1183 self.select_newest_child, Qt.Key_K)
1185 sig = signals.commits_selected
1186 notifier.add_observer(sig, self.commits_selected)
1188 def clear(self):
1189 self.scene().clear()
1190 self.selection_list = []
1191 self.items.clear()
1192 self.x_offsets.clear()
1193 self.x_max = 0
1194 self.y_min = 0
1195 self.commits = []
1197 def zoom_in(self):
1198 self.scale_view(1.5)
1200 def zoom_out(self):
1201 self.scale_view(1.0/1.5)
1203 def commits_selected(self, commits):
1204 if self.selecting:
1205 return
1206 self.select([commit.sha1 for commit in commits])
1208 def contextMenuEvent(self, event):
1209 self.context_menu_event(event)
1211 def select(self, sha1s):
1212 """Select the item for the SHA-1"""
1213 self.scene().clearSelection()
1214 for sha1 in sha1s:
1215 try:
1216 item = self.items[sha1]
1217 except KeyError:
1218 continue
1219 item.blockSignals(True)
1220 item.setSelected(True)
1221 item.blockSignals(False)
1222 item_rect = item.sceneTransform().mapRect(item.boundingRect())
1223 self.ensureVisible(item_rect)
1225 def selected_item(self):
1226 """Return the currently selected item"""
1227 selected_items = self.selectedItems()
1228 if not selected_items:
1229 return None
1230 return selected_items[0]
1232 def selectedItems(self):
1233 """Return the currently selected items"""
1234 return self.scene().selectedItems()
1236 def get_item_by_generation(self, commits, criteria_fn):
1237 """Return the item for the commit matching criteria"""
1238 if not commits:
1239 return None
1240 generation = None
1241 for commit in commits:
1242 if (generation is None or
1243 criteria_fn(generation, commit.generation)):
1244 sha1 = commit.sha1
1245 generation = commit.generation
1246 try:
1247 return self.items[sha1]
1248 except KeyError:
1249 return None
1251 def oldest_item(self, commits):
1252 """Return the item for the commit with the oldest generation number"""
1253 return self.get_item_by_generation(commits, lambda a, b: a > b)
1255 def newest_item(self, commits):
1256 """Return the item for the commit with the newest generation number"""
1257 return self.get_item_by_generation(commits, lambda a, b: a < b)
1259 def create_patch(self):
1260 items = self.selectedItems()
1261 if not items:
1262 return
1263 selected_commits = self.sort_by_generation([n.commit for n in items])
1264 sha1s = [c.sha1 for c in selected_commits]
1265 all_sha1s = [c.sha1 for c in self.commits]
1266 cola.notifier().broadcast(signals.format_patch, sha1s, all_sha1s)
1268 def select_parent(self):
1269 """Select the parent with the newest generation number"""
1270 selected_item = self.selected_item()
1271 if selected_item is None:
1272 return
1273 parent_item = self.newest_item(selected_item.commit.parents)
1274 if parent_item is None:
1275 return
1276 selected_item.setSelected(False)
1277 parent_item.setSelected(True)
1278 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1280 def select_oldest_parent(self):
1281 """Select the parent with the oldest generation number"""
1282 selected_item = self.selected_item()
1283 if selected_item is None:
1284 return
1285 parent_item = self.oldest_item(selected_item.commit.parents)
1286 if parent_item is None:
1287 return
1288 selected_item.setSelected(False)
1289 parent_item.setSelected(True)
1290 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect()))
1292 def select_child(self):
1293 """Select the child with the oldest generation number"""
1294 selected_item = self.selected_item()
1295 if selected_item is None:
1296 return
1297 child_item = self.oldest_item(selected_item.commit.children)
1298 if child_item is None:
1299 return
1300 selected_item.setSelected(False)
1301 child_item.setSelected(True)
1302 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1304 def select_newest_child(self):
1305 """Select the Nth child with the newest generation number (N > 1)"""
1306 selected_item = self.selected_item()
1307 if selected_item is None:
1308 return
1309 if len(selected_item.commit.children) > 1:
1310 children = selected_item.commit.children[1:]
1311 else:
1312 children = selected_item.commit.children
1313 child_item = self.newest_item(children)
1314 if child_item is None:
1315 return
1316 selected_item.setSelected(False)
1317 child_item.setSelected(True)
1318 self.ensureVisible(child_item.mapRectToScene(child_item.boundingRect()))
1320 def set_initial_view(self):
1321 self_commits = self.commits
1322 self_items = self.items
1324 commits = self_commits[-8:]
1325 items = [self_items[c.sha1] for c in commits]
1326 self.fit_view_to_items(items)
1328 def fit_view_to_selection(self):
1329 """Fit selected items into the viewport"""
1331 items = self.scene().selectedItems()
1332 self.fit_view_to_items(items)
1334 def fit_view_to_items(self, items):
1335 if not items:
1336 rect = self.scene().itemsBoundingRect()
1337 else:
1338 x_min = sys.maxint
1339 y_min = sys.maxint
1340 x_max = -sys.maxint
1341 ymax = -sys.maxint
1342 for item in items:
1343 pos = item.pos()
1344 item_rect = item.boundingRect()
1345 x_off = item_rect.width()
1346 y_off = item_rect.height()
1347 x_min = min(x_min, pos.x())
1348 y_min = min(y_min, pos.y()-y_off)
1349 x_max = max(x_max, pos.x()+x_off)
1350 ymax = max(ymax, pos.y())
1351 rect = QtCore.QRectF(x_min, y_min, x_max-x_min, ymax-y_min)
1352 x_adjust = Commit.width
1353 y_adjust = Commit.height
1354 rect.setX(rect.x() - x_adjust)
1355 rect.setY(rect.y())
1356 rect.setHeight(rect.height() + y_adjust)
1357 rect.setWidth(rect.width() + x_adjust)
1358 self.fitInView(rect, Qt.KeepAspectRatio)
1359 self.scene().invalidate()
1361 def save_selection(self, event):
1362 if event.button() != Qt.LeftButton:
1363 return
1364 elif Qt.ShiftModifier != event.modifiers():
1365 return
1366 self.selection_list = self.selectedItems()
1368 def restore_selection(self, event):
1369 if Qt.ShiftModifier != event.modifiers():
1370 return
1371 for item in self.selection_list:
1372 item.setSelected(True)
1374 def handle_event(self, event_handler, event):
1375 self.update()
1376 self.save_selection(event)
1377 event_handler(self, event)
1378 self.restore_selection(event)
1380 def mousePressEvent(self, event):
1381 if event.button() == Qt.MidButton:
1382 pos = event.pos()
1383 self.mouse_start = [pos.x(), pos.y()]
1384 self.saved_matrix = QtGui.QMatrix(self.matrix())
1385 self.is_panning = True
1386 return
1387 if event.button() == Qt.RightButton:
1388 event.ignore()
1389 return
1390 if event.button() == Qt.LeftButton:
1391 self.pressed = True
1392 self.handle_event(QtGui.QGraphicsView.mousePressEvent, event)
1394 def mouseMoveEvent(self, event):
1395 pos = self.mapToScene(event.pos())
1396 if self.is_panning:
1397 self.pan(event)
1398 return
1399 self.last_mouse[0] = pos.x()
1400 self.last_mouse[1] = pos.y()
1401 self.handle_event(QtGui.QGraphicsView.mouseMoveEvent, event)
1403 def set_selecting(self, selecting):
1404 self.selecting = selecting
1406 def mouseReleaseEvent(self, event):
1407 self.pressed = False
1408 if event.button() == Qt.MidButton:
1409 self.is_panning = False
1410 return
1411 self.handle_event(QtGui.QGraphicsView.mouseReleaseEvent, event)
1412 self.selection_list = []
1414 def pan(self, event):
1415 pos = event.pos()
1416 dx = pos.x() - self.mouse_start[0]
1417 dy = pos.y() - self.mouse_start[1]
1419 if dx == 0 and dy == 0:
1420 return
1422 rect = QtCore.QRect(0, 0, abs(dx), abs(dy))
1423 delta = self.mapToScene(rect).boundingRect()
1425 tx = delta.width()
1426 if dx < 0.0:
1427 tx = -tx
1429 ty = delta.height()
1430 if dy < 0.0:
1431 ty = -ty
1433 matrix = QtGui.QMatrix(self.saved_matrix).translate(tx, ty)
1434 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1435 self.setMatrix(matrix)
1437 def wheelEvent(self, event):
1438 """Handle Qt mouse wheel events."""
1439 if event.modifiers() == Qt.ControlModifier:
1440 self.wheel_zoom(event)
1441 else:
1442 self.wheel_pan(event)
1444 def wheel_zoom(self, event):
1445 """Handle mouse wheel zooming."""
1446 zoom = math.pow(2.0, event.delta() / 512.0)
1447 factor = (self.matrix()
1448 .scale(zoom, zoom)
1449 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
1450 .width())
1451 if factor < 0.014 or factor > 42.0:
1452 return
1453 self.setTransformationAnchor(QtGui.QGraphicsView.AnchorUnderMouse)
1454 self.zoom = zoom
1455 self.scale(zoom, zoom)
1457 def wheel_pan(self, event):
1458 """Handle mouse wheel panning."""
1460 if event.delta() < 0:
1461 s = -133.
1462 else:
1463 s = 133.
1464 pan_rect = QtCore.QRectF(0.0, 0.0, 1.0, 1.0)
1465 factor = 1.0 / self.matrix().mapRect(pan_rect).width()
1467 if event.orientation() == Qt.Vertical:
1468 matrix = self.matrix().translate(0, s * factor)
1469 else:
1470 matrix = self.matrix().translate(s * factor, 0)
1471 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1472 self.setMatrix(matrix)
1474 def scale_view(self, scale):
1475 factor = (self.matrix().scale(scale, scale)
1476 .mapRect(QtCore.QRectF(0, 0, 1, 1))
1477 .width())
1478 if factor < 0.07 or factor > 100:
1479 return
1480 self.zoom = scale
1482 adjust_scrollbars = True
1483 scrollbar = self.verticalScrollBar()
1484 if scrollbar:
1485 value = scrollbar.value()
1486 min_ = scrollbar.minimum()
1487 max_ = scrollbar.maximum()
1488 range_ = max_ - min_
1489 distance = value - min_
1490 nonzero_range = float(range_) != 0.0
1491 if nonzero_range:
1492 scrolloffset = distance/float(range_)
1493 else:
1494 adjust_scrollbars = False
1496 self.setTransformationAnchor(QtGui.QGraphicsView.NoAnchor)
1497 self.scale(scale, scale)
1499 scrollbar = self.verticalScrollBar()
1500 if scrollbar and adjust_scrollbars:
1501 min_ = scrollbar.minimum()
1502 max_ = scrollbar.maximum()
1503 range_ = max_ - min_
1504 value = min_ + int(float(range_) * scrolloffset)
1505 scrollbar.setValue(value)
1507 def add_commits(self, commits):
1508 """Traverse commits and add them to the view."""
1509 self.commits.extend(commits)
1510 scene = self.scene()
1511 for commit in commits:
1512 item = Commit(commit, self.notifier)
1513 self.items[commit.sha1] = item
1514 for ref in commit.tags:
1515 self.items[ref] = item
1516 scene.addItem(item)
1518 self.layout_commits(commits)
1519 self.link(commits)
1521 def link(self, commits):
1522 """Create edges linking commits with their parents"""
1523 scene = self.scene()
1524 for commit in commits:
1525 try:
1526 commit_item = self.items[commit.sha1]
1527 except KeyError:
1528 # TODO - Handle truncated history viewing
1529 pass
1530 for parent in reversed(commit.parents):
1531 try:
1532 parent_item = self.items[parent.sha1]
1533 except KeyError:
1534 # TODO - Handle truncated history viewing
1535 continue
1536 edge = Edge(parent_item, commit_item)
1537 scene.addItem(edge)
1539 def layout_commits(self, nodes):
1540 positions = self.position_nodes(nodes)
1541 for sha1, (x, y) in positions.items():
1542 item = self.items[sha1]
1543 item.setPos(x, y)
1545 def position_nodes(self, nodes):
1546 positions = {}
1548 x_max = self.x_max
1549 y_min = self.y_min
1550 x_off = self.x_off
1551 y_off = self.y_off
1552 x_offsets = self.x_offsets
1554 for node in nodes:
1555 generation = node.generation
1556 sha1 = node.sha1
1558 if len(node.children) > 1:
1559 # This is a fan-out so sweep over child generations and
1560 # shift them to the right to avoid overlapping edges
1561 child_gens = [c.generation for c in node.children]
1562 maxgen = reduce(max, child_gens)
1563 mingen = reduce(min, child_gens)
1564 if maxgen > mingen:
1565 for g in xrange(generation+1, maxgen):
1566 x_offsets[g] += x_off
1568 if len(node.parents) == 1:
1569 # Align nodes relative to their parents
1570 parent_gen = node.parents[0].generation
1571 parent_off = x_offsets[parent_gen]
1572 x_offsets[generation] = max(parent_off-x_off,
1573 x_offsets[generation])
1575 cur_xoff = x_offsets[generation]
1576 next_xoff = cur_xoff
1577 next_xoff += x_off
1578 x_offsets[generation] = next_xoff
1580 x_pos = cur_xoff
1581 y_pos = -generation * y_off
1582 positions[sha1] = (x_pos, y_pos)
1584 x_max = max(x_max, x_pos)
1585 y_min = min(y_min, y_pos)
1588 self.x_max = x_max
1589 self.y_min = y_min
1591 return positions
1593 def update_scene_rect(self):
1594 y_min = self.y_min
1595 x_max = self.x_max
1596 self.scene().setSceneRect(-Commit.width*3/4,
1597 y_min-self.y_off/2,
1598 x_max + int(self.x_off * 1.1),
1599 abs(y_min)+self.y_off)
1601 def sort_by_generation(self, commits):
1602 if len(commits) < 2:
1603 return commits
1604 commits.sort(cmp=lambda a, b: cmp(a.generation, b.generation))
1605 return commits