5 from PyQt4
import QtGui
6 from PyQt4
import QtCore
7 from PyQt4
.QtCore
import Qt
8 from PyQt4
.QtCore
import SIGNAL
9 from PyQt4
.QtCore
import QPointF
10 from PyQt4
.QtCore
import QRectF
13 from cola
import difftool
14 from cola
import observable
15 from cola
import qtutils
16 from cola
.i18n
import N_
17 from cola
.models
.dag
import DAG
18 from cola
.models
.dag
import RepoReader
19 from cola
.widgets
import completion
20 from cola
.widgets
import defs
21 from cola
.widgets
.createbranch
import create_new_branch
22 from cola
.widgets
.createtag
import create_tag
23 from cola
.widgets
.archive
import GitArchiveDialog
24 from cola
.widgets
.browse
import BrowseDialog
25 from cola
.widgets
.standard
import MainWindow
26 from cola
.widgets
.standard
import TreeWidget
27 from cola
.widgets
.diff
import COMMITS_SELECTED
28 from cola
.widgets
.diff
import DiffWidget
31 def git_dag(model
, args
=None):
32 """Return a pre-populated git DAG widget."""
33 branch
= model
.currentbranch
34 # disambiguate between branch names and filenames by using '--'
35 branch_doubledash
= branch
and (branch
+ ' --') or ''
36 dag
= DAG(branch_doubledash
, 1000)
37 dag
.set_arguments(args
)
39 view
= DAGView(model
, dag
, None)
45 class ViewerMixin(object):
46 """Implementations must provide selected_items()"""
51 self
.menu_actions
= self
.context_menu_actions()
53 def selected_item(self
):
54 """Return the currently selected item"""
55 selected_items
= self
.selected_items()
56 if not selected_items
:
58 return selected_items
[0]
60 def selected_sha1(self
):
61 item
= self
.selected_item()
64 return item
.commit
.sha1
66 def diff_selected_this(self
):
67 clicked_sha1
= self
.clicked
.sha1
68 selected_sha1
= self
.selected
.sha1
69 self
.emit(SIGNAL('diff_commits'), selected_sha1
, clicked_sha1
)
71 def diff_this_selected(self
):
72 clicked_sha1
= self
.clicked
.sha1
73 selected_sha1
= self
.selected
.sha1
74 self
.emit(SIGNAL('diff_commits'), clicked_sha1
, selected_sha1
)
76 def cherry_pick(self
):
77 sha1
= self
.selected_sha1()
80 cmds
.do(cmds
.CherryPick
, [sha1
])
82 def copy_to_clipboard(self
):
83 sha1
= self
.selected_sha1()
86 qtutils
.set_clipboard(sha1
)
88 def create_branch(self
):
89 sha1
= self
.selected_sha1()
92 create_new_branch(revision
=sha1
)
95 sha1
= self
.selected_sha1()
100 def create_tarball(self
):
101 sha1
= self
.selected_sha1()
104 short_sha1
= sha1
[:7]
105 GitArchiveDialog
.save(sha1
, short_sha1
, self
)
107 def save_blob_dialog(self
):
108 sha1
= self
.selected_sha1()
111 return BrowseDialog
.browse(sha1
)
113 def context_menu_actions(self
):
115 'diff_this_selected':
116 qtutils
.add_action(self
, N_('Diff this -> selected'),
117 self
.diff_this_selected
),
118 'diff_selected_this':
119 qtutils
.add_action(self
, N_('Diff selected -> this'),
120 self
.diff_selected_this
),
122 qtutils
.add_action(self
, N_('Create Branch'),
125 qtutils
.add_action(self
, N_('Create Patch'),
128 qtutils
.add_action(self
, N_('Create Tag'),
131 qtutils
.add_action(self
, N_('Save As Tarball/Zip...'),
132 self
.create_tarball
),
134 qtutils
.add_action(self
, N_('Cherry Pick'),
137 qtutils
.add_action(self
, N_('Grab File...'),
138 self
.save_blob_dialog
),
140 qtutils
.add_action(self
, N_('Copy SHA-1'),
141 self
.copy_to_clipboard
,
142 QtGui
.QKeySequence
.Copy
),
145 def update_menu_actions(self
, event
):
146 selected_items
= self
.selected_items()
147 item
= self
.itemAt(event
.pos())
149 self
.clicked
= commit
= None
151 self
.clicked
= commit
= item
.commit
153 has_single_selection
= len(selected_items
) == 1
154 has_selection
= bool(selected_items
)
155 can_diff
= bool(commit
and has_single_selection
and
156 commit
is not selected_items
[0].commit
)
159 self
.selected
= selected_items
[0].commit
163 self
.menu_actions
['diff_this_selected'].setEnabled(can_diff
)
164 self
.menu_actions
['diff_selected_this'].setEnabled(can_diff
)
166 self
.menu_actions
['create_branch'].setEnabled(has_single_selection
)
167 self
.menu_actions
['create_tag'].setEnabled(has_single_selection
)
169 self
.menu_actions
['cherry_pick'].setEnabled(has_single_selection
)
170 self
.menu_actions
['create_patch'].setEnabled(has_selection
)
171 self
.menu_actions
['create_tarball'].setEnabled(has_single_selection
)
173 self
.menu_actions
['save_blob'].setEnabled(has_single_selection
)
174 self
.menu_actions
['copy'].setEnabled(has_single_selection
)
176 def context_menu_event(self
, event
):
177 self
.update_menu_actions(event
)
178 menu
= QtGui
.QMenu(self
)
179 menu
.addAction(self
.menu_actions
['diff_this_selected'])
180 menu
.addAction(self
.menu_actions
['diff_selected_this'])
182 menu
.addAction(self
.menu_actions
['create_branch'])
183 menu
.addAction(self
.menu_actions
['create_tag'])
185 menu
.addAction(self
.menu_actions
['cherry_pick'])
186 menu
.addAction(self
.menu_actions
['create_patch'])
187 menu
.addAction(self
.menu_actions
['create_tarball'])
189 menu
.addAction(self
.menu_actions
['save_blob'])
190 menu
.addAction(self
.menu_actions
['copy'])
191 menu
.exec_(self
.mapToGlobal(event
.pos()))
194 class CommitTreeWidgetItem(QtGui
.QTreeWidgetItem
):
196 def __init__(self
, commit
, parent
=None):
197 QtGui
.QTreeWidgetItem
.__init
__(self
, parent
)
199 self
.setText(0, commit
.summary
)
200 self
.setText(1, commit
.author
)
201 self
.setText(2, commit
.authdate
)
204 class CommitTreeWidget(ViewerMixin
, TreeWidget
):
206 def __init__(self
, notifier
, parent
):
207 TreeWidget
.__init
__(self
, parent
)
208 ViewerMixin
.__init
__(self
)
210 self
.setSelectionMode(self
.ContiguousSelection
)
211 self
.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')])
214 self
.notifier
= notifier
215 self
.selecting
= False
218 self
.action_up
= qtutils
.add_action(self
, N_('Go Up'), self
.go_up
,
221 self
.action_down
= qtutils
.add_action(self
, N_('Go Down'), self
.go_down
,
224 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
226 self
.connect(self
, SIGNAL('itemSelectionChanged()'),
227 self
.selection_changed
)
231 self
.goto(self
.itemAbove
)
234 self
.goto(self
.itemBelow
)
236 def goto(self
, finder
):
237 items
= self
.selected_items()
238 item
= items
and items
[0] or None
243 self
.select([found
.commit
.sha1
], block_signals
=False)
245 def set_selecting(self
, selecting
):
246 self
.selecting
= selecting
248 def selection_changed(self
):
249 items
= self
.selected_items()
252 self
.set_selecting(True)
253 self
.notifier
.notify_observers(COMMITS_SELECTED
,
254 [i
.commit
for i
in items
])
255 self
.set_selecting(False)
257 def commits_selected(self
, commits
):
260 self
.select([commit
.sha1
for commit
in commits
])
262 def select(self
, sha1s
, block_signals
=True):
263 self
.clearSelection()
266 item
= self
.sha1map
[sha1
]
269 block
= self
.blockSignals(block_signals
)
270 self
.scrollToItem(item
)
271 item
.setSelected(True)
272 self
.blockSignals(block
)
274 def adjust_columns(self
):
275 width
= self
.width()-20
278 self
.setColumnWidth(0, zero
)
279 self
.setColumnWidth(1, onetwo
)
280 self
.setColumnWidth(2, onetwo
)
283 QtGui
.QTreeWidget
.clear(self
)
287 def add_commits(self
, commits
):
288 self
.commits
.extend(commits
)
290 for c
in reversed(commits
):
291 item
= CommitTreeWidgetItem(c
)
293 self
.sha1map
[c
.sha1
] = item
295 self
.sha1map
[tag
] = item
296 self
.insertTopLevelItems(0, items
)
298 def create_patch(self
):
299 items
= self
.selectedItems()
303 sha1s
= [item
.commit
.sha1
for item
in items
]
304 all_sha1s
= [c
.sha1
for c
in self
.commits
]
305 cmds
.do(cmds
.FormatPatch
, sha1s
, all_sha1s
)
308 def contextMenuEvent(self
, event
):
309 self
.context_menu_event(event
)
311 def mousePressEvent(self
, event
):
312 if event
.button() == Qt
.RightButton
:
315 QtGui
.QTreeWidget
.mousePressEvent(self
, event
)
318 class DAGView(MainWindow
):
319 """The git-dag widget."""
321 def __init__(self
, model
, dag
, parent
=None, args
=None):
322 MainWindow
.__init
__(self
, parent
)
324 self
.setAttribute(Qt
.WA_MacMetalStyle
)
325 self
.setMinimumSize(420, 420)
327 # change when widgets are added/removed
328 self
.widget_version
= 1
333 self
.commit_list
= []
335 self
.old_count
= None
337 self
.thread
= ReaderThread(dag
, self
)
339 self
.revtext
= completion
.GitLogLineEdit()
341 self
.maxresults
= QtGui
.QSpinBox()
342 self
.maxresults
.setMinimum(1)
343 self
.maxresults
.setMaximum(99999)
344 self
.maxresults
.setPrefix('')
345 self
.maxresults
.setSuffix('')
347 self
.zoom_out
= qtutils
.create_action_button(
348 tooltip
=N_('Zoom Out'),
349 icon
=qtutils
.theme_icon('zoom-out.png'))
351 self
.zoom_in
= qtutils
.create_action_button(
352 tooltip
=N_('Zoom In'),
353 icon
=qtutils
.theme_icon('zoom-in.png'))
355 self
.zoom_to_fit
= qtutils
.create_action_button(
356 tooltip
=N_('Zoom to Fit'),
357 icon
=qtutils
.theme_icon('zoom-fit-best.png'))
359 self
.notifier
= notifier
= observable
.Observable()
360 self
.notifier
.refs_updated
= refs_updated
= 'refs_updated'
361 self
.notifier
.add_observer(refs_updated
, self
.display
)
363 self
.treewidget
= CommitTreeWidget(notifier
, self
)
364 self
.diffwidget
= DiffWidget(notifier
, self
)
365 self
.graphview
= GraphView(notifier
, self
)
367 self
.controls_layout
= QtGui
.QHBoxLayout()
368 self
.controls_layout
.setMargin(defs
.no_margin
)
369 self
.controls_layout
.setSpacing(defs
.spacing
)
370 self
.controls_layout
.addWidget(self
.revtext
)
371 self
.controls_layout
.addWidget(self
.maxresults
)
373 self
.controls_widget
= QtGui
.QWidget()
374 self
.controls_widget
.setLayout(self
.controls_layout
)
376 self
.log_dock
= qtutils
.create_dock(N_('Log'), self
, stretch
=False)
377 self
.log_dock
.setWidget(self
.treewidget
)
378 log_dock_titlebar
= self
.log_dock
.titleBarWidget()
379 log_dock_titlebar
.add_corner_widget(self
.controls_widget
)
381 self
.diff_dock
= qtutils
.create_dock(N_('Diff'), self
)
382 self
.diff_dock
.setWidget(self
.diffwidget
)
384 self
.graph_controls_layout
= QtGui
.QHBoxLayout()
385 self
.graph_controls_layout
.setMargin(defs
.no_margin
)
386 self
.graph_controls_layout
.setSpacing(defs
.button_spacing
)
387 self
.graph_controls_layout
.addWidget(self
.zoom_out
)
388 self
.graph_controls_layout
.addWidget(self
.zoom_in
)
389 self
.graph_controls_layout
.addWidget(self
.zoom_to_fit
)
391 self
.graph_controls_widget
= QtGui
.QWidget()
392 self
.graph_controls_widget
.setLayout(self
.graph_controls_layout
)
394 self
.graphview_dock
= qtutils
.create_dock(N_('Graph'), self
)
395 self
.graphview_dock
.setWidget(self
.graphview
)
396 graph_titlebar
= self
.graphview_dock
.titleBarWidget()
397 graph_titlebar
.add_corner_widget(self
.graph_controls_widget
)
399 self
.lock_layout_action
= qtutils
.add_action_bool(self
,
400 N_('Lock Layout'), self
.set_lock_layout
, False)
402 # Create the application menu
403 self
.menubar
= QtGui
.QMenuBar(self
)
406 self
.view_menu
= qtutils
.create_menu(N_('View'), self
.menubar
)
407 self
.view_menu
.addAction(self
.log_dock
.toggleViewAction())
408 self
.view_menu
.addAction(self
.graphview_dock
.toggleViewAction())
409 self
.view_menu
.addAction(self
.diff_dock
.toggleViewAction())
410 self
.view_menu
.addSeparator()
411 self
.view_menu
.addAction(self
.lock_layout_action
)
413 self
.menubar
.addAction(self
.view_menu
.menuAction())
414 self
.setMenuBar(self
.menubar
)
416 left
= Qt
.LeftDockWidgetArea
417 right
= Qt
.RightDockWidgetArea
418 bottom
= Qt
.BottomDockWidgetArea
419 self
.addDockWidget(left
, self
.log_dock
)
420 self
.addDockWidget(right
, self
.graphview_dock
)
421 self
.addDockWidget(bottom
, self
.diff_dock
)
423 # Update fields affected by model
424 self
.revtext
.setText(dag
.ref
)
425 self
.maxresults
.setValue(dag
.count
)
426 self
.update_window_title()
428 # Also re-loads dag.* from the saved state
429 if not qtutils
.apply_state(self
):
430 self
.resize_to_desktop()
432 qtutils
.connect_button(self
.zoom_out
, self
.graphview
.zoom_out
)
433 qtutils
.connect_button(self
.zoom_in
, self
.graphview
.zoom_in
)
434 qtutils
.connect_button(self
.zoom_to_fit
,
435 self
.graphview
.zoom_to_fit
)
437 self
.thread
.connect(self
.thread
, self
.thread
.commits_ready
,
440 self
.thread
.connect(self
.thread
, self
.thread
.done
,
443 self
.connect(self
.treewidget
, SIGNAL('diff_commits'),
446 self
.connect(self
.graphview
, SIGNAL('diff_commits'),
449 self
.connect(self
.maxresults
, SIGNAL('editingFinished()'),
452 self
.connect(self
.revtext
, SIGNAL('changed()'),
455 self
.connect(self
.revtext
, SIGNAL('textChanged(QString)'),
458 self
.connect(self
.revtext
, SIGNAL('returnPressed()'),
461 # The model is updated in another thread so use
462 # signals/slots to bring control back to the main GUI thread
463 self
.model
.add_observer(self
.model
.message_updated
,
464 self
.emit_model_updated
)
466 self
.connect(self
, SIGNAL('model_updated'),
469 qtutils
.add_action(self
, 'Focus search field',
470 lambda: self
.revtext
.setFocus(), 'Ctrl+l')
472 qtutils
.add_close_action(self
)
474 def text_changed(self
, txt
):
475 self
.dag
.ref
= unicode(txt
)
476 self
.update_window_title()
478 def update_window_title(self
):
479 project
= self
.model
.project
481 self
.setWindowTitle(N_('%s: %s - DAG') % (project
, self
.dag
.ref
))
483 self
.setWindowTitle(project
+ N_(' - DAG'))
485 def export_state(self
):
486 state
= self
.Mixin
.export_state(self
)
487 state
['count'] = self
.dag
.count
490 def apply_state(self
, state
):
491 result
= self
.Mixin
.apply_state(self
, state
)
493 count
= state
['count']
494 if self
.dag
.overridden('count'):
495 count
= self
.dag
.count
497 count
= self
.dag
.count
499 self
.dag
.set_count(count
)
500 self
.lock_layout_action
.setChecked(state
.get('lock_layout', False))
503 def emit_model_updated(self
):
504 self
.emit(SIGNAL('model_updated'))
506 def model_updated(self
):
508 self
.revtext
.update_matches()
510 if not self
.model
.currentbranch
:
512 self
.revtext
.setText(self
.model
.currentbranch
+ ' --')
516 new_ref
= unicode(self
.revtext
.text())
519 new_count
= self
.maxresults
.value()
520 old_ref
= self
.old_ref
521 old_count
= self
.old_count
522 if old_ref
== new_ref
and old_count
== new_count
:
525 self
.old_ref
= new_ref
526 self
.old_count
= new_count
530 self
.dag
.set_ref(new_ref
)
531 self
.dag
.set_count(self
.maxresults
.value())
535 self
.Mixin
.show(self
)
536 self
.treewidget
.adjust_columns()
539 self
.graphview
.clear()
540 self
.treewidget
.clear()
542 self
.commit_list
= []
544 def add_commits(self
, commits
):
545 self
.commit_list
.extend(commits
)
546 # Keep track of commits
547 for commit_obj
in commits
:
548 self
.commits
[commit_obj
.sha1
] = commit_obj
549 for tag
in commit_obj
.tags
:
550 self
.commits
[tag
] = commit_obj
551 self
.graphview
.add_commits(commits
)
552 self
.treewidget
.add_commits(commits
)
554 def thread_done(self
):
555 self
.graphview
.setFocus()
557 commit_obj
= self
.commit_list
[-1]
560 self
.notifier
.notify_observers(COMMITS_SELECTED
, [commit_obj
])
561 self
.graphview
.update_scene_rect()
562 self
.graphview
.set_initial_view()
564 def resize_to_desktop(self
):
565 desktop
= QtGui
.QApplication
.instance().desktop()
566 width
= desktop
.width()
567 height
= desktop
.height()
568 self
.resize(width
, height
)
570 def diff_commits(self
, a
, b
):
571 paths
= self
.dag
.paths()
573 difftool
.launch([a
, b
, '--'] + paths
)
575 difftool
.diff_commits(self
, a
, b
)
578 def closeEvent(self
, event
):
579 self
.revtext
.close_popup()
581 self
.Mixin
.closeEvent(self
, event
)
583 def resizeEvent(self
, e
):
584 self
.Mixin
.resizeEvent(self
, e
)
585 self
.treewidget
.adjust_columns()
588 class ReaderThread(QtCore
.QThread
):
589 commits_ready
= SIGNAL('commits_ready')
590 done
= SIGNAL('done')
592 def __init__(self
, dag
, parent
):
593 QtCore
.QThread
.__init
__(self
, parent
)
597 self
._mutex
= QtCore
.QMutex()
598 self
._condition
= QtCore
.QWaitCondition()
601 repo
= RepoReader(self
.dag
)
607 self
._condition
.wait(self
._mutex
)
613 if len(commits
) >= 512:
614 self
.emit(self
.commits_ready
, commits
)
618 self
.emit(self
.commits_ready
, commits
)
624 QtCore
.QThread
.start(self
)
635 self
._condition
.wakeOne()
646 class Edge(QtGui
.QGraphicsItem
):
647 item_type
= QtGui
.QGraphicsItem
.UserType
+ 1
649 def __init__(self
, source
, dest
):
651 QtGui
.QGraphicsItem
.__init
__(self
)
653 self
.setAcceptedMouseButtons(Qt
.NoButton
)
656 self
.commit
= source
.commit
659 dest_pt
= Commit
.item_bbox
.center()
661 self
.source_pt
= self
.mapFromItem(self
.source
, dest_pt
)
662 self
.dest_pt
= self
.mapFromItem(self
.dest
, dest_pt
)
663 self
.line
= QtCore
.QLineF(self
.source_pt
, self
.dest_pt
)
665 width
= self
.dest_pt
.x() - self
.source_pt
.x()
666 height
= self
.dest_pt
.y() - self
.source_pt
.y()
667 rect
= QtCore
.QRectF(self
.source_pt
, QtCore
.QSizeF(width
, height
))
668 self
.bound
= rect
.normalized()
670 # Choose a new color for new branch edges
671 if self
.source
.x() < self
.dest
.x():
672 color
= EdgeColor
.next()
674 elif self
.source
.x() != self
.dest
.x():
675 color
= EdgeColor
.current()
678 color
= EdgeColor
.current()
681 self
.pen
= QtGui
.QPen(color
, 4.0, line
, Qt
.SquareCap
, Qt
.RoundJoin
)
685 return self
.item_type
687 def boundingRect(self
):
690 def paint(self
, painter
, option
, widget
):
695 painter
.setPen(self
.pen
)
696 path
= QtGui
.QPainterPath()
698 if self
.source
.x() == self
.dest
.x():
699 path
.moveTo(self
.source
.x(), self
.source
.y())
700 path
.lineTo(self
.dest
.x(), self
.dest
.y())
701 painter
.drawPath(path
)
705 #Define points starting from source
706 point1
= QPointF(self
.source
.x(), self
.source
.y())
707 point2
= QPointF(point1
.x(), point1
.y() - connector_length
)
708 point3
= QPointF(point2
.x() + arc_rect
, point2
.y() - arc_rect
)
710 #Define points starting from dest
711 point4
= QPointF(self
.dest
.x(), self
.dest
.y())
712 point5
= QPointF(point4
.x(),point3
.y() - arc_rect
)
713 point6
= QPointF(point5
.x() - arc_rect
, point5
.y() + arc_rect
)
715 start_angle_arc1
= 180
717 start_angle_arc2
= 90
718 span_angle_arc2
= -90
720 # If the dest is at the left of the source, then we
721 # need to reverse some values
722 if self
.source
.x() > self
.dest
.x():
723 point5
= QPointF(point4
.x(), point4
.y() + connector_length
)
724 point6
= QPointF(point5
.x() + arc_rect
, point5
.y() + arc_rect
)
725 point3
= QPointF(self
.source
.x() - arc_rect
, point6
.y())
726 point2
= QPointF(self
.source
.x(), point3
.y() + arc_rect
)
732 path
.arcTo(QRectF(point2
, point3
),
733 start_angle_arc1
, span_angle_arc1
)
735 path
.arcTo(QRectF(point6
, point5
),
736 start_angle_arc2
, span_angle_arc2
)
738 painter
.drawPath(path
)
741 class EdgeColor(object):
742 """An edge color factory"""
744 current_color_index
= 0
746 QtGui
.QColor(Qt
.red
),
747 QtGui
.QColor(Qt
.green
),
748 QtGui
.QColor(Qt
.blue
),
749 QtGui
.QColor(Qt
.black
),
750 QtGui
.QColor(Qt
.darkRed
),
751 QtGui
.QColor(Qt
.darkGreen
),
752 QtGui
.QColor(Qt
.darkBlue
),
753 QtGui
.QColor(Qt
.cyan
),
754 QtGui
.QColor(Qt
.magenta
),
755 # Orange; Qt.yellow is too low-contrast
756 qtutils
.rgba(0xff, 0x66, 0x00),
757 QtGui
.QColor(Qt
.gray
),
758 QtGui
.QColor(Qt
.darkCyan
),
759 QtGui
.QColor(Qt
.darkMagenta
),
760 QtGui
.QColor(Qt
.darkYellow
),
761 QtGui
.QColor(Qt
.darkGray
),
766 cls
.current_color_index
+= 1
767 cls
.current_color_index
%= len(cls
.colors
)
768 color
= cls
.colors
[cls
.current_color_index
]
774 return cls
.colors
[cls
.current_color_index
]
777 class Commit(QtGui
.QGraphicsItem
):
778 item_type
= QtGui
.QGraphicsItem
.UserType
+ 2
782 item_shape
= QtGui
.QPainterPath()
783 item_shape
.addRect(commit_radius
/-2.0,
785 commit_radius
, commit_radius
)
786 item_bbox
= item_shape
.boundingRect()
788 inner_rect
= QtGui
.QPainterPath()
789 inner_rect
.addRect(commit_radius
/-2.0 + 2.0,
790 commit_radius
/-2.0 + 2.0,
793 inner_rect
= inner_rect
.boundingRect()
795 commit_color
= QtGui
.QColor(Qt
.white
)
796 outline_color
= commit_color
.darker()
797 merge_color
= QtGui
.QColor(Qt
.lightGray
)
799 commit_selected_color
= QtGui
.QColor(Qt
.green
)
800 selected_outline_color
= commit_selected_color
.darker()
802 commit_pen
= QtGui
.QPen()
803 commit_pen
.setWidth(1.0)
804 commit_pen
.setColor(outline_color
)
806 def __init__(self
, commit
,
808 selectable
=QtGui
.QGraphicsItem
.ItemIsSelectable
,
809 cursor
=Qt
.PointingHandCursor
,
810 xpos
=commit_radius
/2.0 + 1.0,
811 cached_commit_color
=commit_color
,
812 cached_merge_color
=merge_color
):
814 QtGui
.QGraphicsItem
.__init
__(self
)
817 self
.notifier
= notifier
820 self
.setFlag(selectable
)
821 self
.setCursor(cursor
)
822 self
.setToolTip(commit
.sha1
[:7] + ': ' + commit
.summary
)
825 self
.label
= label
= Label(commit
)
826 label
.setParentItem(self
)
827 label
.setPos(xpos
, -self
.commit_radius
/2.0)
831 if len(commit
.parents
) > 1:
832 self
.brush
= cached_merge_color
834 self
.brush
= cached_commit_color
839 def blockSignals(self
, blocked
):
840 self
.notifier
.notification_enabled
= not blocked
842 def itemChange(self
, change
, value
):
843 if change
== QtGui
.QGraphicsItem
.ItemSelectedHasChanged
:
844 # Broadcast selection to other widgets
845 selected_items
= self
.scene().selectedItems()
846 commits
= [item
.commit
for item
in selected_items
]
847 self
.scene().parent().set_selecting(True)
848 self
.notifier
.notify_observers(COMMITS_SELECTED
, commits
)
849 self
.scene().parent().set_selecting(False)
851 # Cache the pen for use in paint()
852 if value
.toPyObject():
853 self
.brush
= self
.commit_selected_color
854 color
= self
.selected_outline_color
856 if len(self
.commit
.parents
) > 1:
857 self
.brush
= self
.merge_color
859 self
.brush
= self
.commit_color
860 color
= self
.outline_color
861 commit_pen
= QtGui
.QPen()
862 commit_pen
.setWidth(1.0)
863 commit_pen
.setColor(color
)
864 self
.commit_pen
= commit_pen
866 return QtGui
.QGraphicsItem
.itemChange(self
, change
, value
)
869 return self
.item_type
871 def boundingRect(self
, rect
=item_bbox
):
875 return self
.item_shape
877 def paint(self
, painter
, option
, widget
,
881 # Do not draw outside the exposed rect
882 painter
.setClipRect(option
.exposedRect
)
885 painter
.setPen(self
.commit_pen
)
886 painter
.setBrush(self
.brush
)
887 painter
.drawEllipse(inner
)
890 def mousePressEvent(self
, event
):
891 QtGui
.QGraphicsItem
.mousePressEvent(self
, event
)
893 self
.selected
= self
.isSelected()
895 def mouseMoveEvent(self
, event
):
898 QtGui
.QGraphicsItem
.mouseMoveEvent(self
, event
)
900 def mouseReleaseEvent(self
, event
):
901 QtGui
.QGraphicsItem
.mouseReleaseEvent(self
, event
)
902 if (not self
.dragged
and
904 event
.button() == Qt
.LeftButton
):
910 class Label(QtGui
.QGraphicsItem
):
911 item_type
= QtGui
.QGraphicsItem
.UserType
+ 3
916 item_shape
= QtGui
.QPainterPath()
917 item_shape
.addRect(0, 0, width
, height
)
918 item_bbox
= item_shape
.boundingRect()
920 text_options
= QtGui
.QTextOption()
921 text_options
.setAlignment(Qt
.AlignCenter
)
922 text_options
.setAlignment(Qt
.AlignVCenter
)
924 def __init__(self
, commit
,
925 other_color
=QtGui
.QColor(Qt
.white
),
926 head_color
=QtGui
.QColor(Qt
.green
)):
927 QtGui
.QGraphicsItem
.__init
__(self
)
930 # Starts with enough space for two tags. Any more and the commit
931 # needs to be taller to accomodate.
934 if 'HEAD' in commit
.tags
:
935 self
.color
= head_color
937 self
.color
= other_color
939 self
.color
.setAlpha(180)
940 self
.pen
= QtGui
.QPen()
941 self
.pen
.setColor(self
.color
.darker())
942 self
.pen
.setWidth(1.0)
945 return self
.item_type
947 def boundingRect(self
, rect
=item_bbox
):
951 return self
.item_shape
953 def paint(self
, painter
, option
, widget
,
954 text_opts
=text_options
,
958 font
= cache
.label_font
959 except AttributeError:
960 font
= cache
.label_font
= QtGui
.QApplication
.font()
965 painter
.setBrush(self
.color
)
966 painter
.setPen(self
.pen
)
967 painter
.setFont(font
)
971 for tag
in self
.commit
.tags
:
972 text_rect
= painter
.boundingRect(
973 QRectF(current_width
, 0, 0, 0), Qt
.TextSingleLine
, tag
)
974 box_rect
= text_rect
.adjusted(-1, -1, 1, 1)
975 painter
.drawRoundedRect(box_rect
, 2, 2)
976 painter
.drawText(text_rect
, Qt
.TextSingleLine
, tag
)
977 current_width
+= text_rect
.width() + 5
980 class GraphView(ViewerMixin
, QtGui
.QGraphicsView
):
985 x_adjust
= Commit
.commit_radius
*4/3
986 y_adjust
= Commit
.commit_radius
*4/3
991 def __init__(self
, notifier
, parent
):
992 QtGui
.QGraphicsView
.__init
__(self
, parent
)
993 ViewerMixin
.__init
__(self
)
995 highlight
= self
.palette().color(QtGui
.QPalette
.Highlight
)
996 Commit
.commit_selected_color
= highlight
997 Commit
.selected_outline_color
= highlight
.darker()
999 self
.selection_list
= []
1000 self
.notifier
= notifier
1003 self
.saved_matrix
= QtGui
.QMatrix(self
.matrix())
1005 self
.x_offsets
= collections
.defaultdict(int)
1007 self
.is_panning
= False
1008 self
.pressed
= False
1009 self
.selecting
= False
1010 self
.last_mouse
= [0, 0]
1012 self
.setDragMode(self
.RubberBandDrag
)
1014 scene
= QtGui
.QGraphicsScene(self
)
1015 scene
.setItemIndexMethod(QtGui
.QGraphicsScene
.NoIndex
)
1016 self
.setScene(scene
)
1018 self
.setRenderHint(QtGui
.QPainter
.Antialiasing
)
1019 self
.setViewportUpdateMode(self
.BoundingRectViewportUpdate
)
1020 self
.setCacheMode(QtGui
.QGraphicsView
.CacheBackground
)
1021 self
.setTransformationAnchor(QtGui
.QGraphicsView
.AnchorUnderMouse
)
1022 self
.setResizeAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1023 self
.setBackgroundBrush(QtGui
.QColor(Qt
.white
))
1025 qtutils
.add_action(self
, N_('Zoom In'),
1026 self
.zoom_in
, Qt
.Key_Plus
, Qt
.Key_Equal
)
1028 qtutils
.add_action(self
, N_('Zoom Out'),
1029 self
.zoom_out
, Qt
.Key_Minus
)
1031 qtutils
.add_action(self
, N_('Zoom to Fit'),
1032 self
.zoom_to_fit
, Qt
.Key_F
)
1034 qtutils
.add_action(self
, N_('Select Parent'),
1035 self
.select_parent
, 'Shift+J')
1037 qtutils
.add_action(self
, N_('Select Oldest Parent'),
1038 self
.select_oldest_parent
, Qt
.Key_J
)
1040 qtutils
.add_action(self
, N_('Select Child'),
1041 self
.select_child
, 'Shift+K')
1043 qtutils
.add_action(self
, N_('Select Newest Child'),
1044 self
.select_newest_child
, Qt
.Key_K
)
1046 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
1049 self
.scene().clear()
1050 self
.selection_list
= []
1052 self
.x_offsets
.clear()
1057 # ViewerMixin interface
1058 def selected_items(self
):
1059 """Return the currently selected items"""
1060 return self
.scene().selectedItems()
1063 self
.scale_view(1.5)
1066 self
.scale_view(1.0/1.5)
1068 def commits_selected(self
, commits
):
1071 self
.select([commit
.sha1
for commit
in commits
])
1073 def select(self
, sha1s
):
1074 """Select the item for the SHA-1"""
1075 self
.scene().clearSelection()
1078 item
= self
.items
[sha1
]
1081 item
.blockSignals(True)
1082 item
.setSelected(True)
1083 item
.blockSignals(False)
1084 item_rect
= item
.sceneTransform().mapRect(item
.boundingRect())
1085 self
.ensureVisible(item_rect
)
1087 def get_item_by_generation(self
, commits
, criteria_fn
):
1088 """Return the item for the commit matching criteria"""
1092 for commit
in commits
:
1093 if (generation
is None or
1094 criteria_fn(generation
, commit
.generation
)):
1096 generation
= commit
.generation
1098 return self
.items
[sha1
]
1102 def oldest_item(self
, commits
):
1103 """Return the item for the commit with the oldest generation number"""
1104 return self
.get_item_by_generation(commits
, lambda a
, b
: a
> b
)
1106 def newest_item(self
, commits
):
1107 """Return the item for the commit with the newest generation number"""
1108 return self
.get_item_by_generation(commits
, lambda a
, b
: a
< b
)
1110 def create_patch(self
):
1111 items
= self
.selected_items()
1114 selected_commits
= self
.sort_by_generation([n
.commit
for n
in items
])
1115 sha1s
= [c
.sha1
for c
in selected_commits
]
1116 all_sha1s
= [c
.sha1
for c
in self
.commits
]
1117 cmds
.do(cmds
.FormatPatch
, sha1s
, all_sha1s
)
1119 def select_parent(self
):
1120 """Select the parent with the newest generation number"""
1121 selected_item
= self
.selected_item()
1122 if selected_item
is None:
1124 parent_item
= self
.newest_item(selected_item
.commit
.parents
)
1125 if parent_item
is None:
1127 selected_item
.setSelected(False)
1128 parent_item
.setSelected(True)
1130 parent_item
.mapRectToScene(parent_item
.boundingRect()))
1132 def select_oldest_parent(self
):
1133 """Select the parent with the oldest generation number"""
1134 selected_item
= self
.selected_item()
1135 if selected_item
is None:
1137 parent_item
= self
.oldest_item(selected_item
.commit
.parents
)
1138 if parent_item
is None:
1140 selected_item
.setSelected(False)
1141 parent_item
.setSelected(True)
1142 scene_rect
= parent_item
.mapRectToScene(parent_item
.boundingRect())
1143 self
.ensureVisible(scene_rect
)
1145 def select_child(self
):
1146 """Select the child with the oldest generation number"""
1147 selected_item
= self
.selected_item()
1148 if selected_item
is None:
1150 child_item
= self
.oldest_item(selected_item
.commit
.children
)
1151 if child_item
is None:
1153 selected_item
.setSelected(False)
1154 child_item
.setSelected(True)
1155 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1156 self
.ensureVisible(scene_rect
)
1158 def select_newest_child(self
):
1159 """Select the Nth child with the newest generation number (N > 1)"""
1160 selected_item
= self
.selected_item()
1161 if selected_item
is None:
1163 if len(selected_item
.commit
.children
) > 1:
1164 children
= selected_item
.commit
.children
[1:]
1166 children
= selected_item
.commit
.children
1167 child_item
= self
.newest_item(children
)
1168 if child_item
is None:
1170 selected_item
.setSelected(False)
1171 child_item
.setSelected(True)
1172 scene_rect
= child_item
.mapRectToScene(child_item
.boundingRect())
1173 self
.ensureVisible(scene_rect
)
1175 def set_initial_view(self
):
1176 self_commits
= self
.commits
1177 self_items
= self
.items
1179 commits
= self_commits
[-2:]
1180 items
= [self_items
[c
.sha1
] for c
in commits
]
1181 self
.fit_view_to_items(items
)
1183 def zoom_to_fit(self
):
1184 """Fit selected items into the viewport"""
1186 items
= self
.selected_items()
1187 self
.fit_view_to_items(items
)
1189 def fit_view_to_items(self
, items
):
1191 rect
= self
.scene().itemsBoundingRect()
1199 item_rect
= item
.boundingRect()
1200 x_off
= item_rect
.width()
1201 y_off
= item_rect
.height()
1202 x_min
= min(x_min
, pos
.x())
1203 y_min
= min(y_min
, pos
.y()-y_off
)
1204 x_max
= max(x_max
, pos
.x()+x_off
)
1205 ymax
= max(ymax
, pos
.y())
1206 rect
= QtCore
.QRectF(x_min
, y_min
, x_max
-x_min
, ymax
-y_min
)
1207 x_adjust
= GraphView
.x_adjust
1208 y_adjust
= GraphView
.y_adjust
1209 rect
.setX(rect
.x() - x_adjust
)
1210 rect
.setY(rect
.y() - y_adjust
)
1211 rect
.setHeight(rect
.height() + y_adjust
*2)
1212 rect
.setWidth(rect
.width() + x_adjust
*2)
1213 self
.fitInView(rect
, Qt
.KeepAspectRatio
)
1214 self
.scene().invalidate()
1216 def save_selection(self
, event
):
1217 if event
.button() != Qt
.LeftButton
:
1219 elif Qt
.ShiftModifier
!= event
.modifiers():
1221 self
.selection_list
= self
.selected_items()
1223 def restore_selection(self
, event
):
1224 if Qt
.ShiftModifier
!= event
.modifiers():
1226 for item
in self
.selection_list
:
1227 item
.setSelected(True)
1229 def handle_event(self
, event_handler
, event
):
1231 self
.save_selection(event
)
1232 event_handler(self
, event
)
1233 self
.restore_selection(event
)
1235 def set_selecting(self
, selecting
):
1236 self
.selecting
= selecting
1238 def pan(self
, event
):
1240 dx
= pos
.x() - self
.mouse_start
[0]
1241 dy
= pos
.y() - self
.mouse_start
[1]
1243 if dx
== 0 and dy
== 0:
1246 rect
= QtCore
.QRect(0, 0, abs(dx
), abs(dy
))
1247 delta
= self
.mapToScene(rect
).boundingRect()
1257 matrix
= QtGui
.QMatrix(self
.saved_matrix
).translate(tx
, ty
)
1258 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1259 self
.setMatrix(matrix
)
1261 def wheel_zoom(self
, event
):
1262 """Handle mouse wheel zooming."""
1263 zoom
= math
.pow(2.0, event
.delta()/512.0)
1264 factor
= (self
.matrix()
1266 .mapRect(QtCore
.QRectF(0.0, 0.0, 1.0, 1.0))
1268 if factor
< 0.014 or factor
> 42.0:
1270 self
.setTransformationAnchor(QtGui
.QGraphicsView
.AnchorUnderMouse
)
1272 self
.scale(zoom
, zoom
)
1274 def wheel_pan(self
, event
):
1275 """Handle mouse wheel panning."""
1277 if event
.delta() < 0:
1281 pan_rect
= QtCore
.QRectF(0.0, 0.0, 1.0, 1.0)
1282 factor
= 1.0/self
.matrix().mapRect(pan_rect
).width()
1284 if event
.orientation() == Qt
.Vertical
:
1285 matrix
= self
.matrix().translate(0, s
*factor
)
1287 matrix
= self
.matrix().translate(s
*factor
, 0)
1288 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1289 self
.setMatrix(matrix
)
1291 def scale_view(self
, scale
):
1292 factor
= (self
.matrix().scale(scale
, scale
)
1293 .mapRect(QtCore
.QRectF(0, 0, 1, 1))
1295 if factor
< 0.07 or factor
> 100.0:
1299 adjust_scrollbars
= True
1300 scrollbar
= self
.verticalScrollBar()
1302 value
= scrollbar
.value()
1303 min_
= scrollbar
.minimum()
1304 max_
= scrollbar
.maximum()
1305 range_
= max_
- min_
1306 distance
= value
- min_
1307 nonzero_range
= float(range_
) > 0.1
1309 scrolloffset
= distance
/float(range_
)
1311 adjust_scrollbars
= False
1313 self
.setTransformationAnchor(QtGui
.QGraphicsView
.NoAnchor
)
1314 self
.scale(scale
, scale
)
1316 scrollbar
= self
.verticalScrollBar()
1317 if scrollbar
and adjust_scrollbars
:
1318 min_
= scrollbar
.minimum()
1319 max_
= scrollbar
.maximum()
1320 range_
= max_
- min_
1321 value
= min_
+ int(float(range_
) * scrolloffset
)
1322 scrollbar
.setValue(value
)
1324 def add_commits(self
, commits
):
1325 """Traverse commits and add them to the view."""
1326 self
.commits
.extend(commits
)
1327 scene
= self
.scene()
1328 for commit
in commits
:
1329 item
= Commit(commit
, self
.notifier
)
1330 self
.items
[commit
.sha1
] = item
1331 for ref
in commit
.tags
:
1332 self
.items
[ref
] = item
1335 self
.layout_commits(commits
)
1338 def link(self
, commits
):
1339 """Create edges linking commits with their parents"""
1340 scene
= self
.scene()
1341 for commit
in commits
:
1343 commit_item
= self
.items
[commit
.sha1
]
1345 # TODO - Handle truncated history viewing
1347 for parent
in reversed(commit
.parents
):
1349 parent_item
= self
.items
[parent
.sha1
]
1351 # TODO - Handle truncated history viewing
1353 edge
= Edge(parent_item
, commit_item
)
1356 def layout_commits(self
, nodes
):
1357 positions
= self
.position_nodes(nodes
)
1358 for sha1
, (x
, y
) in positions
.items():
1359 item
= self
.items
[sha1
]
1362 def position_nodes(self
, nodes
):
1369 x_offsets
= self
.x_offsets
1372 generation
= node
.generation
1376 # This is a fan-out so sweep over child generations and
1377 # shift them to the right to avoid overlapping edges
1378 child_gens
= [c
.generation
for c
in node
.children
]
1379 maxgen
= max(child_gens
)
1380 for g
in xrange(generation
+ 1, maxgen
):
1381 x_offsets
[g
] += x_off
1383 if len(node
.parents
) == 1:
1384 # Align nodes relative to their parents
1385 parent_gen
= node
.parents
[0].generation
1386 parent_off
= x_offsets
[parent_gen
]
1387 x_offsets
[generation
] = max(parent_off
-x_off
,
1388 x_offsets
[generation
])
1390 cur_xoff
= x_offsets
[generation
]
1391 next_xoff
= cur_xoff
1393 x_offsets
[generation
] = next_xoff
1396 y_pos
= -generation
* y_off
1398 y_pos
= min(y_pos
, y_min
- y_off
)
1401 positions
[sha1
] = (x_pos
, y_pos
)
1403 x_max
= max(x_max
, x_pos
)
1411 def update_scene_rect(self
):
1414 self
.scene().setSceneRect(-GraphView
.x_adjust
,
1415 y_min
-GraphView
.y_adjust
,
1416 x_max
+ GraphView
.x_adjust
,
1417 abs(y_min
) + GraphView
.y_adjust
)
1419 def sort_by_generation(self
, commits
):
1420 if len(commits
) < 2:
1422 commits
.sort(cmp=lambda a
, b
: cmp(a
.generation
, b
.generation
))
1426 def contextMenuEvent(self
, event
):
1427 self
.context_menu_event(event
)
1429 def mousePressEvent(self
, event
):
1430 if event
.button() == Qt
.MidButton
:
1432 self
.mouse_start
= [pos
.x(), pos
.y()]
1433 self
.saved_matrix
= QtGui
.QMatrix(self
.matrix())
1434 self
.is_panning
= True
1436 if event
.button() == Qt
.RightButton
:
1439 if event
.button() == Qt
.LeftButton
:
1441 self
.handle_event(QtGui
.QGraphicsView
.mousePressEvent
, event
)
1443 def mouseMoveEvent(self
, event
):
1444 pos
= self
.mapToScene(event
.pos())
1448 self
.last_mouse
[0] = pos
.x()
1449 self
.last_mouse
[1] = pos
.y()
1450 self
.handle_event(QtGui
.QGraphicsView
.mouseMoveEvent
, event
)
1452 def mouseReleaseEvent(self
, event
):
1453 self
.pressed
= False
1454 if event
.button() == Qt
.MidButton
:
1455 self
.is_panning
= False
1457 self
.handle_event(QtGui
.QGraphicsView
.mouseReleaseEvent
, event
)
1458 self
.selection_list
= []
1460 def wheelEvent(self
, event
):
1461 """Handle Qt mouse wheel events."""
1462 if event
.modifiers() == Qt
.ControlModifier
:
1463 self
.wheel_zoom(event
)
1465 self
.wheel_pan(event
)