1""" 2Contains main class for PyMOL QT GUI 3""" 4 5 6from __future__ import absolute_import 7from __future__ import print_function 8from collections import defaultdict 9import os 10import re 11import sys 12 13import pymol 14import pymol._gui 15from pymol import colorprinting 16 17from pymol.Qt import QtGui, QtCore, QtWidgets 18from pymol.Qt.utils import (getSaveFileNameWithExt, UpdateLock, WidgetMenu, 19 PopupOnException, 20 connectFontContextMenu, getMonospaceFont) 21 22from .pymol_gl_widget import PyMOLGLWidget 23from . import keymapping 24 25from pmg_qt import properties_dialog, file_dialogs 26 27Qt = QtCore.Qt 28QFileDialog = QtWidgets.QFileDialog 29getOpenFileNames = QFileDialog.getOpenFileNames 30 31 32class PyMOLQtGUI(QtWidgets.QMainWindow, pymol._gui.PyMOLDesktopGUI): 33 ''' 34 PyMOL QMainWindow GUI 35 ''' 36 37 from pmg_qt.file_dialogs import ( 38 load_dialog, 39 load_mae_dialog, 40 file_fetch_pdb, 41 file_save_png, 42 file_save_mpeg, 43 file_save_map, 44 file_save_aln, 45 file_save 46 ) 47 48 _ext_window_visible = True 49 _initialdir = '' 50 51 def keyPressEvent(self, ev): 52 args = keymapping.keyPressEventToPyMOLButtonArgs(ev) 53 54 if args is not None: 55 self.pymolwidget.pymol.button(*args) 56 57 def closeEvent(self, event): 58 self.cmd.quit() 59 60 # for thread-safe viewport command 61 viewportsignal = QtCore.Signal(int, int) 62 63 def pymolviewport(self, w, h): 64 cw, ch = self.cmd.get_viewport() 65 pw = self.pymolwidget 66 scale = pw.fb_scale 67 68 # maintain aspect ratio 69 if h < 1: 70 if w < 1: 71 pw.pymol.reshape(int(scale * pw.width()), 72 int(scale * pw.height()), True) 73 return 74 h = (w * ch) / cw 75 if w < 1: 76 w = (h * cw) / ch 77 78 win_size = self.size() 79 delta = QtCore.QSize(w - cw, h - ch) / scale 80 81 # window resize 82 self.resize(delta + win_size) 83 84 def get_view(self): 85 self.cmd.get_view(2, quiet=0) 86 QtWidgets.QApplication.clipboard().setText(self.cmd.get_view(3)) 87 print(" get_view: matrix copied to clipboard.") 88 89 def __init__(self): # noqa 90 QtWidgets.QMainWindow.__init__(self) 91 self.setDockOptions(QtWidgets.QMainWindow.AllowTabbedDocks | 92 QtWidgets.QMainWindow.AllowNestedDocks) 93 94 # resize Window before it is shown 95 options = pymol.invocation.options 96 self.resize( 97 options.win_x + (220 if options.internal_gui else 0), 98 options.win_y + (246 if options.external_gui else 18)) 99 100 # for thread-safe viewport command 101 self.viewportsignal.connect(self.pymolviewport) 102 103 # reusable dialogs 104 self.dialog_png = None 105 self.advanced_settings_dialog = None 106 self.props_dialog = None 107 self.builder = None 108 109 # setting index -> callable 110 self.setting_callbacks = defaultdict(list) 111 112 # "session_file" setting in window title 113 self.setting_callbacks[440].append( 114 lambda v: self.setWindowTitle("PyMOL (" + os.path.basename(v) + ")") 115 ) 116 117 # "External" Command Line and Loggin Widget 118 self._setup_history() 119 self.lineedit = CommandLineEdit() 120 self.lineedit.setObjectName("command_line") 121 self.browser = QtWidgets.QPlainTextEdit() 122 self.browser.setObjectName("feedback_browser") 123 self.browser.setReadOnly(True) 124 125 # convenience: clicking into feedback browser gives focus to command 126 # line. Drawback: Copying with CTRL+C doesn't work in feedback 127 # browser -> clear focus proxy while text selected 128 self.browser.setFocusProxy(self.lineedit) 129 130 @self.browser.copyAvailable.connect 131 def _(yes): 132 self.browser.setFocusProxy(None if yes else self.lineedit) 133 self.browser.setFocus() 134 135 # Font 136 self.browser.setFont(getMonospaceFont()) 137 connectFontContextMenu(self.browser) 138 139 lineeditlayout = QtWidgets.QHBoxLayout() 140 command_label = QtWidgets.QLabel("PyMOL>") 141 command_label.setObjectName("command_label") 142 lineeditlayout.addWidget(command_label) 143 lineeditlayout.addWidget(self.lineedit) 144 self.lineedit.setToolTip('''Command Input Area 145 146Get the list of commands by hitting <TAB> 147 148Get the list of arguments for one command with a question mark: 149PyMOL> color ? 150 151Read the online help for a command with "help": 152PyMOL> help color 153 154Get autocompletion for many arguments by hitting <TAB> 155PyMOL> color ye<TAB> (will autocomplete "yellow") 156''') 157 158 layout = QtWidgets.QVBoxLayout() 159 layout.addWidget(self.browser) 160 layout.addLayout(lineeditlayout) 161 162 quickbuttonslayout = QtWidgets.QVBoxLayout() 163 quickbuttonslayout.setSpacing(2) 164 165 extguilayout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight) 166 extguilayout.setContentsMargins(2, 2, 2, 2) 167 extguilayout.addLayout(layout) 168 extguilayout.addLayout(quickbuttonslayout) 169 170 class ExtGuiFrame(QtWidgets.QFrame): 171 def mouseDoubleClickEvent(_, event): 172 self.toggle_ext_window_dockable(True) 173 174 _size_hint = QtCore.QSize(options.win_x, options.ext_y) 175 176 def sizeHint(self): 177 return self._size_hint 178 179 dockWidgetContents = ExtGuiFrame(self) 180 dockWidgetContents.setLayout(extguilayout) 181 dockWidgetContents.setObjectName("extgui") 182 183 self.ext_window = \ 184 dockWidget = QtWidgets.QDockWidget(self) 185 dockWidget.setWindowTitle("External GUI") 186 dockWidget.setWidget(dockWidgetContents) 187 if options.external_gui: 188 dockWidget.setTitleBarWidget(QtWidgets.QWidget()) 189 else: 190 dockWidget.hide() 191 192 self.addDockWidget(Qt.TopDockWidgetArea, dockWidget) 193 194 # rearrange vertically if docking left or right 195 @dockWidget.dockLocationChanged.connect 196 def _(area): 197 if area == Qt.LeftDockWidgetArea or area == Qt.RightDockWidgetArea: 198 extguilayout.setDirection(QtWidgets.QBoxLayout.BottomToTop) 199 quickbuttonslayout.takeAt(quickbuttons_stretch_index) 200 else: 201 extguilayout.setDirection(QtWidgets.QBoxLayout.LeftToRight) 202 if quickbuttons_stretch_index >= quickbuttonslayout.count(): 203 quickbuttonslayout.addStretch() 204 205 # OpenGL Widget 206 self.pymolwidget = PyMOLGLWidget(self) 207 self.setCentralWidget(self.pymolwidget) 208 209 cmd = self.cmd = self.pymolwidget.cmd 210 211 ''' 212 # command completion 213 completer = QtWidgets.QCompleter(cmd.kwhash.keywords, self) 214 self.lineedit.setCompleter(completer) 215 ''' 216 217 # overload <Tab> action 218 self.lineedit.installEventFilter(self) 219 self.pymolwidget.installEventFilter(self) 220 221 # Quick Buttons 222 for row in [ 223 [ 224 ('Reset', cmd.reset), 225 ('Zoom', lambda: cmd.zoom(animate=1.0)), 226 ('Orient', lambda: cmd.orient(animate=1.0)), 227 228 # render dialog will be constructed when the menu is shown 229 # for the first time. This way it's populated with the current 230 # viewport and settings. Also defers parsing of the ui file. 231 ('Draw/Ray', WidgetMenu(self).setSetupUi(self.render_dialog)), 232 ], 233 [ 234 ('Unpick', cmd.unpick), 235 ('Deselect', cmd.deselect), 236 ('Rock', cmd.rock), 237 ('Get View', self.get_view), 238 ], 239 [ 240 ('|<', cmd.rewind), 241 ('<', cmd.backward), 242 ('Stop', cmd.mstop), 243 ('Play', cmd.mplay), 244 ('>', cmd.forward), 245 ('>|', cmd.ending), 246 ('MClear', cmd.mclear), 247 ], 248 [ 249 ('Builder', self.open_builder_panel), 250 ('Properties', self.open_props_dialog), 251 ('Rebuild', cmd.rebuild), 252 ], 253 ]: 254 hbox = QtWidgets.QHBoxLayout() 255 hbox.setSpacing(2) 256 257 for name, callback in row: 258 btn = QtWidgets.QPushButton(name) 259 btn.setProperty("quickbutton", True) 260 btn.setAttribute(Qt.WA_LayoutUsesWidgetRect) # OS X workaround 261 hbox.addWidget(btn) 262 263 if callback is None: 264 btn.setEnabled(False) 265 elif isinstance(callback, QtWidgets.QMenu): 266 btn.setMenu(callback) 267 else: 268 btn.released.connect(callback) 269 270 quickbuttonslayout.addLayout(hbox) 271 272 # progress bar 273 hbox = QtWidgets.QHBoxLayout() 274 self.progressbar = QtWidgets.QProgressBar() 275 self.progressbar.setSizePolicy( 276 QtWidgets.QSizePolicy.Minimum, 277 QtWidgets.QSizePolicy.Minimum) 278 hbox.addWidget(self.progressbar) 279 self.abortbutton = QtWidgets.QPushButton('Abort') 280 self.abortbutton.setStyleSheet("background: #FF0000; color: #FFFFFF") 281 self.abortbutton.released.connect(cmd.interrupt) 282 hbox.addWidget(self.abortbutton) 283 quickbuttonslayout.addLayout(hbox) 284 285 quickbuttonslayout.addStretch() 286 quickbuttons_stretch_index = quickbuttonslayout.count() - 1 287 288 # menu top level 289 self.menubar = menubar = self.menuBar() 290 291 # action groups 292 actiongroups = {} 293 294 def _addmenu(data, menu): 295 '''Fill a menu from "data"''' 296 menu.setTearOffEnabled(True) 297 menu.setWindowTitle(menu.title()) # needed for Windows 298 for item in data: 299 if item[0] == 'separator': 300 menu.addSeparator() 301 elif item[0] == 'menu': 302 _addmenu(item[2], menu.addMenu(item[1].replace('&', '&&'))) 303 elif item[0] == 'command': 304 command = item[2] 305 if command is None: 306 print('warning: skipping', item) 307 else: 308 if isinstance(command, str): 309 command = lambda c=command: cmd.do(c) 310 menu.addAction(item[1], command) 311 elif item[0] == 'check': 312 if len(item) > 4: 313 menu.addAction( 314 SettingAction(self, cmd, item[2], item[1], 315 item[3], item[4])) 316 else: 317 menu.addAction( 318 SettingAction(self, cmd, item[2], item[1])) 319 elif item[0] == 'radio': 320 label, name, value = item[1:4] 321 try: 322 group, type_, values = actiongroups[item[2]] 323 except KeyError: 324 group = QtWidgets.QActionGroup(self) 325 type_, values = cmd.get_setting_tuple(name) 326 actiongroups[item[2]] = group, type_, values 327 action = QtWidgets.QAction(label, self) 328 action.triggered.connect(lambda _=0, args=(name, value): 329 cmd.set(*args, log=1, quiet=0)) 330 331 self.setting_callbacks[cmd.setting._get_index( 332 name)].append( 333 lambda v, V=value, a=action: a.setChecked(v == V)) 334 335 group.addAction(action) 336 menu.addAction(action) 337 action.setCheckable(True) 338 if values[0] == value: 339 action.setChecked(True) 340 elif item[0] == 'open_recent_menu': 341 self.open_recent_menu = menu.addMenu('Open Recent...') 342 else: 343 print('error:', item) 344 345 # recent files menu 346 self.open_recent_menu = None 347 348 # for plugins 349 self.menudict = {'': menubar} 350 351 # menu 352 for _, label, data in self.get_menudata(cmd): 353 assert _ == 'menu' 354 menu = menubar.addMenu(label) 355 self.menudict[label] = menu 356 _addmenu(data, menu) 357 358 # hack for macOS to hide "Edit > Start Dictation" 359 # https://bugreports.qt.io/browse/QTBUG-43217 360 if pymol.IS_MACOS: 361 self.menudict['Edit'].setTitle('Edit_') 362 QtCore.QTimer.singleShot(10, lambda: 363 self.menudict['Edit'].setTitle('Edit')) 364 365 # recent files menu 366 if self.open_recent_menu: 367 @self.open_recent_menu.aboutToShow.connect 368 def _(): 369 self.open_recent_menu.clear() 370 for fname in self.recent_filenames: 371 self.open_recent_menu.addAction( 372 fname if len(fname) < 128 else '...' + fname[-120:], 373 lambda fname=fname: self.load_dialog(fname)) 374 375 # some experimental window control 376 menu = self.menudict['Display'].addSeparator() 377 menu = self.menudict['Display'].addMenu('External GUI') 378 menu.addAction('Toggle floating', self.toggle_ext_window_dockable, 379 QtGui.QKeySequence('Ctrl+E')) 380 ext_vis_action = self.ext_window.toggleViewAction() 381 ext_vis_action.setText('Visible') 382 menu.addAction(ext_vis_action) 383 384 # extra key mappings (MacPyMOL compatible) 385 QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+O'), self).activated.connect(self.file_open) 386 QtWidgets.QShortcut(QtGui.QKeySequence('Ctrl+S'), self).activated.connect(self.session_save) 387 388 # feedback 389 self.feedback_timer = QtCore.QTimer() 390 self.feedback_timer.setSingleShot(True) 391 self.feedback_timer.timeout.connect(self.update_feedback) 392 self.feedback_timer.start(100) 393 394 # legacy plugin system 395 self.menudict['Plugin'].addAction( 396 'Initialize Plugin System', self.initializePlugins) 397 398 # focus in command line 399 if options.external_gui: 400 self.lineedit.setFocus() 401 else: 402 self.pymolwidget.setFocus() 403 404 # Apply PyMOL stylesheet 405 try: 406 with open(cmd.exp_path('$PYMOL_DATA/pmg_qt/styles/pymol.sty')) as f: 407 style = f.read() 408 except IOError: 409 print('Could not read PyMOL stylesheet.') 410 print('DEBUG: PYMOL_DATA=' + repr(os.getenv('PYMOL_DATA'))) 411 style = "" 412 413 if style: 414 self.setStyleSheet(style) 415 416 def lineeditKeyPressEventFilter(self, watched, event): 417 key = event.key() 418 if key == Qt.Key_Tab: 419 self.complete() 420 elif key == Qt.Key_Up: 421 if event.modifiers() & Qt.ControlModifier: 422 self.back_search() 423 else: 424 self.back() 425 elif key == Qt.Key_Down: 426 self.forward() 427 elif key == Qt.Key_Return or key == Qt.Key_Enter: 428 # filter out "Return" instead of binding lineedit.returnPressed, 429 # because otherwise OrthoKey would capture it as well. 430 self.doPrompt() 431 else: 432 return False 433 return True 434 435 def eventFilter(self, watched, event): 436 ''' 437 Filter out <Tab> event to do tab-completion instead of move focus 438 ''' 439 type_ = event.type() 440 if type_ == QtCore.QEvent.KeyRelease: 441 if event.key() == Qt.Key_Tab: 442 # silently skip tab release 443 return True 444 elif type_ == QtCore.QEvent.KeyPress: 445 if watched is self.lineedit: 446 return self.lineeditKeyPressEventFilter(watched, event) 447 elif event.key() == Qt.Key_Tab: 448 self.keyPressEvent(event) 449 return True 450 return False 451 452 def toggle_ext_window_dockable(self, neverfloat=False): 453 ''' 454 Toggle whether the "external" GUI is dockable 455 ''' 456 dockWidget = self.ext_window 457 458 if dockWidget.titleBarWidget() is None: 459 tbw = QtWidgets.QWidget() 460 else: 461 tbw = None 462 463 dockWidget.setFloating(tbw is None and not neverfloat) 464 dockWidget.setTitleBarWidget(tbw) 465 dockWidget.show() 466 467 def toggle_fullscreen(self, toggle=-1): 468 ''' 469 Full screen 470 ''' 471 is_fullscreen = self.windowState() == Qt.WindowFullScreen 472 473 if toggle == -1: 474 toggle = not is_fullscreen 475 476 if not is_fullscreen: 477 self._ext_window_visible = self.ext_window.isVisible() 478 479 if toggle: 480 self.menubar.hide() 481 self.ext_window.hide() 482 self.showFullScreen() 483 self.pymolwidget.setFocus() 484 else: 485 self.menubar.show() 486 if self._ext_window_visible: 487 self.ext_window.show() 488 self.showNormal() 489 490 @property 491 def initialdir(self): 492 ''' 493 Be in sync with cd/pwd on the console until the first file has been 494 browsed, then remember the last directory. 495 ''' 496 return self._initialdir or os.getcwd() 497 498 @initialdir.setter 499 def initialdir(self, value): 500 self._initialdir = value 501 502 ################## 503 # UI Forms 504 ################## 505 506 def load_form(self, name, dialog=None): 507 '''Load a form from pmg_qt/forms/{name}.py''' 508 import importlib 509 if dialog is None: 510 dialog = QtWidgets.QDialog(self) 511 widget = dialog 512 elif dialog == 'floating': 513 widget = QtWidgets.QWidget(self) 514 else: 515 widget = dialog 516 517 try: 518 m = importlib.import_module('.forms.' + name, 'pmg_qt') 519 except ImportError as e: 520 if pymol.Qt.DEBUG: 521 print('load_form import failed (%s)' % (e,)) 522 uifile = os.path.join(os.path.dirname(__file__), 'forms', '%s.ui' % name) 523 form = pymol.Qt.utils.loadUi(uifile, widget) 524 else: 525 if hasattr(m, 'Ui_Form'): 526 form = m.Ui_Form() 527 else: 528 form = m.Ui_Dialog() 529 530 form.setupUi(widget) 531 532 if dialog == 'floating': 533 dialog = QtWidgets.QDockWidget(widget.windowTitle(), self) 534 dialog.setFloating(True) 535 dialog.setWidget(widget) 536 dialog.resize(widget.size()) 537 538 form._dialog = dialog 539 return form 540 541 def open_props_dialog(self): #noqa 542 if not self.props_dialog: 543 self.props_dialog = properties_dialog.props_dialog(self) 544 545 self.props_dialog.show() 546 self.props_dialog.raise_() 547 548 def edit_colors_dialog(self): 549 form = self.load_form('colors') 550 form.list_colors.setSortingEnabled(True) 551 552 # populate list with named colors 553 for color_index in self.cmd.get_color_indices(): 554 form.list_colors.addItem(color_index[0]) 555 556 # update spinboxes for given color 557 def load_color(name): 558 index = self.cmd.get_color_index(name) 559 if index == -1: 560 return 561 rgb = self.cmd.get_color_tuple(index) 562 form.input_R.setValue(rgb[0]) 563 form.input_G.setValue(rgb[1]) 564 form.input_B.setValue(rgb[2]) 565 566 # update spinbox from slider 567 spinbox_lock = [False] 568 def update_spinbox(spinbox, value): 569 if not spinbox_lock[0]: 570 spinbox.setValue(value / 100.) 571 572 # update sliders and colored frame 573 def update_gui(*args): 574 spinbox_lock[0] = True 575 R = form.input_R.value() 576 G = form.input_G.value() 577 B = form.input_B.value() 578 form.slider_R.setValue(R * 100) 579 form.slider_G.setValue(G * 100) 580 form.slider_B.setValue(B * 100) 581 form.frame_color.setStyleSheet( 582 "background-color: rgb(%d,%d,%d)" % ( 583 R * 0xFF, G * 0xFF, B * 0xFF)) 584 spinbox_lock[0] = False 585 586 def run(): 587 name = form.input_name.text() 588 R = form.input_R.value() 589 G = form.input_G.value() 590 B = form.input_B.value() 591 592 self.cmd.do('set_color %s, [%.2f, %.2f, %.2f]\nrecolor' % 593 (name, R, G, B)) 594 595 # if new color, insert and make current row 596 if not form.list_colors.findItems(name, Qt.MatchExactly): 597 form.list_colors.addItem(name) 598 form.list_colors.setCurrentItem( 599 form.list_colors.findItems(name, Qt.MatchExactly)[0]) 600 601 # hook up events 602 form.slider_R.valueChanged.connect(lambda v: update_spinbox(form.input_R, v)) 603 form.slider_G.valueChanged.connect(lambda v: update_spinbox(form.input_G, v)) 604 form.slider_B.valueChanged.connect(lambda v: update_spinbox(form.input_B, v)) 605 form.input_R.valueChanged.connect(update_gui) 606 form.input_G.valueChanged.connect(update_gui) 607 form.input_B.valueChanged.connect(update_gui) 608 form.input_name.textChanged.connect(load_color) 609 form.list_colors.currentTextChanged.connect(form.input_name.setText) 610 form.button_apply.clicked.connect(run) 611 612 form._dialog.show() 613 614 def open_builder_panel(self): 615 from pmg_qt.builder import BuilderPanelDocked 616 from pymol import plugins 617 618 app = plugins.get_pmgapp() 619 if not self.builder: 620 self.builder = BuilderPanelDocked(self, app) 621 self.addDockWidget(Qt.TopDockWidgetArea, self.builder) 622 623 self.builder.show() 624 self.builder.raise_() 625 626 def edit_pymolrc(self): 627 from . import TextEditor 628 from pymol import plugins 629 TextEditor.edit_pymolrc(plugins.get_pmgapp()) 630 631 ################## 632 # Menu callbacks 633 ################## 634 635 def file_open(self): 636 fnames = getOpenFileNames(self, 'Open file', self.initialdir)[0] 637 partial = 0 638 for fname in fnames: 639 if not self.load_dialog(fname, partial=partial): 640 break 641 partial = 1 642 643 def session_save(self): 644 fname = self.cmd.get('session_file') 645 fname = self.cmd.as_pathstr(fname) 646 return self.session_save_as(fname) 647 648 @PopupOnException.decorator 649 def session_save_as(self, fname=''): 650 formats = [ 651 'PyMOL Session File (*.pse *.pze *.pse.gz)', 652 'PyMOL Show File (*.psw *.pzw *.psw.gz)', 653 ] 654 if not fname: 655 fname = getSaveFileNameWithExt( 656 self, 657 'Save Session As...', 658 self.initialdir, 659 filter=';;'.join(formats)) 660 if fname: 661 self.initialdir = os.path.dirname(fname) 662 self.cmd.save(fname, format='pse', quiet=0) 663 self.recent_filenames_add(fname) 664 665 def render_dialog(self, widget=None): 666 form = self.load_form('render', widget) 667 lock = UpdateLock([ZeroDivisionError]) 668 669 def get_factor(): 670 units = form.input_units.currentText() 671 factor = 1.0 if units == 'inch' else 2.54 672 return factor / float(form.input_dpi.currentText()) 673 674 @lock.skipIfCircular 675 def update_units(*args): 676 width = form.input_width.value() 677 height = form.input_height.value() 678 factor = get_factor() 679 form.input_width_units.setValue(width * factor) 680 form.input_height_units.setValue(height * factor) 681 682 @lock.skipIfCircular 683 def update_pixels(*args): 684 width = form.input_width_units.value() 685 height = form.input_height_units.value() 686 factor = get_factor() 687 form.input_width.setValue(width / factor) 688 form.input_height.setValue(height / factor) 689 690 @lock.skipIfCircular 691 def update_width(*args): 692 if form.aspectratio > 0: 693 width = form.input_height.value() * form.aspectratio 694 form.input_width.setValue(int(width)) 695 form.input_width_units.setValue(width * get_factor()) 696 697 @lock.skipIfCircular 698 def update_height(*args): 699 if form.aspectratio > 0: 700 height = form.input_width.value() / form.aspectratio 701 form.input_height.setValue(int(height)) 702 form.input_height_units.setValue(height * get_factor()) 703 704 def update_aspectratio(checked=True): 705 if checked: 706 try: 707 form.aspectratio = ( 708 float(form.input_width.value()) / 709 float(form.input_height.value())) 710 except ZeroDivisionError: 711 form.button_lock.setChecked(False) 712 else: 713 form.aspectratio = 0 714 715 def update_from_viewport(): 716 w, h = self.cmd.get_viewport() 717 form.aspectratio = 0 718 form.input_width.setValue(w) 719 form.input_height.setValue(h) 720 update_aspectratio(form.button_lock.isChecked()) 721 722 def run_draw(ray=False): 723 width = form.input_width.value() 724 height = form.input_height.value() 725 if ray: 726 self.cmd.set('opaque_background', 727 not form.input_transparent.isChecked()) 728 self.cmd.do('ray %d, %d, async=1' % (width, height)) 729 else: 730 self.cmd.do('draw %d, %d' % (width, height)) 731 form.stack.setCurrentIndex(1) 732 733 def run_ray(): 734 run_draw(ray=True) 735 736 def run_save(): 737 fname = getSaveFileNameWithExt(self, 'Save As...', self.initialdir, 738 filter='PNG File (*.png)') 739 if not fname: 740 return 741 self.initialdir = os.path.dirname(fname) 742 self.cmd.png(fname, prior=1, dpi=form.input_dpi.currentText()) 743 744 def run_copy_clipboard(): 745 with PopupOnException(): 746 _copy_image(self.cmd, False, form.input_dpi.currentText()) 747 748 dpi = self.cmd.get_setting_int('image_dots_per_inch') 749 if dpi > 0: 750 form.input_dpi.setEditText(str(dpi)) 751 form.input_dpi.setValidator(QtGui.QIntValidator()) 752 753 form.input_units.currentIndexChanged.connect(update_units) 754 form.input_dpi.editTextChanged.connect(update_pixels) 755 form.input_width.valueChanged.connect(update_units) 756 form.input_height.valueChanged.connect(update_units) 757 form.input_width_units.valueChanged.connect(update_pixels) 758 form.input_height_units.valueChanged.connect(update_pixels) 759 760 # set values before connecting mutual width<->height updates 761 update_from_viewport() 762 763 form.input_width.valueChanged.connect(update_height) 764 form.input_height.valueChanged.connect(update_width) 765 form.input_width_units.valueChanged.connect(update_height) 766 form.input_height_units.valueChanged.connect(update_width) 767 form.button_lock.toggled.connect(update_aspectratio) 768 769 form.button_draw.clicked.connect(run_draw) 770 form.button_ray.clicked.connect(run_ray) 771 form.button_current.clicked.connect(update_from_viewport) 772 form.button_back.clicked.connect(lambda: form.stack.setCurrentIndex(0)) 773 form.button_clip.clicked.connect(run_copy_clipboard) 774 form.button_save.clicked.connect(run_save) 775 776 if widget is None: 777 form._dialog.show() 778 779 def _file_save(self, filter, format): 780 fname = getSaveFileNameWithExt( 781 self, 782 'Save As...', 783 self.initialdir, 784 filter=filter) 785 if fname: 786 self.cmd.save(fname, format=format, quiet=0) 787 788 def file_save_wrl(self): 789 self._file_save('VRML 2 WRL File (*.wrl)', 'wrl') 790 791 def file_save_dae(self): 792 self._file_save('COLLADA File (*.dae)', 'dae') 793 794 def file_save_pov(self): 795 self._file_save('POV File (*.pov)', 'pov') 796 797 def file_save_mpng(self): 798 self.file_save_mpeg('png') 799 800 def file_save_mov(self): 801 self.file_save_mpeg('mov') 802 803 def file_save_stl(self): 804 self._file_save('STL File (*.stl)', 'stl') 805 806 def file_save_gltf(self): 807 self._file_save('GLTF File (*.gltf)', 'gltf') 808 809 LOG_FORMATS = [ 810 'PyMOL Script (*.pml)', 811 'Python Script (*.py *.pym)', 812 'All (*)', 813 ] 814 815 def log_open(self, fname='', mode='w'): 816 if not fname: 817 fname = getSaveFileNameWithExt(self, 'Open Logfile...', self.initialdir, 818 filter=';;'.join(self.LOG_FORMATS)) 819 if fname: 820 self.initialdir = os.path.dirname(fname) 821 self.cmd.log_open(fname, mode) 822 823 def log_append(self): 824 return self.log_open(mode='a') 825 826 def log_resume(self): 827 fname = getSaveFileNameWithExt(self, 'Open Logfile...', self.initialdir, 828 filter=';;'.join(self.LOG_FORMATS)) 829 if fname: 830 self.initialdir = os.path.dirname(fname) 831 self.cmd.resume(fname) 832 833 def file_run(self): 834 formats = [ 835 'All Runnable (*.pml *.py *.pym)', 836 'PyMOL Command Script (*.pml)', 837 'PyMOL Command Script (*.txt)', 838 'Python Script (*.py *.pym)', 839 'Python Script (*.txt)', 840 'All Files(*)', 841 ] 842 fnames, selectedfilter = getOpenFileNames( 843 self, 'Open file', self.initialdir, filter=';;'.join(formats)) 844 is_py = selectedfilter.startswith('Python') 845 846 with PopupOnException(): 847 for fname in fnames: 848 self.initialdir = os.path.dirname(fname) 849 self.cmd.cd(self.initialdir, quiet=0) 850 # detect: .py, .pym, .pyc, .pyo, .py.txt 851 if is_py or re.search(r'\.py(|m|c|o|\.txt)$', fname, re.I): 852 self.cmd.run(fname) 853 else: 854 self.cmd.do("@" + fname) 855 856 def cd_dialog(self): 857 dname = QFileDialog.getExistingDirectory( 858 self, "Change Working Directory", self.initialdir) 859 self.cmd.cd(dname or '.', quiet=0) 860 861 def confirm_quit(self): 862 QtWidgets.qApp.quit() 863 864 def settings_edit_all_dialog(self): 865 from .advanced_settings_gui import PyMOLAdvancedSettings 866 if self.advanced_settings_dialog is None: 867 self.advanced_settings_dialog = PyMOLAdvancedSettings(self, 868 self.cmd) 869 self.advanced_settings_dialog.show() 870 871 def show_about(self): 872 msg = [ 873 'The PyMOL Molecular Graphics System\n', 874 'Version %s' % (self.cmd.get_version()[0]), 875 u'Copyright (C) Schr\xF6dinger, LLC.', 876 'All rights reserved.\n', 877 'License information:', 878 ] 879 880 msg.append('Open-Source Build') 881 882 msg += [ 883 '', 884 'For more information:', 885 'https://pymol.org', 886 'sales@schrodinger.com', 887 ] 888 QtWidgets.QMessageBox.about(self, "About PyMOL", '\n'.join(msg)) 889 890 ################# 891 # GUI callbacks 892 ################# 893 894 if sys.version_info[0] < 3: 895 def command_get(self): 896 return self.lineedit.text().encode('utf-8') 897 else: 898 def command_get(self): 899 return self.lineedit.text() 900 901 def command_set(self, v): 902 return self.lineedit.setText(v) 903 904 def command_set_cursor(self, i): 905 return self.lineedit.setCursorPosition(i) 906 907 def update_progress(self): 908 progress = self.cmd.get_progress() 909 if progress >= 0: 910 self.progressbar.setValue(progress * 100) 911 self.progressbar.show() 912 self.abortbutton.show() 913 else: 914 self.progressbar.hide() 915 self.abortbutton.hide() 916 917 def update_feedback(self): 918 self.update_progress() 919 920 feedback = self.cmd._get_feedback() 921 if feedback: 922 html = colorprinting.text2html('\n'.join(feedback)) 923 self.browser.appendHtml(html) 924 925 scrollbar = self.browser.verticalScrollBar() 926 scrollbar.setValue(scrollbar.maximum()) 927 928 for setting in self.cmd.get_setting_updates() or (): 929 if setting in self.setting_callbacks: 930 current_value = self.cmd.get_setting_tuple(setting)[1][0] 931 for callback in self.setting_callbacks[setting]: 932 callback(current_value) 933 934 self.feedback_timer.start(500) 935 936 def doPrompt(self): 937 self.doTypedCommand(self.command_get()) 938 self.pymolwidget._pymolProcess() 939 self.lineedit.clear() 940 self.feedback_timer.start(0) 941 942 ########################## 943 # legacy plugin system 944 ########################## 945 946 @PopupOnException.decorator 947 def initializePlugins(self): 948 from pymol import plugins 949 from . import mimic_tk 950 951 self.menudict['Plugin'].clear() 952 953 app = plugins.get_pmgapp() 954 955 plugins.legacysupport.addPluginManagerMenuItem() 956 957 # Redirect to Legacy submenu 958 self.menudict['PluginQt'] = self.menudict['Plugin'] 959 self.menudict['Plugin'] = self.menudict['PluginQt'].addMenu('Legacy Plugins') 960 self.menudict['Plugin'].setTearOffEnabled(True) 961 self.menudict['PluginQt'].addSeparator() 962 963 plugins.HAVE_QT = True 964 plugins.initialize(app) 965 966 def createlegacypmgapp(self): 967 from . import mimic_pmg_tk as mimic 968 pmgapp = mimic.PMGApp() 969 pmgapp.menuBar = mimic.PmwMenuBar(self.menudict) 970 return pmgapp 971 972 def window_cmd(self, action, x, y, w, h): 973 if action == 0: # hide 974 self.hide() 975 elif action == 1: # show 976 self.show() 977 elif action == 2: # position 978 self.move(x, y) 979 elif action == 3: # size (first two arguments) 980 self.resize(x, y) 981 elif action == 4: # box 982 self.move(x, y) 983 self.resize(w, h) 984 elif action == 5: # maximize 985 self.showMaximized() 986 elif action == 6: # fit 987 if hasattr(QtGui, 'QWindow') and self.windowHandle().visibility() in ( 988 QtGui.QWindow.Maximized, QtGui.QWindow.FullScreen): 989 return 990 a = QtWidgets.QApplication.desktop().availableGeometry(self) 991 g = self.geometry() 992 f = self.frameGeometry() 993 w = min(f.width(), a.width()) 994 h = min(f.height(), a.height()) 995 x = max(min(f.x(), a.right() - w), a.x()) 996 y = max(min(f.y(), a.bottom() - h), a.y()) 997 self.setGeometry( 998 x - f.x() + g.x(), 999 y - f.y() + g.y(), 1000 w - f.width() + g.width(), 1001 h - f.height() + g.height(), 1002 ) 1003 elif action == 7: # focus 1004 self.setFocus(Qt.OtherFocusReason) 1005 elif action == 8: # defocus 1006 self.clearFocus() 1007 1008 1009def commandoverloaddecorator(func): 1010 name = func.__name__ 1011 func.__doc__ = getattr(pymol.cmd, name).__doc__ 1012 setattr(pymol.cmd, name, func) 1013 pymol.cmd.extend(func) 1014 return func 1015 1016 1017def SettingAction(parent, cmd, name, label='', true_value=1, false_value=0, 1018 command=None): 1019 ''' 1020 Menu toggle action for a PyMOL setting 1021 1022 parent: parent QObject 1023 cmd: PyMOL instance 1024 name: setting name 1025 label: menu item text 1026 ''' 1027 if not label: 1028 label = name 1029 1030 index = cmd.setting._get_index(name) 1031 type_, values = cmd.get_setting_tuple(index) 1032 action = QtWidgets.QAction(label, parent) 1033 1034 if not command: 1035 command = lambda: cmd.set( 1036 index, 1037 true_value if action.isChecked() else false_value, 1038 log=1, 1039 quiet=0) 1040 1041 parent.setting_callbacks[index].append( 1042 lambda v: action.setChecked(v != false_value)) 1043 1044 if type_ in ( 1045 1, # bool 1046 2, # int 1047 3, # float 1048 5, # color 1049 6, # str 1050 ): 1051 action.setCheckable(True) 1052 if values[0] == true_value: 1053 action.setChecked(True) 1054 else: 1055 print('TODO', type_, name) 1056 1057 action.triggered.connect(command) 1058 return action 1059 1060window = None 1061 1062 1063class CommandLineEdit(QtWidgets.QLineEdit): 1064 ''' 1065 Line edit widget with instant text insert on drag-enter 1066 ''' 1067 _saved_pos = -1 1068 1069 def dragMoveEvent(self, event): 1070 pass 1071 1072 def dropEvent(self, event): 1073 if event.mimeData().hasText(): 1074 event.acceptProposedAction() 1075 1076 def dragEnterEvent(self, event): 1077 if not event.mimeData().hasText(): 1078 self._saved_pos = -1 1079 return 1080 1081 event.acceptProposedAction() 1082 1083 urls = event.mimeData().urls() 1084 if urls and urls[0].isLocalFile(): 1085 droppedtext = urls[0].toLocalFile() 1086 else: 1087 droppedtext = event.mimeData().text() 1088 1089 pos = self.cursorPosition() 1090 text = self.text() 1091 self._saved_pos = pos 1092 self._saved_text = text 1093 1094 self.setText(text[:pos] + droppedtext + text[pos:]) 1095 self.setSelection(pos, len(droppedtext)) 1096 1097 def dragLeaveEvent(self, event): 1098 if self._saved_pos != -1: 1099 self.setText(self._saved_text) 1100 self.setCursorPosition(self._saved_pos) 1101 1102 1103class PyMOLApplication(QtWidgets.QApplication): 1104 ''' 1105 Catch drop events on app icon 1106 ''' 1107 # FileOpen event is only activated after the first 1108 # application state change, otherwise sys.argv would be 1109 # handled by Qt, we don't want that. 1110 1111 def handle_file_open(self, ev): 1112 if ev.type() == QtCore.QEvent.ApplicationActivate: 1113 self.handle_file_open = self.handle_file_open_active 1114 return False 1115 1116 def handle_file_open_active(self, ev): 1117 if ev.type() != QtCore.QEvent.FileOpen: 1118 return False 1119 1120 # When double clicking a file in Finder, open it in a new instance 1121 if not pymol.invocation.options.reuse_helper and pymol.cmd.get_names(): 1122 window.new_window([ev.file()]) 1123 return True 1124 1125 # pymol -I -U 1126 if pymol.invocation.options.auto_reinitialize: 1127 pymol.cmd.reinitialize() 1128 1129 # PyMOL Show 1130 if ev.file().endswith('.psw'): 1131 pymol.cmd.set('presentation') 1132 pymol.cmd.set('internal_gui', 0) 1133 pymol.cmd.set('internal_feedback', 0) 1134 pymol.cmd.full_screen('on') 1135 1136 window.load_dialog(ev.file()) 1137 return True 1138 1139 def event(self, ev): 1140 if self.handle_file_open(ev): 1141 return True 1142 return super(PyMOLApplication, self).event(ev) 1143 1144 1145# like pymol.internal._copy_image 1146def _copy_image(_self=pymol.cmd, quiet=1, dpi=-1): 1147 import tempfile 1148 fname = tempfile.mktemp('.png') 1149 1150 if not _self.png(fname, prior=1, dpi=dpi): 1151 print("no prior image") 1152 return 1153 1154 try: 1155 qim = QtGui.QImage(fname) 1156 QtWidgets.QApplication.clipboard().setImage(qim) 1157 finally: 1158 os.unlink(fname) 1159 1160 if not quiet: 1161 print(" Image copied to clipboard") 1162 1163 1164def make_pymol_qicon(): 1165 icons_dir = os.path.expandvars('$PYMOL_DATA/pymol/icons') 1166 return QtGui.QIcon(os.path.join(icons_dir, 'icon2.svg')) 1167 1168 1169def execapp(): 1170 ''' 1171 Run PyMOL as a Qt application 1172 ''' 1173 global window 1174 global pymol 1175 1176 # don't let exceptions stop PyMOL 1177 import traceback 1178 sys.excepthook = traceback.print_exception 1179 1180 # use QT_OPENGL=desktop (auto-detection may fail on Windows) 1181 if hasattr(Qt, 'AA_UseDesktopOpenGL') and pymol.IS_WINDOWS: 1182 QtCore.QCoreApplication.setAttribute(Qt.AA_UseDesktopOpenGL) 1183 1184 # enable 4K scaling on Windows and Linux 1185 if hasattr(Qt, 'AA_EnableHighDpiScaling') and not any( 1186 v in os.environ 1187 for v in ['QT_SCALE_FACTOR', 'QT_SCREEN_SCALE_FACTORS']): 1188 QtCore.QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling) 1189 1190 # fix Windows taskbar icon 1191 if pymol.IS_WINDOWS: 1192 import ctypes 1193 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( 1194 u'com.schrodinger.pymol') 1195 1196 app = PyMOLApplication(['PyMOL']) 1197 app.setWindowIcon(make_pymol_qicon()) 1198 1199 window = PyMOLQtGUI() 1200 window.setWindowTitle("PyMOL") 1201 1202 @commandoverloaddecorator 1203 def viewport(w=-1, h=-1, _self=None): 1204 window.viewportsignal.emit(int(w), int(h)) 1205 1206 @commandoverloaddecorator 1207 def full_screen(toggle=-1, _self=None): 1208 from pymol import viewing as v 1209 toggle = v.toggle_dict[v.toggle_sc.auto_err(str(toggle), 'toggle')] 1210 window.toggle_fullscreen(toggle) 1211 1212 import pymol.gui 1213 pymol.gui.createlegacypmgapp = window.createlegacypmgapp 1214 1215 pymol.cmd._copy_image = _copy_image 1216 1217 window.show() 1218 window.raise_() 1219 1220 # window size according to -W -H options 1221 options = pymol.invocation.options 1222 if options.win_xy_set: 1223 scale = window.pymolwidget.fb_scale 1224 viewport(scale * options.win_x, scale * options.win_y) 1225 1226 # load plugins 1227 if options.plugins: 1228 window.initializePlugins() 1229 1230 app.exec_() 1231