1from __future__ import division, absolute_import, unicode_literals 2import collections 3import itertools 4import math 5from functools import partial 6 7from qtpy.QtCore import Qt 8from qtpy.QtCore import Signal 9from qtpy import QtCore 10from qtpy import QtGui 11from qtpy import QtWidgets 12 13from ..compat import maxsize 14from ..i18n import N_ 15from ..models import dag 16from ..qtutils import get 17from .. import core 18from .. import cmds 19from .. import difftool 20from .. import gitcmds 21from .. import hotkeys 22from .. import icons 23from .. import observable 24from .. import qtcompat 25from .. import qtutils 26from .. import utils 27from . import archive 28from . import browse 29from . import completion 30from . import createbranch 31from . import createtag 32from . import defs 33from . import diff 34from . import filelist 35from . import standard 36 37 38def git_dag(context, args=None, existing_view=None, show=True): 39 """Return a pre-populated git DAG widget.""" 40 model = context.model 41 branch = model.currentbranch 42 # disambiguate between branch names and filenames by using '--' 43 branch_doubledash = (branch + ' --') if branch else '' 44 params = dag.DAG(branch_doubledash, 1000) 45 params.set_arguments(args) 46 47 if existing_view is None: 48 view = GitDAG(context, params) 49 else: 50 view = existing_view 51 view.set_params(params) 52 if params.ref: 53 view.display() 54 if show: 55 view.show() 56 return view 57 58 59class FocusRedirectProxy(object): 60 """Redirect actions from the main widget to child widgets""" 61 62 def __init__(self, *widgets): 63 """Provide proxied widgets; the default widget must be first""" 64 self.widgets = widgets 65 self.default = widgets[0] 66 67 def __getattr__(self, name): 68 return lambda *args, **kwargs: self._forward_action(name, *args, **kwargs) 69 70 def _forward_action(self, name, *args, **kwargs): 71 """Forward the captured action to the focused or default widget""" 72 widget = QtWidgets.QApplication.focusWidget() 73 if widget in self.widgets and hasattr(widget, name): 74 fn = getattr(widget, name) 75 else: 76 fn = getattr(self.default, name) 77 78 return fn(*args, **kwargs) 79 80 81class ViewerMixin(object): 82 """Implementations must provide selected_items()""" 83 84 def __init__(self): 85 self.context = None # provided by implementation 86 self.selected = None 87 self.clicked = None 88 self.menu_actions = None # provided by implementation 89 90 def selected_item(self): 91 """Return the currently selected item""" 92 selected_items = self.selected_items() 93 if not selected_items: 94 return None 95 return selected_items[0] 96 97 def selected_oid(self): 98 item = self.selected_item() 99 if item is None: 100 result = None 101 else: 102 result = item.commit.oid 103 return result 104 105 def selected_oids(self): 106 return [i.commit for i in self.selected_items()] 107 108 def with_oid(self, fn): 109 oid = self.selected_oid() 110 if oid: 111 result = fn(oid) 112 else: 113 result = None 114 return result 115 116 def diff_selected_this(self): 117 clicked_oid = self.clicked.oid 118 selected_oid = self.selected.oid 119 self.diff_commits.emit(selected_oid, clicked_oid) 120 121 def diff_this_selected(self): 122 clicked_oid = self.clicked.oid 123 selected_oid = self.selected.oid 124 self.diff_commits.emit(clicked_oid, selected_oid) 125 126 def cherry_pick(self): 127 context = self.context 128 self.with_oid(lambda oid: cmds.do(cmds.CherryPick, context, [oid])) 129 130 def revert(self): 131 context = self.context 132 self.with_oid(lambda oid: cmds.do(cmds.Revert, context, oid)) 133 134 def copy_to_clipboard(self): 135 self.with_oid(qtutils.set_clipboard) 136 137 def create_branch(self): 138 context = self.context 139 create_new_branch = partial(createbranch.create_new_branch, context) 140 self.with_oid(lambda oid: create_new_branch(revision=oid)) 141 142 def create_tag(self): 143 context = self.context 144 self.with_oid(lambda oid: createtag.create_tag(context, ref=oid)) 145 146 def create_tarball(self): 147 context = self.context 148 self.with_oid(lambda oid: archive.show_save_dialog(context, oid, parent=self)) 149 150 def show_diff(self): 151 context = self.context 152 self.with_oid( 153 lambda oid: difftool.diff_expression( 154 context, self, oid + '^!', hide_expr=False, focus_tree=True 155 ) 156 ) 157 158 def show_dir_diff(self): 159 context = self.context 160 self.with_oid( 161 lambda oid: cmds.difftool_launch( 162 context, left=oid, left_take_magic=True, dir_diff=True 163 ) 164 ) 165 166 def reset_mixed(self): 167 context = self.context 168 self.with_oid(lambda oid: cmds.do(cmds.ResetMixed, context, ref=oid)) 169 170 def reset_keep(self): 171 context = self.context 172 self.with_oid(lambda oid: cmds.do(cmds.ResetKeep, context, ref=oid)) 173 174 def reset_merge(self): 175 context = self.context 176 self.with_oid(lambda oid: cmds.do(cmds.ResetMerge, context, ref=oid)) 177 178 def reset_soft(self): 179 context = self.context 180 self.with_oid(lambda oid: cmds.do(cmds.ResetSoft, context, ref=oid)) 181 182 def reset_hard(self): 183 context = self.context 184 self.with_oid(lambda oid: cmds.do(cmds.ResetHard, context, ref=oid)) 185 186 def restore_worktree(self): 187 context = self.context 188 self.with_oid(lambda oid: cmds.do(cmds.RestoreWorktree, context, ref=oid)) 189 190 def checkout_detached(self): 191 context = self.context 192 self.with_oid(lambda oid: cmds.do(cmds.Checkout, context, [oid])) 193 194 def save_blob_dialog(self): 195 context = self.context 196 self.with_oid(lambda oid: browse.BrowseBranch.browse(context, oid)) 197 198 def update_menu_actions(self, event): 199 selected_items = self.selected_items() 200 item = self.itemAt(event.pos()) 201 if item is None: 202 self.clicked = commit = None 203 else: 204 self.clicked = commit = item.commit 205 206 has_single_selection = len(selected_items) == 1 207 has_selection = bool(selected_items) 208 can_diff = bool( 209 commit and has_single_selection and commit is not selected_items[0].commit 210 ) 211 212 if can_diff: 213 self.selected = selected_items[0].commit 214 else: 215 self.selected = None 216 217 self.menu_actions['diff_this_selected'].setEnabled(can_diff) 218 self.menu_actions['diff_selected_this'].setEnabled(can_diff) 219 self.menu_actions['diff_commit'].setEnabled(has_single_selection) 220 self.menu_actions['diff_commit_all'].setEnabled(has_single_selection) 221 222 self.menu_actions['checkout_detached'].setEnabled(has_single_selection) 223 self.menu_actions['cherry_pick'].setEnabled(has_single_selection) 224 self.menu_actions['copy'].setEnabled(has_single_selection) 225 self.menu_actions['create_branch'].setEnabled(has_single_selection) 226 self.menu_actions['create_patch'].setEnabled(has_selection) 227 self.menu_actions['create_tag'].setEnabled(has_single_selection) 228 self.menu_actions['create_tarball'].setEnabled(has_single_selection) 229 self.menu_actions['reset_mixed'].setEnabled(has_single_selection) 230 self.menu_actions['reset_keep'].setEnabled(has_single_selection) 231 self.menu_actions['reset_merge'].setEnabled(has_single_selection) 232 self.menu_actions['reset_soft'].setEnabled(has_single_selection) 233 self.menu_actions['reset_hard'].setEnabled(has_single_selection) 234 self.menu_actions['restore_worktree'].setEnabled(has_single_selection) 235 self.menu_actions['revert'].setEnabled(has_single_selection) 236 self.menu_actions['save_blob'].setEnabled(has_single_selection) 237 238 def context_menu_event(self, event): 239 self.update_menu_actions(event) 240 menu = qtutils.create_menu(N_('Actions'), self) 241 menu.addAction(self.menu_actions['diff_this_selected']) 242 menu.addAction(self.menu_actions['diff_selected_this']) 243 menu.addAction(self.menu_actions['diff_commit']) 244 menu.addAction(self.menu_actions['diff_commit_all']) 245 menu.addSeparator() 246 menu.addAction(self.menu_actions['create_branch']) 247 menu.addAction(self.menu_actions['create_tag']) 248 menu.addSeparator() 249 menu.addAction(self.menu_actions['cherry_pick']) 250 menu.addAction(self.menu_actions['revert']) 251 menu.addAction(self.menu_actions['create_patch']) 252 menu.addAction(self.menu_actions['create_tarball']) 253 menu.addSeparator() 254 reset_menu = menu.addMenu(N_('Reset')) 255 reset_menu.addAction(self.menu_actions['reset_soft']) 256 reset_menu.addAction(self.menu_actions['reset_mixed']) 257 reset_menu.addAction(self.menu_actions['restore_worktree']) 258 reset_menu.addSeparator() 259 reset_menu.addAction(self.menu_actions['reset_keep']) 260 reset_menu.addAction(self.menu_actions['reset_merge']) 261 reset_menu.addAction(self.menu_actions['reset_hard']) 262 menu.addAction(self.menu_actions['checkout_detached']) 263 menu.addSeparator() 264 menu.addAction(self.menu_actions['save_blob']) 265 menu.addAction(self.menu_actions['copy']) 266 menu.exec_(self.mapToGlobal(event.pos())) 267 268 269def set_icon(icon, action): 270 """"Set the icon for an action and return the action""" 271 action.setIcon(icon) 272 return action 273 274 275def viewer_actions(widget): 276 return { 277 'diff_this_selected': set_icon( 278 icons.compare(), 279 qtutils.add_action( 280 widget, N_('Diff this -> selected'), widget.proxy.diff_this_selected 281 ), 282 ), 283 'diff_selected_this': set_icon( 284 icons.compare(), 285 qtutils.add_action( 286 widget, N_('Diff selected -> this'), widget.proxy.diff_selected_this 287 ), 288 ), 289 'create_branch': set_icon( 290 icons.branch(), 291 qtutils.add_action(widget, N_('Create Branch'), widget.proxy.create_branch), 292 ), 293 'create_patch': set_icon( 294 icons.save(), 295 qtutils.add_action(widget, N_('Create Patch'), widget.proxy.create_patch), 296 ), 297 'create_tag': set_icon( 298 icons.tag(), 299 qtutils.add_action(widget, N_('Create Tag'), widget.proxy.create_tag), 300 ), 301 'create_tarball': set_icon( 302 icons.file_zip(), 303 qtutils.add_action( 304 widget, N_('Save As Tarball/Zip...'), widget.proxy.create_tarball 305 ), 306 ), 307 'cherry_pick': set_icon( 308 icons.style_dialog_apply(), 309 qtutils.add_action(widget, N_('Cherry Pick'), widget.proxy.cherry_pick), 310 ), 311 'revert': set_icon( 312 icons.undo(), qtutils.add_action(widget, N_('Revert'), widget.proxy.revert) 313 ), 314 'diff_commit': set_icon( 315 icons.diff(), 316 qtutils.add_action( 317 widget, N_('Launch Diff Tool'), widget.proxy.show_diff, hotkeys.DIFF 318 ), 319 ), 320 'diff_commit_all': set_icon( 321 icons.diff(), 322 qtutils.add_action( 323 widget, 324 N_('Launch Directory Diff Tool'), 325 widget.proxy.show_dir_diff, 326 hotkeys.DIFF_SECONDARY, 327 ), 328 ), 329 'checkout_detached': qtutils.add_action( 330 widget, N_('Checkout Detached HEAD'), widget.proxy.checkout_detached 331 ), 332 'reset_soft': set_icon( 333 icons.style_dialog_reset(), 334 qtutils.add_action( 335 widget, N_('Reset Branch (Soft)'), widget.proxy.reset_soft 336 ), 337 ), 338 'reset_mixed': set_icon( 339 icons.style_dialog_reset(), 340 qtutils.add_action( 341 widget, N_('Reset Branch and Stage (Mixed)'), widget.proxy.reset_mixed 342 ), 343 ), 344 'reset_keep': set_icon( 345 icons.style_dialog_reset(), 346 qtutils.add_action( 347 widget, 348 N_('Restore Worktree and Reset All (Keep Unstaged Edits)'), 349 widget.proxy.reset_keep, 350 ), 351 ), 352 'reset_merge': set_icon( 353 icons.style_dialog_reset(), 354 qtutils.add_action( 355 widget, 356 N_('Restore Worktree and Reset All (Merge)'), 357 widget.proxy.reset_merge, 358 ), 359 ), 360 'reset_hard': set_icon( 361 icons.style_dialog_reset(), 362 qtutils.add_action( 363 widget, 364 N_('Restore Worktree and Reset All (Hard)'), 365 widget.proxy.reset_hard, 366 ), 367 ), 368 'restore_worktree': set_icon( 369 icons.edit(), 370 qtutils.add_action( 371 widget, N_('Restore Worktree'), widget.proxy.restore_worktree 372 ), 373 ), 374 'save_blob': set_icon( 375 icons.save(), 376 qtutils.add_action( 377 widget, N_('Grab File...'), widget.proxy.save_blob_dialog 378 ), 379 ), 380 'copy': set_icon( 381 icons.copy(), 382 qtutils.add_action( 383 widget, 384 N_('Copy SHA-1'), 385 widget.proxy.copy_to_clipboard, 386 hotkeys.COPY_SHA1, 387 ), 388 ), 389 } 390 391 392class CommitTreeWidgetItem(QtWidgets.QTreeWidgetItem): 393 def __init__(self, commit, parent=None): 394 QtWidgets.QTreeWidgetItem.__init__(self, parent) 395 self.commit = commit 396 self.setText(0, commit.summary) 397 self.setText(1, commit.author) 398 self.setText(2, commit.authdate) 399 400 401# pylint: disable=too-many-ancestors 402class CommitTreeWidget(standard.TreeWidget, ViewerMixin): 403 404 diff_commits = Signal(object, object) 405 zoom_to_fit = Signal() 406 407 def __init__(self, context, notifier, parent): 408 standard.TreeWidget.__init__(self, parent) 409 ViewerMixin.__init__(self) 410 411 self.setSelectionMode(self.ExtendedSelection) 412 self.setHeaderLabels([N_('Summary'), N_('Author'), N_('Date, Time')]) 413 414 self.context = context 415 self.oidmap = {} 416 self.menu_actions = None 417 self.notifier = notifier 418 self.selecting = False 419 self.commits = [] 420 self._adjust_columns = False 421 422 self.action_up = qtutils.add_action( 423 self, N_('Go Up'), self.go_up, hotkeys.MOVE_UP 424 ) 425 426 self.action_down = qtutils.add_action( 427 self, N_('Go Down'), self.go_down, hotkeys.MOVE_DOWN 428 ) 429 430 self.zoom_to_fit_action = qtutils.add_action( 431 self, N_('Zoom to Fit'), self.zoom_to_fit.emit, hotkeys.FIT 432 ) 433 434 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected) 435 # pylint: disable=no-member 436 self.itemSelectionChanged.connect(self.selection_changed) 437 438 def export_state(self): 439 """Export the widget's state""" 440 # The base class method is intentionally overridden because we only 441 # care about the details below for this subwidget. 442 state = {} 443 state['column_widths'] = self.column_widths() 444 return state 445 446 def apply_state(self, state): 447 """Apply the exported widget state""" 448 try: 449 column_widths = state['column_widths'] 450 except (KeyError, ValueError): 451 column_widths = None 452 if column_widths: 453 self.set_column_widths(column_widths) 454 else: 455 # Defer showing the columns until we are shown, and our true width 456 # is known. Calling adjust_columns() here ends up with the wrong 457 # answer because we have not yet been parented to the layout. 458 # We set this flag that we process once during our initial 459 # showEvent(). 460 self._adjust_columns = True 461 return True 462 463 # Qt overrides 464 def showEvent(self, event): 465 """Override QWidget::showEvent() to size columns when we are shown""" 466 if self._adjust_columns: 467 self._adjust_columns = False 468 width = self.width() 469 two_thirds = (width * 2) // 3 470 one_sixth = width // 6 471 472 self.setColumnWidth(0, two_thirds) 473 self.setColumnWidth(1, one_sixth) 474 self.setColumnWidth(2, one_sixth) 475 return standard.TreeWidget.showEvent(self, event) 476 477 # ViewerMixin 478 def go_up(self): 479 self.goto(self.itemAbove) 480 481 def go_down(self): 482 self.goto(self.itemBelow) 483 484 def goto(self, finder): 485 items = self.selected_items() 486 item = items[0] if items else None 487 if item is None: 488 return 489 found = finder(item) 490 if found: 491 self.select([found.commit.oid]) 492 493 def selected_commit_range(self): 494 selected_items = self.selected_items() 495 if not selected_items: 496 return None, None 497 return selected_items[-1].commit.oid, selected_items[0].commit.oid 498 499 def set_selecting(self, selecting): 500 self.selecting = selecting 501 502 def selection_changed(self): 503 items = self.selected_items() 504 if not items: 505 return 506 self.set_selecting(True) 507 self.notifier.notify_observers(diff.COMMITS_SELECTED, [i.commit for i in items]) 508 self.set_selecting(False) 509 510 def commits_selected(self, commits): 511 if self.selecting: 512 return 513 with qtutils.BlockSignals(self): 514 self.select([commit.oid for commit in commits]) 515 516 def select(self, oids): 517 if not oids: 518 return 519 self.clearSelection() 520 for oid in oids: 521 try: 522 item = self.oidmap[oid] 523 except KeyError: 524 continue 525 self.scrollToItem(item) 526 item.setSelected(True) 527 528 def clear(self): 529 QtWidgets.QTreeWidget.clear(self) 530 self.oidmap.clear() 531 self.commits = [] 532 533 def add_commits(self, commits): 534 self.commits.extend(commits) 535 items = [] 536 for c in reversed(commits): 537 item = CommitTreeWidgetItem(c) 538 items.append(item) 539 self.oidmap[c.oid] = item 540 for tag in c.tags: 541 self.oidmap[tag] = item 542 self.insertTopLevelItems(0, items) 543 544 def create_patch(self): 545 items = self.selectedItems() 546 if not items: 547 return 548 context = self.context 549 oids = [item.commit.oid for item in reversed(items)] 550 all_oids = [c.oid for c in self.commits] 551 cmds.do(cmds.FormatPatch, context, oids, all_oids) 552 553 # Qt overrides 554 def contextMenuEvent(self, event): 555 self.context_menu_event(event) 556 557 def mousePressEvent(self, event): 558 if event.button() == Qt.RightButton: 559 event.accept() 560 return 561 QtWidgets.QTreeWidget.mousePressEvent(self, event) 562 563 564class GitDAG(standard.MainWindow): 565 """The git-dag widget.""" 566 567 updated = Signal() 568 569 def __init__(self, context, params, parent=None): 570 super(GitDAG, self).__init__(parent) 571 572 self.setMinimumSize(420, 420) 573 574 # change when widgets are added/removed 575 self.widget_version = 2 576 self.context = context 577 self.params = params 578 self.model = context.model 579 580 self.commits = {} 581 self.commit_list = [] 582 self.selection = [] 583 self.old_refs = set() 584 self.old_oids = None 585 self.old_count = 0 586 self.force_refresh = False 587 588 self.thread = None 589 self.revtext = completion.GitLogLineEdit(context) 590 self.maxresults = standard.SpinBox() 591 592 self.zoom_out = qtutils.create_action_button( 593 tooltip=N_('Zoom Out'), icon=icons.zoom_out() 594 ) 595 596 self.zoom_in = qtutils.create_action_button( 597 tooltip=N_('Zoom In'), icon=icons.zoom_in() 598 ) 599 600 self.zoom_to_fit = qtutils.create_action_button( 601 tooltip=N_('Zoom to Fit'), icon=icons.zoom_fit_best() 602 ) 603 604 self.notifier = notifier = observable.Observable() 605 self.notifier.refs_updated = refs_updated = 'refs_updated' 606 self.notifier.add_observer(refs_updated, self.display) 607 self.notifier.add_observer(filelist.HISTORIES_SELECTED, self.histories_selected) 608 self.notifier.add_observer(filelist.DIFFTOOL_SELECTED, self.difftool_selected) 609 self.notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected) 610 611 self.treewidget = CommitTreeWidget(context, notifier, self) 612 self.diffwidget = diff.DiffWidget(context, notifier, self, is_commit=True) 613 self.filewidget = filelist.FileWidget(context, notifier, self) 614 self.graphview = GraphView(context, notifier, self) 615 616 self.proxy = FocusRedirectProxy( 617 self.treewidget, self.graphview, self.filewidget 618 ) 619 620 self.viewer_actions = actions = viewer_actions(self) 621 self.treewidget.menu_actions = actions 622 self.graphview.menu_actions = actions 623 624 self.controls_layout = qtutils.hbox( 625 defs.no_margin, defs.spacing, self.revtext, self.maxresults 626 ) 627 628 self.controls_widget = QtWidgets.QWidget() 629 self.controls_widget.setLayout(self.controls_layout) 630 631 self.log_dock = qtutils.create_dock(N_('Log'), self, stretch=False) 632 self.log_dock.setWidget(self.treewidget) 633 log_dock_titlebar = self.log_dock.titleBarWidget() 634 log_dock_titlebar.add_corner_widget(self.controls_widget) 635 636 self.file_dock = qtutils.create_dock(N_('Files'), self) 637 self.file_dock.setWidget(self.filewidget) 638 639 self.diff_dock = qtutils.create_dock(N_('Diff'), self) 640 self.diff_dock.setWidget(self.diffwidget) 641 642 self.graph_controls_layout = qtutils.hbox( 643 defs.no_margin, 644 defs.button_spacing, 645 self.zoom_out, 646 self.zoom_in, 647 self.zoom_to_fit, 648 defs.spacing, 649 ) 650 651 self.graph_controls_widget = QtWidgets.QWidget() 652 self.graph_controls_widget.setLayout(self.graph_controls_layout) 653 654 self.graphview_dock = qtutils.create_dock(N_('Graph'), self) 655 self.graphview_dock.setWidget(self.graphview) 656 graph_titlebar = self.graphview_dock.titleBarWidget() 657 graph_titlebar.add_corner_widget(self.graph_controls_widget) 658 659 self.lock_layout_action = qtutils.add_action_bool( 660 self, N_('Lock Layout'), self.set_lock_layout, False 661 ) 662 663 self.refresh_action = qtutils.add_action( 664 self, N_('Refresh'), self.refresh, hotkeys.REFRESH 665 ) 666 667 # Create the application menu 668 self.menubar = QtWidgets.QMenuBar(self) 669 self.setMenuBar(self.menubar) 670 671 # View Menu 672 self.view_menu = qtutils.add_menu(N_('View'), self.menubar) 673 self.view_menu.addAction(self.refresh_action) 674 self.view_menu.addAction(self.log_dock.toggleViewAction()) 675 self.view_menu.addAction(self.graphview_dock.toggleViewAction()) 676 self.view_menu.addAction(self.diff_dock.toggleViewAction()) 677 self.view_menu.addAction(self.file_dock.toggleViewAction()) 678 self.view_menu.addSeparator() 679 self.view_menu.addAction(self.lock_layout_action) 680 681 left = Qt.LeftDockWidgetArea 682 right = Qt.RightDockWidgetArea 683 self.addDockWidget(left, self.log_dock) 684 self.addDockWidget(left, self.diff_dock) 685 self.addDockWidget(right, self.graphview_dock) 686 self.addDockWidget(right, self.file_dock) 687 688 # Also re-loads dag.* from the saved state 689 self.init_state(context.settings, self.resize_to_desktop) 690 691 qtutils.connect_button(self.zoom_out, self.graphview.zoom_out) 692 qtutils.connect_button(self.zoom_in, self.graphview.zoom_in) 693 qtutils.connect_button(self.zoom_to_fit, self.graphview.zoom_to_fit) 694 695 self.treewidget.zoom_to_fit.connect(self.graphview.zoom_to_fit) 696 self.treewidget.diff_commits.connect(self.diff_commits) 697 self.graphview.diff_commits.connect(self.diff_commits) 698 self.filewidget.grab_file.connect(self.grab_file) 699 700 # pylint: disable=no-member 701 self.maxresults.editingFinished.connect(self.display) 702 703 self.revtext.textChanged.connect(self.text_changed) 704 self.revtext.activated.connect(self.display) 705 self.revtext.enter.connect(self.display) 706 self.revtext.down.connect(self.focus_tree) 707 708 # The model is updated in another thread so use 709 # signals/slots to bring control back to the main GUI thread 710 self.model.add_observer(self.model.message_updated, self.updated.emit) 711 self.updated.connect(self.model_updated, type=Qt.QueuedConnection) 712 713 qtutils.add_action(self, 'Focus', self.focus_input, hotkeys.FOCUS) 714 qtutils.add_close_action(self) 715 716 self.set_params(params) 717 718 def set_params(self, params): 719 context = self.context 720 self.params = params 721 722 # Update fields affected by model 723 self.revtext.setText(params.ref) 724 self.maxresults.setValue(params.count) 725 self.update_window_title() 726 727 if self.thread is not None: 728 self.thread.stop() 729 730 self.thread = ReaderThread(context, params, self) 731 732 thread = self.thread 733 thread.begin.connect(self.thread_begin, type=Qt.QueuedConnection) 734 thread.status.connect(self.thread_status, type=Qt.QueuedConnection) 735 thread.add.connect(self.add_commits, type=Qt.QueuedConnection) 736 thread.end.connect(self.thread_end, type=Qt.QueuedConnection) 737 738 def focus_input(self): 739 self.revtext.setFocus() 740 741 def focus_tree(self): 742 self.treewidget.setFocus() 743 744 def text_changed(self, txt): 745 self.params.ref = txt 746 self.update_window_title() 747 748 def update_window_title(self): 749 project = self.model.project 750 if self.params.ref: 751 self.setWindowTitle( 752 N_('%(project)s: %(ref)s - DAG') 753 % dict(project=project, ref=self.params.ref) 754 ) 755 else: 756 self.setWindowTitle(project + N_(' - DAG')) 757 758 def export_state(self): 759 state = standard.MainWindow.export_state(self) 760 state['count'] = self.params.count 761 state['log'] = self.treewidget.export_state() 762 return state 763 764 def apply_state(self, state): 765 result = standard.MainWindow.apply_state(self, state) 766 try: 767 count = state['count'] 768 if self.params.overridden('count'): 769 count = self.params.count 770 except (KeyError, TypeError, ValueError, AttributeError): 771 count = self.params.count 772 result = False 773 self.params.set_count(count) 774 self.lock_layout_action.setChecked(state.get('lock_layout', False)) 775 776 try: 777 log_state = state['log'] 778 except (KeyError, ValueError): 779 log_state = None 780 if log_state: 781 self.treewidget.apply_state(log_state) 782 783 return result 784 785 def model_updated(self): 786 self.display() 787 self.update_window_title() 788 789 def refresh(self): 790 """Unconditionally refresh the DAG""" 791 # self.force_refresh triggers an Unconditional redraw 792 self.force_refresh = True 793 cmds.do(cmds.Refresh, self.context) 794 self.force_refresh = False 795 796 def display(self): 797 """Update the view when the Git refs change""" 798 ref = get(self.revtext) 799 count = get(self.maxresults) 800 context = self.context 801 model = self.model 802 # The DAG tries to avoid updating when the object IDs have not 803 # changed. Without doing this the DAG constantly redraws itself 804 # whenever inotify sends update events, which hurts usability. 805 # 806 # To minimize redraws we leverage `git rev-parse`. The strategy is to 807 # use `git rev-parse` on the input line, which converts each argument 808 # into object IDs. From there it's a simple matter of detecting when 809 # the object IDs changed. 810 # 811 # In addition to object IDs, we also need to know when the set of 812 # named references (branches, tags) changes so that an update is 813 # triggered when new branches and tags are created. 814 refs = set(model.local_branches + model.remote_branches + model.tags) 815 argv = utils.shell_split(ref or 'HEAD') 816 oids = gitcmds.parse_refs(context, argv) 817 update = ( 818 self.force_refresh 819 or count != self.old_count 820 or oids != self.old_oids 821 or refs != self.old_refs 822 ) 823 if update: 824 self.thread.stop() 825 self.params.set_ref(ref) 826 self.params.set_count(count) 827 self.thread.start() 828 829 self.old_oids = oids 830 self.old_count = count 831 self.old_refs = refs 832 833 def commits_selected(self, commits): 834 if commits: 835 self.selection = commits 836 837 def clear(self): 838 self.commits.clear() 839 self.commit_list = [] 840 self.graphview.clear() 841 self.treewidget.clear() 842 843 def add_commits(self, commits): 844 self.commit_list.extend(commits) 845 # Keep track of commits 846 for commit_obj in commits: 847 self.commits[commit_obj.oid] = commit_obj 848 for tag in commit_obj.tags: 849 self.commits[tag] = commit_obj 850 self.graphview.add_commits(commits) 851 self.treewidget.add_commits(commits) 852 853 def thread_begin(self): 854 self.clear() 855 856 def thread_end(self): 857 self.restore_selection() 858 859 def thread_status(self, successful): 860 self.revtext.hint.set_error(not successful) 861 862 def restore_selection(self): 863 selection = self.selection 864 try: 865 commit_obj = self.commit_list[-1] 866 except IndexError: 867 # No commits, exist, early-out 868 return 869 870 new_commits = [self.commits.get(s.oid, None) for s in selection] 871 new_commits = [c for c in new_commits if c is not None] 872 if new_commits: 873 # The old selection exists in the new state 874 self.notifier.notify_observers(diff.COMMITS_SELECTED, new_commits) 875 else: 876 # The old selection is now empty. Select the top-most commit 877 self.notifier.notify_observers(diff.COMMITS_SELECTED, [commit_obj]) 878 879 self.graphview.set_initial_view() 880 881 def diff_commits(self, a, b): 882 paths = self.params.paths() 883 if paths: 884 cmds.difftool_launch(self.context, left=a, right=b, paths=paths) 885 else: 886 difftool.diff_commits(self.context, self, a, b) 887 888 # Qt overrides 889 def closeEvent(self, event): 890 self.revtext.close_popup() 891 self.thread.stop() 892 standard.MainWindow.closeEvent(self, event) 893 894 def histories_selected(self, histories): 895 argv = [self.model.currentbranch, '--'] 896 argv.extend(histories) 897 text = core.list2cmdline(argv) 898 self.revtext.setText(text) 899 self.display() 900 901 def difftool_selected(self, files): 902 bottom, top = self.treewidget.selected_commit_range() 903 if not top: 904 return 905 cmds.difftool_launch( 906 self.context, left=bottom, left_take_parent=True, right=top, paths=files 907 ) 908 909 def grab_file(self, filename): 910 """Save the selected file from the filelist widget""" 911 oid = self.treewidget.selected_oid() 912 model = browse.BrowseModel(oid, filename=filename) 913 browse.save_path(self.context, filename, model) 914 915 916class ReaderThread(QtCore.QThread): 917 begin = Signal() 918 add = Signal(object) 919 end = Signal() 920 status = Signal(object) 921 922 def __init__(self, context, params, parent): 923 QtCore.QThread.__init__(self, parent) 924 self.context = context 925 self.params = params 926 self._abort = False 927 self._stop = False 928 self._mutex = QtCore.QMutex() 929 self._condition = QtCore.QWaitCondition() 930 931 def run(self): 932 context = self.context 933 repo = dag.RepoReader(context, self.params) 934 repo.reset() 935 self.begin.emit() 936 commits = [] 937 for c in repo.get(): 938 self._mutex.lock() 939 if self._stop: 940 self._condition.wait(self._mutex) 941 self._mutex.unlock() 942 if self._abort: 943 repo.reset() 944 return 945 commits.append(c) 946 if len(commits) >= 512: 947 self.add.emit(commits) 948 commits = [] 949 950 self.status.emit(repo.returncode == 0) 951 if commits: 952 self.add.emit(commits) 953 self.end.emit() 954 955 def start(self): 956 self._abort = False 957 self._stop = False 958 QtCore.QThread.start(self) 959 960 def pause(self): 961 self._mutex.lock() 962 self._stop = True 963 self._mutex.unlock() 964 965 def resume(self): 966 self._mutex.lock() 967 self._stop = False 968 self._mutex.unlock() 969 self._condition.wakeOne() 970 971 def stop(self): 972 self._abort = True 973 self.wait() 974 975 976class Cache(object): 977 978 _label_font = None 979 980 @classmethod 981 def label_font(cls): 982 font = cls._label_font 983 if font is None: 984 font = cls._label_font = QtWidgets.QApplication.font() 985 font.setPointSize(6) 986 return font 987 988 989class Edge(QtWidgets.QGraphicsItem): 990 item_type = QtWidgets.QGraphicsItem.UserType + 1 991 992 def __init__(self, source, dest): 993 994 QtWidgets.QGraphicsItem.__init__(self) 995 996 self.setAcceptedMouseButtons(Qt.NoButton) 997 self.source = source 998 self.dest = dest 999 self.commit = source.commit 1000 self.setZValue(-2) 1001 1002 self.recompute_bound() 1003 self.path = None 1004 self.path_valid = False 1005 1006 # Choose a new color for new branch edges 1007 if self.source.x() < self.dest.x(): 1008 color = EdgeColor.cycle() 1009 line = Qt.SolidLine 1010 elif self.source.x() != self.dest.x(): 1011 color = EdgeColor.current() 1012 line = Qt.SolidLine 1013 else: 1014 color = EdgeColor.current() 1015 line = Qt.SolidLine 1016 1017 self.pen = QtGui.QPen(color, 4.0, line, Qt.SquareCap, Qt.RoundJoin) 1018 1019 def recompute_bound(self): 1020 dest_pt = Commit.item_bbox.center() 1021 1022 self.source_pt = self.mapFromItem(self.source, dest_pt) 1023 self.dest_pt = self.mapFromItem(self.dest, dest_pt) 1024 self.line = QtCore.QLineF(self.source_pt, self.dest_pt) 1025 1026 width = self.dest_pt.x() - self.source_pt.x() 1027 height = self.dest_pt.y() - self.source_pt.y() 1028 rect = QtCore.QRectF(self.source_pt, QtCore.QSizeF(width, height)) 1029 self.bound = rect.normalized() 1030 1031 def commits_were_invalidated(self): 1032 self.recompute_bound() 1033 self.prepareGeometryChange() 1034 # The path should not be recomputed immediately because just small part 1035 # of DAG is actually shown at same time. It will be recomputed on 1036 # demand in course of 'paint' method. 1037 self.path_valid = False 1038 # Hence, just queue redrawing. 1039 self.update() 1040 1041 # Qt overrides 1042 def type(self): 1043 return self.item_type 1044 1045 def boundingRect(self): 1046 return self.bound 1047 1048 def recompute_path(self): 1049 QRectF = QtCore.QRectF 1050 QPointF = QtCore.QPointF 1051 1052 arc_rect = 10 1053 connector_length = 5 1054 1055 path = QtGui.QPainterPath() 1056 1057 if self.source.x() == self.dest.x(): 1058 path.moveTo(self.source.x(), self.source.y()) 1059 path.lineTo(self.dest.x(), self.dest.y()) 1060 else: 1061 # Define points starting from source 1062 point1 = QPointF(self.source.x(), self.source.y()) 1063 point2 = QPointF(point1.x(), point1.y() - connector_length) 1064 point3 = QPointF(point2.x() + arc_rect, point2.y() - arc_rect) 1065 1066 # Define points starting from dest 1067 point4 = QPointF(self.dest.x(), self.dest.y()) 1068 point5 = QPointF(point4.x(), point3.y() - arc_rect) 1069 point6 = QPointF(point5.x() - arc_rect, point5.y() + arc_rect) 1070 1071 start_angle_arc1 = 180 1072 span_angle_arc1 = 90 1073 start_angle_arc2 = 90 1074 span_angle_arc2 = -90 1075 1076 # If the dest is at the left of the source, then we 1077 # need to reverse some values 1078 if self.source.x() > self.dest.x(): 1079 point3 = QPointF(point2.x() - arc_rect, point3.y()) 1080 point6 = QPointF(point5.x() + arc_rect, point6.y()) 1081 1082 span_angle_arc1 = 90 1083 1084 path.moveTo(point1) 1085 path.lineTo(point2) 1086 path.arcTo(QRectF(point2, point3), start_angle_arc1, span_angle_arc1) 1087 path.lineTo(point6) 1088 path.arcTo(QRectF(point6, point5), start_angle_arc2, span_angle_arc2) 1089 path.lineTo(point4) 1090 1091 self.path = path 1092 self.path_valid = True 1093 1094 def paint(self, painter, _option, _widget): 1095 if not self.path_valid: 1096 self.recompute_path() 1097 painter.setPen(self.pen) 1098 painter.drawPath(self.path) 1099 1100 1101class EdgeColor(object): 1102 """An edge color factory""" 1103 1104 current_color_index = 0 1105 colors = [ 1106 QtGui.QColor(Qt.red), 1107 QtGui.QColor(Qt.green), 1108 QtGui.QColor(Qt.blue), 1109 QtGui.QColor(Qt.black), 1110 QtGui.QColor(Qt.darkRed), 1111 QtGui.QColor(Qt.darkGreen), 1112 QtGui.QColor(Qt.darkBlue), 1113 QtGui.QColor(Qt.cyan), 1114 QtGui.QColor(Qt.magenta), 1115 # Orange; Qt.yellow is too low-contrast 1116 qtutils.rgba(0xFF, 0x66, 0x00), 1117 QtGui.QColor(Qt.gray), 1118 QtGui.QColor(Qt.darkCyan), 1119 QtGui.QColor(Qt.darkMagenta), 1120 QtGui.QColor(Qt.darkYellow), 1121 QtGui.QColor(Qt.darkGray), 1122 ] 1123 1124 @classmethod 1125 def cycle(cls): 1126 cls.current_color_index += 1 1127 cls.current_color_index %= len(cls.colors) 1128 color = cls.colors[cls.current_color_index] 1129 color.setAlpha(128) 1130 return color 1131 1132 @classmethod 1133 def current(cls): 1134 return cls.colors[cls.current_color_index] 1135 1136 @classmethod 1137 def reset(cls): 1138 cls.current_color_index = 0 1139 1140 1141class Commit(QtWidgets.QGraphicsItem): 1142 item_type = QtWidgets.QGraphicsItem.UserType + 2 1143 commit_radius = 12.0 1144 merge_radius = 18.0 1145 1146 item_shape = QtGui.QPainterPath() 1147 item_shape.addRect( 1148 commit_radius / -2.0, commit_radius / -2.0, commit_radius, commit_radius 1149 ) 1150 item_bbox = item_shape.boundingRect() 1151 1152 inner_rect = QtGui.QPainterPath() 1153 inner_rect.addRect( 1154 commit_radius / -2.0 + 2.0, 1155 commit_radius / -2.0 + 2.0, 1156 commit_radius - 4.0, 1157 commit_radius - 4.0, 1158 ) 1159 inner_rect = inner_rect.boundingRect() 1160 1161 commit_color = QtGui.QColor(Qt.white) 1162 outline_color = commit_color.darker() 1163 merge_color = QtGui.QColor(Qt.lightGray) 1164 1165 commit_selected_color = QtGui.QColor(Qt.green) 1166 selected_outline_color = commit_selected_color.darker() 1167 1168 commit_pen = QtGui.QPen() 1169 commit_pen.setWidth(1) 1170 commit_pen.setColor(outline_color) 1171 1172 def __init__( 1173 self, 1174 commit, 1175 notifier, 1176 selectable=QtWidgets.QGraphicsItem.ItemIsSelectable, 1177 cursor=Qt.PointingHandCursor, 1178 xpos=commit_radius / 2.0 + 1.0, 1179 cached_commit_color=commit_color, 1180 cached_merge_color=merge_color, 1181 ): 1182 1183 QtWidgets.QGraphicsItem.__init__(self) 1184 1185 self.commit = commit 1186 self.notifier = notifier 1187 self.selected = False 1188 1189 self.setZValue(0) 1190 self.setFlag(selectable) 1191 self.setCursor(cursor) 1192 self.setToolTip(commit.oid[:12] + ': ' + commit.summary) 1193 1194 if commit.tags: 1195 self.label = label = Label(commit) 1196 label.setParentItem(self) 1197 label.setPos(xpos + 1, -self.commit_radius / 2.0) 1198 else: 1199 self.label = None 1200 1201 if len(commit.parents) > 1: 1202 self.brush = cached_merge_color 1203 else: 1204 self.brush = cached_commit_color 1205 1206 self.pressed = False 1207 self.dragged = False 1208 1209 self.edges = {} 1210 1211 def blockSignals(self, blocked): 1212 self.notifier.notification_enabled = not blocked 1213 1214 def itemChange(self, change, value): 1215 if change == QtWidgets.QGraphicsItem.ItemSelectedHasChanged: 1216 # Broadcast selection to other widgets 1217 selected_items = self.scene().selectedItems() 1218 commits = [item.commit for item in selected_items] 1219 self.scene().parent().set_selecting(True) 1220 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits) 1221 self.scene().parent().set_selecting(False) 1222 1223 # Cache the pen for use in paint() 1224 if value: 1225 self.brush = self.commit_selected_color 1226 color = self.selected_outline_color 1227 else: 1228 if len(self.commit.parents) > 1: 1229 self.brush = self.merge_color 1230 else: 1231 self.brush = self.commit_color 1232 color = self.outline_color 1233 commit_pen = QtGui.QPen() 1234 commit_pen.setWidth(1.0) 1235 commit_pen.setColor(color) 1236 self.commit_pen = commit_pen 1237 1238 return QtWidgets.QGraphicsItem.itemChange(self, change, value) 1239 1240 def type(self): 1241 return self.item_type 1242 1243 def boundingRect(self): 1244 return self.item_bbox 1245 1246 def shape(self): 1247 return self.item_shape 1248 1249 def paint(self, painter, option, _widget): 1250 1251 # Do not draw outside the exposed rect 1252 painter.setClipRect(option.exposedRect) 1253 1254 # Draw ellipse 1255 painter.setPen(self.commit_pen) 1256 painter.setBrush(self.brush) 1257 painter.drawEllipse(self.inner_rect) 1258 1259 def mousePressEvent(self, event): 1260 QtWidgets.QGraphicsItem.mousePressEvent(self, event) 1261 self.pressed = True 1262 self.selected = self.isSelected() 1263 1264 def mouseMoveEvent(self, event): 1265 if self.pressed: 1266 self.dragged = True 1267 QtWidgets.QGraphicsItem.mouseMoveEvent(self, event) 1268 1269 def mouseReleaseEvent(self, event): 1270 QtWidgets.QGraphicsItem.mouseReleaseEvent(self, event) 1271 if not self.dragged and self.selected and event.button() == Qt.LeftButton: 1272 return 1273 self.pressed = False 1274 self.dragged = False 1275 1276 1277class Label(QtWidgets.QGraphicsItem): 1278 1279 item_type = QtWidgets.QGraphicsItem.UserType + 3 1280 1281 head_color = QtGui.QColor(Qt.green) 1282 other_color = QtGui.QColor(Qt.white) 1283 remote_color = QtGui.QColor(Qt.yellow) 1284 1285 head_pen = QtGui.QPen() 1286 head_pen.setColor(head_color.darker().darker()) 1287 head_pen.setWidth(1) 1288 1289 text_pen = QtGui.QPen() 1290 text_pen.setColor(QtGui.QColor(Qt.darkGray)) 1291 text_pen.setWidth(1) 1292 1293 alpha = 180 1294 head_color.setAlpha(alpha) 1295 other_color.setAlpha(alpha) 1296 remote_color.setAlpha(alpha) 1297 1298 border = 2 1299 item_spacing = 5 1300 text_offset = 1 1301 1302 def __init__(self, commit): 1303 QtWidgets.QGraphicsItem.__init__(self) 1304 self.setZValue(-1) 1305 self.commit = commit 1306 1307 def type(self): 1308 return self.item_type 1309 1310 def boundingRect(self, cache=Cache): 1311 QPainterPath = QtGui.QPainterPath 1312 QRectF = QtCore.QRectF 1313 1314 width = 72 1315 height = 18 1316 current_width = 0 1317 spacing = self.item_spacing 1318 border = self.border + self.text_offset # text offset=1 in paint() 1319 1320 font = cache.label_font() 1321 item_shape = QPainterPath() 1322 1323 base_rect = QRectF(0, 0, width, height) 1324 base_rect = base_rect.adjusted(-border, -border, border, border) 1325 item_shape.addRect(base_rect) 1326 1327 for tag in self.commit.tags: 1328 text_shape = QPainterPath() 1329 text_shape.addText(current_width, 0, font, tag) 1330 text_rect = text_shape.boundingRect() 1331 box_rect = text_rect.adjusted(-border, -border, border, border) 1332 item_shape.addRect(box_rect) 1333 current_width = item_shape.boundingRect().width() + spacing 1334 1335 return item_shape.boundingRect() 1336 1337 def paint(self, painter, _option, _widget, cache=Cache): 1338 # Draw tags and branches 1339 font = cache.label_font() 1340 painter.setFont(font) 1341 1342 current_width = 0 1343 border = self.border 1344 offset = self.text_offset 1345 spacing = self.item_spacing 1346 QRectF = QtCore.QRectF 1347 1348 HEAD = 'HEAD' 1349 remotes_prefix = 'remotes/' 1350 tags_prefix = 'tags/' 1351 heads_prefix = 'heads/' 1352 remotes_len = len(remotes_prefix) 1353 tags_len = len(tags_prefix) 1354 heads_len = len(heads_prefix) 1355 1356 for tag in self.commit.tags: 1357 if tag == HEAD: 1358 painter.setPen(self.text_pen) 1359 painter.setBrush(self.remote_color) 1360 elif tag.startswith(remotes_prefix): 1361 tag = tag[remotes_len:] 1362 painter.setPen(self.text_pen) 1363 painter.setBrush(self.other_color) 1364 elif tag.startswith(tags_prefix): 1365 tag = tag[tags_len:] 1366 painter.setPen(self.text_pen) 1367 painter.setBrush(self.remote_color) 1368 elif tag.startswith(heads_prefix): 1369 tag = tag[heads_len:] 1370 painter.setPen(self.head_pen) 1371 painter.setBrush(self.head_color) 1372 else: 1373 painter.setPen(self.text_pen) 1374 painter.setBrush(self.other_color) 1375 1376 text_rect = painter.boundingRect( 1377 QRectF(current_width, 0, 0, 0), Qt.TextSingleLine, tag 1378 ) 1379 box_rect = text_rect.adjusted(-offset, -offset, offset, offset) 1380 1381 painter.drawRoundedRect(box_rect, border, border) 1382 painter.drawText(text_rect, Qt.TextSingleLine, tag) 1383 current_width += text_rect.width() + spacing 1384 1385 1386# pylint: disable=too-many-ancestors 1387class GraphView(QtWidgets.QGraphicsView, ViewerMixin): 1388 1389 diff_commits = Signal(object, object) 1390 1391 x_adjust = int(Commit.commit_radius * 4 / 3) 1392 y_adjust = int(Commit.commit_radius * 4 / 3) 1393 1394 x_off = -18 1395 y_off = -24 1396 1397 def __init__(self, context, notifier, parent): 1398 QtWidgets.QGraphicsView.__init__(self, parent) 1399 ViewerMixin.__init__(self) 1400 1401 highlight = self.palette().color(QtGui.QPalette.Highlight) 1402 Commit.commit_selected_color = highlight 1403 Commit.selected_outline_color = highlight.darker() 1404 1405 self.context = context 1406 self.columns = {} 1407 self.selection_list = [] 1408 self.menu_actions = None 1409 self.notifier = notifier 1410 self.commits = [] 1411 self.items = {} 1412 self.mouse_start = [0, 0] 1413 self.saved_matrix = self.transform() 1414 self.max_column = 0 1415 self.min_column = 0 1416 self.frontier = {} 1417 self.tagged_cells = set() 1418 1419 self.x_start = 24 1420 self.x_min = 24 1421 self.x_offsets = collections.defaultdict(lambda: self.x_min) 1422 1423 self.is_panning = False 1424 self.pressed = False 1425 self.selecting = False 1426 self.last_mouse = [0, 0] 1427 self.zoom = 2 1428 self.setDragMode(self.RubberBandDrag) 1429 1430 scene = QtWidgets.QGraphicsScene(self) 1431 scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex) 1432 self.setScene(scene) 1433 1434 self.setRenderHint(QtGui.QPainter.Antialiasing) 1435 self.setViewportUpdateMode(self.BoundingRectViewportUpdate) 1436 self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground) 1437 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse) 1438 self.setResizeAnchor(QtWidgets.QGraphicsView.NoAnchor) 1439 self.setBackgroundBrush(QtGui.QColor(Qt.white)) 1440 1441 qtutils.add_action( 1442 self, 1443 N_('Zoom In'), 1444 self.zoom_in, 1445 hotkeys.ZOOM_IN, 1446 hotkeys.ZOOM_IN_SECONDARY, 1447 ) 1448 1449 qtutils.add_action(self, N_('Zoom Out'), self.zoom_out, hotkeys.ZOOM_OUT) 1450 1451 qtutils.add_action(self, N_('Zoom to Fit'), self.zoom_to_fit, hotkeys.FIT) 1452 1453 qtutils.add_action( 1454 self, N_('Select Parent'), self._select_parent, hotkeys.MOVE_DOWN_TERTIARY 1455 ) 1456 1457 qtutils.add_action( 1458 self, 1459 N_('Select Oldest Parent'), 1460 self._select_oldest_parent, 1461 hotkeys.MOVE_DOWN, 1462 ) 1463 1464 qtutils.add_action( 1465 self, N_('Select Child'), self._select_child, hotkeys.MOVE_UP_TERTIARY 1466 ) 1467 1468 qtutils.add_action( 1469 self, N_('Select Newest Child'), self._select_newest_child, hotkeys.MOVE_UP 1470 ) 1471 1472 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected) 1473 1474 def clear(self): 1475 EdgeColor.reset() 1476 self.scene().clear() 1477 self.selection_list = [] 1478 self.items.clear() 1479 self.x_offsets.clear() 1480 self.x_min = 24 1481 self.commits = [] 1482 1483 # ViewerMixin interface 1484 def selected_items(self): 1485 """Return the currently selected items""" 1486 return self.scene().selectedItems() 1487 1488 def zoom_in(self): 1489 self.scale_view(1.5) 1490 1491 def zoom_out(self): 1492 self.scale_view(1.0 / 1.5) 1493 1494 def commits_selected(self, commits): 1495 if self.selecting: 1496 return 1497 self.select([commit.oid for commit in commits]) 1498 1499 def select(self, oids): 1500 """Select the item for the oids""" 1501 self.scene().clearSelection() 1502 for oid in oids: 1503 try: 1504 item = self.items[oid] 1505 except KeyError: 1506 continue 1507 item.blockSignals(True) 1508 item.setSelected(True) 1509 item.blockSignals(False) 1510 item_rect = item.sceneTransform().mapRect(item.boundingRect()) 1511 self.ensureVisible(item_rect) 1512 1513 def _get_item_by_generation(self, commits, criteria_fn): 1514 """Return the item for the commit matching criteria""" 1515 if not commits: 1516 return None 1517 generation = None 1518 for commit in commits: 1519 if generation is None or criteria_fn(generation, commit.generation): 1520 oid = commit.oid 1521 generation = commit.generation 1522 try: 1523 return self.items[oid] 1524 except KeyError: 1525 return None 1526 1527 def _oldest_item(self, commits): 1528 """Return the item for the commit with the oldest generation number""" 1529 return self._get_item_by_generation(commits, lambda a, b: a > b) 1530 1531 def _newest_item(self, commits): 1532 """Return the item for the commit with the newest generation number""" 1533 return self._get_item_by_generation(commits, lambda a, b: a < b) 1534 1535 def create_patch(self): 1536 items = self.selected_items() 1537 if not items: 1538 return 1539 context = self.context 1540 selected_commits = sort_by_generation([n.commit for n in items]) 1541 oids = [c.oid for c in selected_commits] 1542 all_oids = [c.oid for c in self.commits] 1543 cmds.do(cmds.FormatPatch, context, oids, all_oids) 1544 1545 def _select_parent(self): 1546 """Select the parent with the newest generation number""" 1547 selected_item = self.selected_item() 1548 if selected_item is None: 1549 return 1550 parent_item = self._newest_item(selected_item.commit.parents) 1551 if parent_item is None: 1552 return 1553 selected_item.setSelected(False) 1554 parent_item.setSelected(True) 1555 self.ensureVisible(parent_item.mapRectToScene(parent_item.boundingRect())) 1556 1557 def _select_oldest_parent(self): 1558 """Select the parent with the oldest generation number""" 1559 selected_item = self.selected_item() 1560 if selected_item is None: 1561 return 1562 parent_item = self._oldest_item(selected_item.commit.parents) 1563 if parent_item is None: 1564 return 1565 selected_item.setSelected(False) 1566 parent_item.setSelected(True) 1567 scene_rect = parent_item.mapRectToScene(parent_item.boundingRect()) 1568 self.ensureVisible(scene_rect) 1569 1570 def _select_child(self): 1571 """Select the child with the oldest generation number""" 1572 selected_item = self.selected_item() 1573 if selected_item is None: 1574 return 1575 child_item = self._oldest_item(selected_item.commit.children) 1576 if child_item is None: 1577 return 1578 selected_item.setSelected(False) 1579 child_item.setSelected(True) 1580 scene_rect = child_item.mapRectToScene(child_item.boundingRect()) 1581 self.ensureVisible(scene_rect) 1582 1583 def _select_newest_child(self): 1584 """Select the Nth child with the newest generation number (N > 1)""" 1585 selected_item = self.selected_item() 1586 if selected_item is None: 1587 return 1588 if len(selected_item.commit.children) > 1: 1589 children = selected_item.commit.children[1:] 1590 else: 1591 children = selected_item.commit.children 1592 child_item = self._newest_item(children) 1593 if child_item is None: 1594 return 1595 selected_item.setSelected(False) 1596 child_item.setSelected(True) 1597 scene_rect = child_item.mapRectToScene(child_item.boundingRect()) 1598 self.ensureVisible(scene_rect) 1599 1600 def set_initial_view(self): 1601 items = [] 1602 selected = self.selected_items() 1603 if selected: 1604 items.extend(selected) 1605 1606 if not selected and self.commits: 1607 commit = self.commits[-1] 1608 items.append(self.items[commit.oid]) 1609 1610 self.setSceneRect(self.scene().itemsBoundingRect()) 1611 self.fit_view_to_items(items) 1612 1613 def zoom_to_fit(self): 1614 """Fit selected items into the viewport""" 1615 1616 items = self.selected_items() 1617 self.fit_view_to_items(items) 1618 1619 def fit_view_to_items(self, items): 1620 if not items: 1621 rect = self.scene().itemsBoundingRect() 1622 else: 1623 x_min = y_min = maxsize 1624 x_max = y_max = -maxsize 1625 1626 for item in items: 1627 pos = item.pos() 1628 x = pos.x() 1629 y = pos.y() 1630 x_min = min(x_min, x) 1631 x_max = max(x_max, x) 1632 y_min = min(y_min, y) 1633 y_max = max(y_max, y) 1634 1635 rect = QtCore.QRectF(x_min, y_min, abs(x_max - x_min), abs(y_max - y_min)) 1636 1637 x_adjust = abs(GraphView.x_adjust) 1638 y_adjust = abs(GraphView.y_adjust) 1639 1640 count = max(2.0, 10.0 - len(items) / 2.0) 1641 y_offset = int(y_adjust * count) 1642 x_offset = int(x_adjust * count) 1643 rect.setX(rect.x() - x_offset // 2) 1644 rect.setY(rect.y() - y_adjust // 2) 1645 rect.setHeight(rect.height() + y_offset) 1646 rect.setWidth(rect.width() + x_offset) 1647 1648 self.fitInView(rect, Qt.KeepAspectRatio) 1649 self.scene().invalidate() 1650 1651 def save_selection(self, event): 1652 if event.button() != Qt.LeftButton: 1653 return 1654 elif Qt.ShiftModifier != event.modifiers(): 1655 return 1656 self.selection_list = self.selected_items() 1657 1658 def restore_selection(self, event): 1659 if Qt.ShiftModifier != event.modifiers(): 1660 return 1661 for item in self.selection_list: 1662 item.setSelected(True) 1663 1664 def handle_event(self, event_handler, event): 1665 self.save_selection(event) 1666 event_handler(self, event) 1667 self.restore_selection(event) 1668 self.update() 1669 1670 def set_selecting(self, selecting): 1671 self.selecting = selecting 1672 1673 def pan(self, event): 1674 pos = event.pos() 1675 dx = pos.x() - self.mouse_start[0] 1676 dy = pos.y() - self.mouse_start[1] 1677 1678 if dx == 0 and dy == 0: 1679 return 1680 1681 rect = QtCore.QRect(0, 0, abs(dx), abs(dy)) 1682 delta = self.mapToScene(rect).boundingRect() 1683 1684 tx = delta.width() 1685 if dx < 0.0: 1686 tx = -tx 1687 1688 ty = delta.height() 1689 if dy < 0.0: 1690 ty = -ty 1691 1692 matrix = self.transform() 1693 matrix.reset() 1694 matrix *= self.saved_matrix 1695 matrix.translate(tx, ty) 1696 1697 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor) 1698 self.setTransform(matrix) 1699 1700 def wheel_zoom(self, event): 1701 """Handle mouse wheel zooming.""" 1702 delta = qtcompat.wheel_delta(event) 1703 zoom = math.pow(2.0, delta / 512.0) 1704 factor = ( 1705 self.transform() 1706 .scale(zoom, zoom) 1707 .mapRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0)) 1708 .width() 1709 ) 1710 if factor < 0.014 or factor > 42.0: 1711 return 1712 self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse) 1713 self.zoom = zoom 1714 self.scale(zoom, zoom) 1715 1716 def wheel_pan(self, event): 1717 """Handle mouse wheel panning.""" 1718 unit = QtCore.QRectF(0.0, 0.0, 1.0, 1.0) 1719 factor = 1.0 / self.transform().mapRect(unit).width() 1720 tx, ty = qtcompat.wheel_translation(event) 1721 1722 matrix = self.transform().translate(tx * factor, ty * factor) 1723 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor) 1724 self.setTransform(matrix) 1725 1726 def scale_view(self, scale): 1727 factor = ( 1728 self.transform() 1729 .scale(scale, scale) 1730 .mapRect(QtCore.QRectF(0, 0, 1, 1)) 1731 .width() 1732 ) 1733 if factor < 0.07 or factor > 100.0: 1734 return 1735 self.zoom = scale 1736 1737 adjust_scrollbars = True 1738 scrollbar = self.verticalScrollBar() 1739 if scrollbar: 1740 value = get(scrollbar) 1741 min_ = scrollbar.minimum() 1742 max_ = scrollbar.maximum() 1743 range_ = max_ - min_ 1744 distance = value - min_ 1745 nonzero_range = range_ > 0.1 1746 if nonzero_range: 1747 scrolloffset = distance / range_ 1748 else: 1749 adjust_scrollbars = False 1750 1751 self.setTransformationAnchor(QtWidgets.QGraphicsView.NoAnchor) 1752 self.scale(scale, scale) 1753 1754 scrollbar = self.verticalScrollBar() 1755 if scrollbar and adjust_scrollbars: 1756 min_ = scrollbar.minimum() 1757 max_ = scrollbar.maximum() 1758 range_ = max_ - min_ 1759 value = min_ + int(float(range_) * scrolloffset) 1760 scrollbar.setValue(value) 1761 1762 def add_commits(self, commits): 1763 """Traverse commits and add them to the view.""" 1764 self.commits.extend(commits) 1765 scene = self.scene() 1766 for commit in commits: 1767 item = Commit(commit, self.notifier) 1768 self.items[commit.oid] = item 1769 for ref in commit.tags: 1770 self.items[ref] = item 1771 scene.addItem(item) 1772 1773 self.layout_commits() 1774 self.link(commits) 1775 1776 def link(self, commits): 1777 """Create edges linking commits with their parents""" 1778 scene = self.scene() 1779 for commit in commits: 1780 try: 1781 commit_item = self.items[commit.oid] 1782 except KeyError: 1783 # TODO - Handle truncated history viewing 1784 continue 1785 for parent in reversed(commit.parents): 1786 try: 1787 parent_item = self.items[parent.oid] 1788 except KeyError: 1789 # TODO - Handle truncated history viewing 1790 continue 1791 try: 1792 edge = parent_item.edges[commit.oid] 1793 except KeyError: 1794 edge = Edge(parent_item, commit_item) 1795 else: 1796 continue 1797 parent_item.edges[commit.oid] = edge 1798 commit_item.edges[parent.oid] = edge 1799 scene.addItem(edge) 1800 1801 def layout_commits(self): 1802 positions = self.position_nodes() 1803 1804 # Each edge is accounted in two commits. Hence, accumulate invalid 1805 # edges to prevent double edge invalidation. 1806 invalid_edges = set() 1807 1808 for oid, (x, y) in positions.items(): 1809 item = self.items[oid] 1810 1811 pos = item.pos() 1812 if pos != (x, y): 1813 item.setPos(x, y) 1814 1815 for edge in item.edges.values(): 1816 invalid_edges.add(edge) 1817 1818 for edge in invalid_edges: 1819 edge.commits_were_invalidated() 1820 1821 # Commit node layout technique 1822 # 1823 # Nodes are aligned by a mesh. Columns and rows are distributed using 1824 # algorithms described below. 1825 # 1826 # Row assignment algorithm 1827 # 1828 # The algorithm aims consequent. 1829 # 1. A commit should be above all its parents. 1830 # 2. No commit should be at right side of a commit with a tag in same row. 1831 # This prevents overlapping of tag labels with commits and other labels. 1832 # 3. Commit density should be maximized. 1833 # 1834 # The algorithm requires that all parents of a commit were assigned column. 1835 # Nodes must be traversed in generation ascend order. This guarantees that all 1836 # parents of a commit were assigned row. So, the algorithm may operate in 1837 # course of column assignment algorithm. 1838 # 1839 # Row assignment uses frontier. A frontier is a dictionary that contains 1840 # minimum available row index for each column. It propagates during the 1841 # algorithm. Set of cells with tags is also maintained to meet second aim. 1842 # 1843 # Initialization is performed by reset_rows method. Each new column should 1844 # be declared using declare_column method. Getting row for a cell is 1845 # implemented in alloc_cell method. Frontier must be propagated for any child 1846 # of fork commit which occupies different column. This meets first aim. 1847 # 1848 # Column assignment algorithm 1849 # 1850 # The algorithm traverses nodes in generation ascend order. This guarantees 1851 # that a node will be visited after all its parents. 1852 # 1853 # The set of occupied columns are maintained during work. Initially it is 1854 # empty and no node occupied a column. Empty columns are allocated on demand. 1855 # Free index for column being allocated is searched in following way. 1856 # 1. Start from desired column and look towards graph center (0 column). 1857 # 2. Start from center and look in both directions simultaneously. 1858 # Desired column is defaulted to 0. Fork node should set desired column for 1859 # children equal to its one. This prevents branch from jumping too far from 1860 # its fork. 1861 # 1862 # Initialization is performed by reset_columns method. Column allocation is 1863 # implemented in alloc_column method. Initialization and main loop are in 1864 # recompute_grid method. The method also embeds row assignment algorithm by 1865 # implementation. 1866 # 1867 # Actions for each node are follow. 1868 # 1. If the node was not assigned a column then it is assigned empty one. 1869 # 2. Allocate row. 1870 # 3. Allocate columns for children. 1871 # If a child have a column assigned then it should no be overridden. One of 1872 # children is assigned same column as the node. If the node is a fork then the 1873 # child is chosen in generation descent order. This is a heuristic and it only 1874 # affects resulting appearance of the graph. Other children are assigned empty 1875 # columns in same order. It is the heuristic too. 1876 # 4. If no child occupies column of the node then leave it. 1877 # It is possible in consequent situations. 1878 # 4.1 The node is a leaf. 1879 # 4.2 The node is a fork and all its children are already assigned side 1880 # column. It is possible if all the children are merges. 1881 # 4.3 Single node child is a merge that is already assigned a column. 1882 # 5. Propagate frontier with respect to this node. 1883 # Each frontier entry corresponding to column occupied by any node's child 1884 # must be gather than node row index. This meets first aim of the row 1885 # assignment algorithm. 1886 # Note that frontier of child that occupies same row was propagated during 1887 # step 2. Hence, it must be propagated for children on side columns. 1888 1889 def reset_columns(self): 1890 # Some children of displayed commits might not be accounted in 1891 # 'commits' list. It is common case during loading of big graph. 1892 # But, they are assigned a column that must be reseted. Hence, use 1893 # depth-first traversal to reset all columns assigned. 1894 for node in self.commits: 1895 if node.column is None: 1896 continue 1897 stack = [node] 1898 while stack: 1899 node = stack.pop() 1900 node.column = None 1901 for child in node.children: 1902 if child.column is not None: 1903 stack.append(child) 1904 1905 self.columns = {} 1906 self.max_column = 0 1907 self.min_column = 0 1908 1909 def reset_rows(self): 1910 self.frontier = {} 1911 self.tagged_cells = set() 1912 1913 def declare_column(self, column): 1914 if self.frontier: 1915 # Align new column frontier by frontier of nearest column. If all 1916 # columns were left then select maximum frontier value. 1917 if not self.columns: 1918 self.frontier[column] = max(list(self.frontier.values())) 1919 return 1920 # This is heuristic that mostly affects roots. Note that the 1921 # frontier values for fork children will be overridden in course of 1922 # propagate_frontier. 1923 for offset in itertools.count(1): 1924 for c in [column + offset, column - offset]: 1925 if c not in self.columns: 1926 # Column 'c' is not occupied. 1927 continue 1928 try: 1929 frontier = self.frontier[c] 1930 except KeyError: 1931 # Column 'c' was never allocated. 1932 continue 1933 1934 frontier -= 1 1935 # The frontier of the column may be higher because of 1936 # tag overlapping prevention performed for previous head. 1937 try: 1938 if self.frontier[column] >= frontier: 1939 break 1940 except KeyError: 1941 pass 1942 1943 self.frontier[column] = frontier 1944 break 1945 else: 1946 continue 1947 break 1948 else: 1949 # First commit must be assigned 0 row. 1950 self.frontier[column] = 0 1951 1952 def alloc_column(self, column=0): 1953 columns = self.columns 1954 # First, look for free column by moving from desired column to graph 1955 # center (column 0). 1956 for c in range(column, 0, -1 if column > 0 else 1): 1957 if c not in columns: 1958 if c > self.max_column: 1959 self.max_column = c 1960 elif c < self.min_column: 1961 self.min_column = c 1962 break 1963 else: 1964 # If no free column was found between graph center and desired 1965 # column then look for free one by moving from center along both 1966 # directions simultaneously. 1967 for c in itertools.count(0): 1968 if c not in columns: 1969 if c > self.max_column: 1970 self.max_column = c 1971 break 1972 c = -c 1973 if c not in columns: 1974 if c < self.min_column: 1975 self.min_column = c 1976 break 1977 self.declare_column(c) 1978 columns[c] = 1 1979 return c 1980 1981 def alloc_cell(self, column, tags): 1982 # Get empty cell from frontier. 1983 cell_row = self.frontier[column] 1984 1985 if tags: 1986 # Prevent overlapping of tag with cells already allocated a row. 1987 if self.x_off > 0: 1988 can_overlap = list(range(column + 1, self.max_column + 1)) 1989 else: 1990 can_overlap = list(range(column - 1, self.min_column - 1, -1)) 1991 for c in can_overlap: 1992 frontier = self.frontier[c] 1993 if frontier > cell_row: 1994 cell_row = frontier 1995 1996 # Avoid overlapping with tags of commits at cell_row. 1997 if self.x_off > 0: 1998 can_overlap = list(range(self.min_column, column)) 1999 else: 2000 can_overlap = list(range(self.max_column, column, -1)) 2001 for cell_row in itertools.count(cell_row): 2002 for c in can_overlap: 2003 if (c, cell_row) in self.tagged_cells: 2004 # Overlapping. Try next row. 2005 break 2006 else: 2007 # No overlapping was found. 2008 break 2009 # Note that all checks should be made for new cell_row value. 2010 2011 if tags: 2012 self.tagged_cells.add((column, cell_row)) 2013 2014 # Propagate frontier. 2015 self.frontier[column] = cell_row + 1 2016 return cell_row 2017 2018 def propagate_frontier(self, column, value): 2019 current = self.frontier[column] 2020 if current < value: 2021 self.frontier[column] = value 2022 2023 def leave_column(self, column): 2024 count = self.columns[column] 2025 if count == 1: 2026 del self.columns[column] 2027 else: 2028 self.columns[column] = count - 1 2029 2030 def recompute_grid(self): 2031 self.reset_columns() 2032 self.reset_rows() 2033 2034 for node in sort_by_generation(list(self.commits)): 2035 if node.column is None: 2036 # Node is either root or its parent is not in items. The last 2037 # happens when tree loading is in progress. Allocate new 2038 # columns for such nodes. 2039 node.column = self.alloc_column() 2040 2041 node.row = self.alloc_cell(node.column, node.tags) 2042 2043 # Allocate columns for children which are still without one. Also 2044 # propagate frontier for children. 2045 if node.is_fork(): 2046 sorted_children = sorted( 2047 node.children, key=lambda c: c.generation, reverse=True 2048 ) 2049 citer = iter(sorted_children) 2050 for child in citer: 2051 if child.column is None: 2052 # Top most child occupies column of parent. 2053 child.column = node.column 2054 # Note that frontier is propagated in course of 2055 # alloc_cell. 2056 break 2057 self.propagate_frontier(child.column, node.row + 1) 2058 else: 2059 # No child occupies same column. 2060 self.leave_column(node.column) 2061 # Note that the loop below will pass no iteration. 2062 2063 # Rest children are allocated new column. 2064 for child in citer: 2065 if child.column is None: 2066 child.column = self.alloc_column(node.column) 2067 self.propagate_frontier(child.column, node.row + 1) 2068 elif node.children: 2069 child = node.children[0] 2070 if child.column is None: 2071 child.column = node.column 2072 # Note that frontier is propagated in course of alloc_cell. 2073 elif child.column != node.column: 2074 # Child node have other parents and occupies column of one 2075 # of them. 2076 self.leave_column(node.column) 2077 # But frontier must be propagated with respect to this 2078 # parent. 2079 self.propagate_frontier(child.column, node.row + 1) 2080 else: 2081 # This is a leaf node. 2082 self.leave_column(node.column) 2083 2084 def position_nodes(self): 2085 self.recompute_grid() 2086 2087 x_start = self.x_start 2088 x_min = self.x_min 2089 x_off = self.x_off 2090 y_off = self.y_off 2091 2092 positions = {} 2093 2094 for node in self.commits: 2095 x_pos = x_start + node.column * x_off 2096 y_pos = y_off + node.row * y_off 2097 2098 positions[node.oid] = (x_pos, y_pos) 2099 x_min = min(x_min, x_pos) 2100 2101 self.x_min = x_min 2102 2103 return positions 2104 2105 # Qt overrides 2106 def contextMenuEvent(self, event): 2107 self.context_menu_event(event) 2108 2109 def mousePressEvent(self, event): 2110 if event.button() == Qt.MidButton: 2111 pos = event.pos() 2112 self.mouse_start = [pos.x(), pos.y()] 2113 self.saved_matrix = self.transform() 2114 self.is_panning = True 2115 return 2116 if event.button() == Qt.RightButton: 2117 event.ignore() 2118 return 2119 if event.button() == Qt.LeftButton: 2120 self.pressed = True 2121 self.handle_event(QtWidgets.QGraphicsView.mousePressEvent, event) 2122 2123 def mouseMoveEvent(self, event): 2124 pos = self.mapToScene(event.pos()) 2125 if self.is_panning: 2126 self.pan(event) 2127 return 2128 self.last_mouse[0] = pos.x() 2129 self.last_mouse[1] = pos.y() 2130 self.handle_event(QtWidgets.QGraphicsView.mouseMoveEvent, event) 2131 if self.pressed: 2132 self.viewport().repaint() 2133 2134 def mouseReleaseEvent(self, event): 2135 self.pressed = False 2136 if event.button() == Qt.MidButton: 2137 self.is_panning = False 2138 return 2139 self.handle_event(QtWidgets.QGraphicsView.mouseReleaseEvent, event) 2140 self.selection_list = [] 2141 self.viewport().repaint() 2142 2143 def wheelEvent(self, event): 2144 """Handle Qt mouse wheel events.""" 2145 if event.modifiers() & Qt.ControlModifier: 2146 self.wheel_zoom(event) 2147 else: 2148 self.wheel_pan(event) 2149 2150 def fitInView(self, rect, flags=Qt.IgnoreAspectRatio): 2151 """Override fitInView to remove unwanted margins 2152 2153 https://bugreports.qt.io/browse/QTBUG-42331 - based on QT sources 2154 2155 """ 2156 if self.scene() is None or rect.isNull(): 2157 return 2158 unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1)) 2159 self.scale(1.0 / unity.width(), 1.0 / unity.height()) 2160 view_rect = self.viewport().rect() 2161 scene_rect = self.transform().mapRect(rect) 2162 xratio = view_rect.width() / scene_rect.width() 2163 yratio = view_rect.height() / scene_rect.height() 2164 if flags == Qt.KeepAspectRatio: 2165 xratio = yratio = min(xratio, yratio) 2166 elif flags == Qt.KeepAspectRatioByExpanding: 2167 xratio = yratio = max(xratio, yratio) 2168 self.scale(xratio, yratio) 2169 self.centerOn(rect.center()) 2170 2171 2172def sort_by_generation(commits): 2173 if len(commits) < 2: 2174 return commits 2175 commits.sort(key=lambda x: x.generation) 2176 return commits 2177 2178 2179# Glossary 2180# ======== 2181# oid -- Git objects IDs (i.e. SHA-1 IDs) 2182# ref -- Git references that resolve to a commit-ish (HEAD, branches, tags) 2183