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
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
)
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
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()
61 if email
== self
.email
and self
.response
is not None:
62 self
.set_pixmap_from_response()
65 self
.get(self
.url_for_email(email
))
68 self
.network
.get(QtNetwork
.QNetworkRequest(QtCore
.QUrl(url
)))
70 def default_pixmap_as_bytes(self
):
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')
81 def network_finished(self
, reply
):
82 header
= QtCore
.QByteArray('Location')
83 raw_header
= reply
.rawHeader(header
)
85 location
= unicode(QtCore
.QString(raw_header
)).strip()
86 request_location
= unicode(self
.url_for_email(self
.email
))
87 relocated
= location
!= request_location
91 if reply
.error() == QtNetwork
.QNetworkReply
.NoError
:
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()
98 self
.response
= reply
.readAll()
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)
123 def setText(self
, text
):
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
)
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:
198 self
.diff
.setText(gitcmds
.diff_info(sha1
))
200 email
= commit
.email
or ''
201 summary
= commit
.summary
or ''
202 author
= commit
.author
or ''
210 self
.gravatar_label
.get_email(email
)
211 author_text
= ("""%(author)s <"""
212 """<a href="mailto:%(email)s">"""
213 """%(email)s</a>>"""
216 self
.author_label
.setText(author_text
)
217 self
.summary_label
.setText(summary
)
220 class ViewerMixin(object):
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
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
):
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
),
269 qtutils
.add_action(self
, 'Create Branch',
272 qtutils
.add_action(self
, 'Create Patch',
275 qtutils
.add_action(self
, 'Create Tag',
278 qtutils
.add_action(self
, 'Save As Tarball/Zip...',
279 self
.create_tarball
),
281 qtutils
.add_action(self
, 'Cherry Pick',
284 qtutils
.add_action(self
, 'Grab File...',
285 self
.save_blob_dialog
),
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
303 self
.selected
= selected_items
[0].commit
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'])
326 menu
.addAction(self
.menu_actions
['create_branch'])
327 menu
.addAction(self
.menu_actions
['create_tag'])
329 menu
.addAction(self
.menu_actions
['cherry_pick'])
330 menu
.addAction(self
.menu_actions
['create_patch'])
331 menu
.addAction(self
.menu_actions
['create_tarball'])
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
)
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'])
360 self
.notifier
= notifier
361 self
.selecting
= False
364 self
.action_up
= qtutils
.add_action(self
, 'Go Up', self
.go_up
,
367 self
.action_down
= qtutils
.add_action(self
, 'Go Down', self
.go_down
,
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
:
383 if event
.modifiers() == QtCore
.Qt
.MetaModifier
:
386 super(CommitTreeWidget
, self
).mousePressEvent(event
)
389 self
.goto(self
.itemAbove
)
392 self
.goto(self
.itemBelow
)
394 def goto(self
, finder
):
395 items
= self
.selectedItems()
396 item
= items
and items
[0] or None
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()
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
):
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()
425 item
= self
.sha1map
[sha1
]
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
437 self
.setColumnWidth(0, zero
)
438 self
.setColumnWidth(1, onetwo
)
439 self
.setColumnWidth(2, onetwo
)
442 QtGui
.QTreeWidget
.clear(self
)
446 def add_commits(self
, commits
):
447 self
.commits
.extend(commits
)
449 for c
in reversed(commits
):
450 item
= CommitTreeWidgetItem(c
)
452 self
.sha1map
[c
.sha1
] = item
454 self
.sha1map
[tag
] = item
455 self
.insertTopLevelItems(0, items
)
457 def create_patch(self
):
458 items
= self
.selectedItems()
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
482 self
.commit_list
= []
484 self
.old_count
= 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
,
570 self
.thread
.connect(self
.thread
, self
.thread
.done
,
573 self
.connect(self
.splitter
, SIGNAL('splitterMoved(int,int)'),
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'),
585 self
.connect(self
.graphview
, SIGNAL('diff_commits'),
588 self
.connect(self
.maxresults
, SIGNAL('editingFinished()'),
591 self
.connect(self
.displaybutton
, SIGNAL('pressed()'),
594 self
.connect(self
.revtext
, SIGNAL('ref_changed'),
597 self
.connect(self
.revtext
, SIGNAL('textChanged(QString)'),
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'),
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
617 self
.setWindowTitle('%s: %s' % (project
, self
.dag
.ref
))
619 self
.setWindowTitle(project
)
621 def export_state(self
):
622 state
= super(DAGView
, self
).export_state()
623 state
['count'] = self
.dag
.count
626 def apply_state(self
, state
):
628 super(DAGView
, self
).apply_state(state
)
632 count
= state
['count']
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
):
644 self
.revtext
.update_matches()
646 if not self
.model
.currentbranch
:
648 self
.revtext
.setText(self
.model
.currentbranch
)
652 new_ref
= unicode(self
.revtext
.text())
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
:
661 self
.setEnabled(False)
663 self
.old_ref
= new_ref
664 self
.old_count
= new_count
668 self
.dag
.set_ref(new_ref
)
669 self
.dag
.set_count(self
.maxresults
.value())
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()
686 self
.graphview
.clear()
687 self
.treewidget
.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)
704 commit_obj
= self
.commit_list
[-1]
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()
715 qtutils
.save_state(self
)
716 return super(DAGView
, self
).closeEvent(event
)
719 self
.thread
.mutex
.lock()
720 self
.thread
.stop
= True
721 self
.thread
.mutex
.unlock()
724 self
.thread
.abort
= True
728 self
.thread
.abort
= False
729 self
.thread
.stop
= False
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()
747 difftool
.launch([a
, b
, '--'] + paths
)
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
)
762 self
.mutex
= QtCore
.QMutex()
763 self
.condition
= QtCore
.QWaitCondition()
766 repo
= RepoReader(self
.dag
)
772 self
.condition
.wait(self
.mutex
)
778 if len(commits
) >= 512:
779 self
.emit(self
.commits_ready
, commits
)
783 self
.emit(self
.commits_ready
, commits
)
791 class Edge(QtGui
.QGraphicsItem
):
792 item_type
= QtGui
.QGraphicsItem
.UserType
+ 1
794 arrow_extra
= (arrow_size
+1.0)/2.0
796 pen
= QtGui
.QPen(QtCore
.Qt
.gray
, 1.0,
801 def __init__(self
, source
, dest
,
803 arrow_size
=arrow_size
):
805 QtGui
.QGraphicsItem
.__init
__(self
)
807 self
.setAcceptedMouseButtons(QtCore
.Qt
.NoButton
)
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
)
824 return self
.item_type
826 def boundingRect(self
):
829 def paint(self
, painter
, option
, widget
,
830 arrow_size
=arrow_size
,
831 gray
=QtCore
.Qt
.gray
):
833 painter
.setPen(self
.pen
)
834 painter
.drawLine(self
.line
)
837 class Commit(QtGui
.QGraphicsItem
):
838 item_type
= QtGui
.QGraphicsItem
.UserType
+ 2
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
,
867 selectable
=QtGui
.QGraphicsItem
.ItemIsSelectable
,
868 cursor
=QtCore
.Qt
.PointingHandCursor
,
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
)
877 self
.setFlag(selectable
)
878 self
.setCursor(cursor
)
881 self
.notifier
= notifier
884 self
.label
= label
= Label(commit
)
885 label
.setParentItem(self
)
886 label
.setPos(xpos
, 0.)
890 if len(commit
.parents
) > 1:
891 self
.commit_color
= merge_color
893 self
.commit_color
= commit_color
894 self
.text_pen
= QtCore
.Qt
.black
895 self
.sha1_text
= commit
.sha1
[:8]
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
923 self
.text_pen
= QtCore
.Qt
.black
924 if len(self
.commit
.parents
) > 1:
925 self
.commit_color
= self
.cached_merge_color
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
)
937 return self
.item_type
939 def boundingRect(self
, rect
=item_bbox
):
943 return self
.item_shape
945 def paint(self
, painter
, option
, widget
,
947 text_opts
=text_options
,
950 # Do not draw outside the exposed rect
951 painter
.setClipRect(option
.exposedRect
)
954 painter
.setPen(self
.commit_pen
)
955 painter
.setBrush(self
.commit_color
)
956 painter
.drawEllipse(inner
)
961 except AttributeError:
962 font
= cache
.font
= painter
.font()
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
)
971 self
.selected
= self
.isSelected()
973 def mouseMoveEvent(self
, event
):
976 QtGui
.QGraphicsItem
.mouseMoveEvent(self
, event
)
978 def mouseReleaseEvent(self
, event
):
979 QtGui
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
980 if (not self
.dragged
and
982 event
.button() == QtCore
.Qt
.LeftButton
):
988 class Label(QtGui
.QGraphicsItem
):
989 item_type
= QtGui
.QGraphicsItem
.UserType
+ 3
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
)
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
1020 self
.color
= other_color
1022 self
.pen
= QtGui
.QPen()
1023 self
.pen
.setColor(self
.color
.darker())
1024 self
.pen
.setWidth(1.0)
1027 return self
.item_type
1029 def boundingRect(self
, rect
=item_bbox
):
1033 return self
.item_shape
1035 def paint(self
, painter
, option
, widget
,
1036 text_opts
=text_options
,
1037 black
=QtCore
.Qt
.black
,
1040 painter
.setBrush(self
.color
)
1041 painter
.setPen(self
.pen
)
1042 painter
.drawRoundedRect(self
.label_box
, 4, 4)
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
)
1058 from PyQt4
import QtOpenGL
1059 glformat
= QtOpenGL
.QGLFormat(QtOpenGL
.QGL
.SampleBuffers
)
1060 self
.glwidget
= QtOpenGL
.QGLWidget(glformat
)
1061 self
.setViewport(self
.glwidget
)
1070 self
.selection_list
= []
1071 self
.notifier
= notifier
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]
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',
1102 QtCore
.Qt
.Key_Equal
))
1104 self
.action_zoom_out
= (
1105 qtutils
.add_action(self
, 'Zoom Out',
1107 QtCore
.Qt
.Key_Minus
))
1109 self
.action_zoom_fit
= (
1110 qtutils
.add_action(self
, 'Zoom to Fit',
1114 self
.action_select_parent
= (
1115 qtutils
.add_action(self
, 'Select Parent',
1119 self
.action_select_oldest_parent
= (
1120 qtutils
.add_action(self
, 'Select Oldest Parent',
1121 self
.select_oldest_parent
,
1124 self
.action_select_child
= (
1125 qtutils
.add_action(self
, 'Select Child',
1129 self
.action_select_child
= (
1130 qtutils
.add_action(self
, 'Select Nth Child',
1131 self
.select_nth_child
,
1134 sig
= signals
.commits_selected
1135 notifier
.add_observer(sig
, self
.commits_selected
)
1138 self
.scene().clear()
1139 self
.selection_list
= []
1141 self
.x_offsets
.clear()
1147 self
.scale_view(1.5)
1150 self
.scale_view(1.0/1.5)
1152 def commits_selected(self
, commits
):
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()
1166 item
= self
.items
[sha1
]
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
:
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"""
1191 for commit
in commits
:
1192 if (generation
is None or
1193 criteria_fn(generation
, commit
.generation
)):
1195 generation
= commit
.generation
1197 return self
.items
[sha1
]
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()
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:
1223 parent_item
= self
.newest_item(selected_item
.commit
.parents
)
1224 if parent_item
is None:
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:
1235 parent_item
= self
.oldest_item(selected_item
.commit
.parents
)
1236 if parent_item
is None:
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:
1247 child_item
= self
.oldest_item(selected_item
.commit
.children
)
1248 if child_item
is None:
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:
1259 if len(selected_item
.commit
.children
) > 1:
1260 children
= selected_item
.commit
.children
[1:]
1262 children
= selected_item
.commit
.children
1263 child_item
= self
.newest_item(children
)
1264 if child_item
is None:
1266 selected_item
.setSelected(False)
1267 child_item
.setSelected(True)
1268 self
.ensureVisible(child_item
.mapRectToScene(child_item
.boundingRect()))
1271 """Fit selected items into the viewport"""
1273 items
= self
.scene().selectedItems()
1275 rect
= self
.scene().itemsBoundingRect()
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
:
1302 elif QtCore
.Qt
.ShiftModifier
!= event
.modifiers():
1304 self
.selection_list
= self
.selectedItems()
1306 def restore_selection(self
, event
):
1307 if QtCore
.Qt
.ShiftModifier
!= event
.modifiers():
1309 for item
in self
.selection_list
:
1310 item
.setSelected(True)
1312 def handle_event(self
, event_handler
, event
):
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
:
1321 self
.mouse_start
= [pos
.x(), pos
.y()]
1322 self
.saved_matrix
= QtGui
.QMatrix(self
.matrix())
1323 self
.is_panning
= True
1325 if event
.button() == QtCore
.Qt
.RightButton
:
1328 if event
.button() == QtCore
.Qt
.LeftButton
:
1330 self
.handle_event(QtGui
.QGraphicsView
.mousePressEvent
, event
)
1332 def mouseMoveEvent(self
, event
):
1333 pos
= self
.mapToScene(event
.pos())
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
1349 self
.handle_event(QtGui
.QGraphicsView
.mouseReleaseEvent
, event
)
1350 self
.selection_list
= []
1352 def pan(self
, event
):
1354 dx
= pos
.x() - self
.mouse_start
[0]
1355 dy
= pos
.y() - self
.mouse_start
[1]
1357 if dx
== 0 and dy
== 0:
1360 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1361 delta
= self
.mapToScene(rect
).boundingRect()
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
)
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()
1387 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1389 if factor
< 0.014 or factor
> 42.0:
1391 self
.setTransformationAnchor(QtGui
.QGraphicsView
.AnchorUnderMouse
)
1393 self
.scale(zoom
, zoom
)
1395 def wheel_pan(self
, event
):
1396 """Handle mouse wheel panning."""
1398 if event
.delta() < 0:
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
)
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))
1416 if factor
< 0.07 or factor
> 100:
1420 adjust_scrollbars
= True
1421 scrollbar
= self
.verticalScrollBar()
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
1430 scrolloffset
= distance
/float(range_
)
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
1456 self
.layout_commits(commits
)
1459 def link(self
, commits
):
1460 """Create edges linking commits with their parents"""
1461 scene
= self
.scene()
1462 for commit
in commits
:
1464 commit_item
= self
.items
[commit
.sha1
]
1466 # TODO - Handle truncated history viewing
1468 for parent
in commit
.parents
:
1470 parent_item
= self
.items
[parent
.sha1
]
1472 # TODO - Handle truncated history viewing
1474 edge
= Edge(parent_item
, commit_item
)
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
]
1483 def position_nodes(self
, nodes
):
1490 x_offsets
= self
.x_offsets
1493 generation
= node
.generation
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
)
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
1516 x_offsets
[generation
] = next_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
)
1531 def update_scene_rect(self
):
1534 self
.scene().setSceneRect(-Commit
.width
*3/4,
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:
1542 commits
.sort(cmp=lambda a
, b
: cmp(a
.generation
, b
.generation
))