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