1# -*- coding: utf-8 -*-
2#
3# Copyright © Spyder Project Contributors
4# Licensed under the terms of the MIT License
5# (see spyder/__init__.py for details)
6
7"""Qt utilities"""
8
9# Standard library imports
10import os
11import os.path as osp
12import re
13import sys
14
15# Third party imports
16from qtpy.compat import to_qvariant, from_qvariant
17from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer,
18                         QTranslator, Signal, Slot)
19from qtpy.QtGui import QIcon, QKeyEvent, QKeySequence, QPixmap
20from qtpy.QtWidgets import (QAction, QApplication, QHBoxLayout, QLabel,
21                            QLineEdit, QMenu, QStyle, QToolBar, QToolButton,
22                            QVBoxLayout, QWidget)
23
24# Local imports
25from spyder.config.base import get_image_path, running_in_mac_app
26from spyder.config.gui import get_shortcut
27from spyder.utils import programs
28from spyder.utils import icon_manager as ima
29from spyder.utils.icon_manager import get_icon, get_std_icon
30from spyder.py3compat import is_text_string, to_text_string
31
32# Note: How to redirect a signal from widget *a* to widget *b* ?
33# ----
34# It has to be done manually:
35#  * typing 'SIGNAL("clicked()")' works
36#  * typing 'signalstr = "clicked()"; SIGNAL(signalstr)' won't work
37# Here is an example of how to do it:
38# (self.listwidget is widget *a* and self is widget *b*)
39#    self.connect(self.listwidget, SIGNAL('option_changed'),
40#                 lambda *args: self.emit(SIGNAL('option_changed'), *args))
41
42
43
44def get_image_label(name, default="not_found.png"):
45    """Return image inside a QLabel object"""
46    label = QLabel()
47    label.setPixmap(QPixmap(get_image_path(name, default)))
48    return label
49
50
51class MacApplication(QApplication):
52    """Subclass to be able to open external files with our Mac app"""
53    sig_open_external_file = Signal(str)
54
55    def __init__(self, *args):
56        QApplication.__init__(self, *args)
57
58    def event(self, event):
59        if event.type() == QEvent.FileOpen:
60            fname = str(event.file())
61            self.sig_open_external_file.emit(fname)
62        return QApplication.event(self, event)
63
64
65def qapplication(translate=True, test_time=3):
66    """
67    Return QApplication instance
68    Creates it if it doesn't already exist
69
70    test_time: Time to maintain open the application when testing. It's given
71    in seconds
72    """
73    if running_in_mac_app():
74        SpyderApplication = MacApplication
75    else:
76        SpyderApplication = QApplication
77
78    app = SpyderApplication.instance()
79    if app is None:
80        # Set Application name for Gnome 3
81        # https://groups.google.com/forum/#!topic/pyside/24qxvwfrRDs
82        app = SpyderApplication(['Spyder'])
83
84        # Set application name for KDE (See issue 2207)
85        app.setApplicationName('Spyder')
86    if translate:
87        install_translator(app)
88
89    test_ci = os.environ.get('TEST_CI_WIDGETS', None)
90    if test_ci is not None:
91        timer_shutdown = QTimer(app)
92        timer_shutdown.timeout.connect(app.quit)
93        timer_shutdown.start(test_time*1000)
94    return app
95
96
97def file_uri(fname):
98    """Select the right file uri scheme according to the operating system"""
99    if os.name == 'nt':
100        # Local file
101        if re.search(r'^[a-zA-Z]:', fname):
102            return 'file:///' + fname
103        # UNC based path
104        else:
105            return 'file://' + fname
106    else:
107        return 'file://' + fname
108
109
110QT_TRANSLATOR = None
111def install_translator(qapp):
112    """Install Qt translator to the QApplication instance"""
113    global QT_TRANSLATOR
114    if QT_TRANSLATOR is None:
115        qt_translator = QTranslator()
116        if qt_translator.load("qt_"+QLocale.system().name(),
117                      QLibraryInfo.location(QLibraryInfo.TranslationsPath)):
118            QT_TRANSLATOR = qt_translator # Keep reference alive
119    if QT_TRANSLATOR is not None:
120        qapp.installTranslator(QT_TRANSLATOR)
121
122
123def keybinding(attr):
124    """Return keybinding"""
125    ks = getattr(QKeySequence, attr)
126    return from_qvariant(QKeySequence.keyBindings(ks)[0], str)
127
128
129def _process_mime_path(path, extlist):
130    if path.startswith(r"file://"):
131        if os.name == 'nt':
132            # On Windows platforms, a local path reads: file:///c:/...
133            # and a UNC based path reads like: file://server/share
134            if path.startswith(r"file:///"): # this is a local path
135                path=path[8:]
136            else: # this is a unc path
137                path = path[5:]
138        else:
139            path = path[7:]
140    path = path.replace('%5C' , os.sep)  # Transforming backslashes
141    if osp.exists(path):
142        if extlist is None or osp.splitext(path)[1] in extlist:
143            return path
144
145
146def mimedata2url(source, extlist=None):
147    """
148    Extract url list from MIME data
149    extlist: for example ('.py', '.pyw')
150    """
151    pathlist = []
152    if source.hasUrls():
153        for url in source.urls():
154            path = _process_mime_path(to_text_string(url.toString()), extlist)
155            if path is not None:
156                pathlist.append(path)
157    elif source.hasText():
158        for rawpath in to_text_string(source.text()).splitlines():
159            path = _process_mime_path(rawpath, extlist)
160            if path is not None:
161                pathlist.append(path)
162    if pathlist:
163        return pathlist
164
165
166def keyevent2tuple(event):
167    """Convert QKeyEvent instance into a tuple"""
168    return (event.type(), event.key(), event.modifiers(), event.text(),
169            event.isAutoRepeat(), event.count())
170
171
172def tuple2keyevent(past_event):
173    """Convert tuple into a QKeyEvent instance"""
174    return QKeyEvent(*past_event)
175
176
177def restore_keyevent(event):
178    if isinstance(event, tuple):
179        _, key, modifiers, text, _, _ = event
180        event = tuple2keyevent(event)
181    else:
182        text = event.text()
183        modifiers = event.modifiers()
184        key = event.key()
185    ctrl = modifiers & Qt.ControlModifier
186    shift = modifiers & Qt.ShiftModifier
187    return event, text, key, ctrl, shift
188
189
190def create_toolbutton(parent, text=None, shortcut=None, icon=None, tip=None,
191                      toggled=None, triggered=None,
192                      autoraise=True, text_beside_icon=False):
193    """Create a QToolButton"""
194    button = QToolButton(parent)
195    if text is not None:
196        button.setText(text)
197    if icon is not None:
198        if is_text_string(icon):
199            icon = get_icon(icon)
200        button.setIcon(icon)
201    if text is not None or tip is not None:
202        button.setToolTip(text if tip is None else tip)
203    if text_beside_icon:
204        button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
205    button.setAutoRaise(autoraise)
206    if triggered is not None:
207        button.clicked.connect(triggered)
208    if toggled is not None:
209        button.toggled.connect(toggled)
210        button.setCheckable(True)
211    if shortcut is not None:
212        button.setShortcut(shortcut)
213    return button
214
215
216def action2button(action, autoraise=True, text_beside_icon=False, parent=None):
217    """Create a QToolButton directly from a QAction object"""
218    if parent is None:
219        parent = action.parent()
220    button = QToolButton(parent)
221    button.setDefaultAction(action)
222    button.setAutoRaise(autoraise)
223    if text_beside_icon:
224        button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
225    return button
226
227
228def toggle_actions(actions, enable):
229    """Enable/disable actions"""
230    if actions is not None:
231        for action in actions:
232            if action is not None:
233                action.setEnabled(enable)
234
235
236def create_action(parent, text, shortcut=None, icon=None, tip=None,
237                  toggled=None, triggered=None, data=None, menurole=None,
238                  context=Qt.WindowShortcut):
239    """Create a QAction"""
240    action = SpyderAction(text, parent)
241    if triggered is not None:
242        action.triggered.connect(triggered)
243    if toggled is not None:
244        action.toggled.connect(toggled)
245        action.setCheckable(True)
246    if icon is not None:
247        if is_text_string(icon):
248            icon = get_icon(icon)
249        action.setIcon(icon)
250    if tip is not None:
251        action.setToolTip(tip)
252        action.setStatusTip(tip)
253    if data is not None:
254        action.setData(to_qvariant(data))
255    if menurole is not None:
256        action.setMenuRole(menurole)
257
258    # Workround for Mac because setting context=Qt.WidgetShortcut
259    # there doesn't have any effect
260    if sys.platform == 'darwin':
261        action._shown_shortcut = None
262        if context == Qt.WidgetShortcut:
263            if shortcut is not None:
264                action._shown_shortcut = shortcut
265            else:
266                # This is going to be filled by
267                # main.register_shortcut
268                action._shown_shortcut = 'missing'
269        else:
270            if shortcut is not None:
271                action.setShortcut(shortcut)
272            action.setShortcutContext(context)
273    else:
274        if shortcut is not None:
275            action.setShortcut(shortcut)
276        action.setShortcutContext(context)
277
278    return action
279
280
281def add_shortcut_to_tooltip(action, context, name):
282    """Add the shortcut associated with a given action to its tooltip"""
283    action.setToolTip(action.toolTip() + ' (%s)' %
284                      get_shortcut(context=context, name=name))
285
286
287def add_actions(target, actions, insert_before=None):
288    """Add actions to a QMenu or a QToolBar."""
289    previous_action = None
290    target_actions = list(target.actions())
291    if target_actions:
292        previous_action = target_actions[-1]
293        if previous_action.isSeparator():
294            previous_action = None
295    for action in actions:
296        if (action is None) and (previous_action is not None):
297            if insert_before is None:
298                target.addSeparator()
299            else:
300                target.insertSeparator(insert_before)
301        elif isinstance(action, QMenu):
302            if insert_before is None:
303                target.addMenu(action)
304            else:
305                target.insertMenu(insert_before, action)
306        elif isinstance(action, QAction):
307            if isinstance(action, SpyderAction):
308                if isinstance(target, QMenu) or not isinstance(target, QToolBar):
309                    try:
310                        action = action.no_icon_action
311                    except RuntimeError:
312                        continue
313            if insert_before is None:
314                # This is needed in order to ignore adding an action whose
315                # wrapped C/C++ object has been deleted. See issue 5074
316                try:
317                    target.addAction(action)
318                except RuntimeError:
319                    continue
320            else:
321                target.insertAction(insert_before, action)
322        previous_action = action
323
324
325def get_item_user_text(item):
326    """Get QTreeWidgetItem user role string"""
327    return from_qvariant(item.data(0, Qt.UserRole), to_text_string)
328
329
330def set_item_user_text(item, text):
331    """Set QTreeWidgetItem user role string"""
332    item.setData(0, Qt.UserRole, to_qvariant(text))
333
334
335def create_bookmark_action(parent, url, title, icon=None, shortcut=None):
336    """Create bookmark action"""
337
338    @Slot()
339    def open_url():
340        return programs.start_file(url)
341
342    return create_action( parent, title, shortcut=shortcut, icon=icon,
343                          triggered=open_url)
344
345
346def create_module_bookmark_actions(parent, bookmarks):
347    """
348    Create bookmark actions depending on module installation:
349    bookmarks = ((module_name, url, title), ...)
350    """
351    actions = []
352    for key, url, title in bookmarks:
353        # Create actions for scientific distros only if Spyder is installed
354        # under them
355        create_act = True
356        if key == 'winpython':
357            if not programs.is_module_installed(key):
358                create_act = False
359        if create_act:
360            act = create_bookmark_action(parent, url, title)
361            actions.append(act)
362    return actions
363
364
365def create_program_action(parent, text, name, icon=None, nt_name=None):
366    """Create action to run a program"""
367    if is_text_string(icon):
368        icon = get_icon(icon)
369    if os.name == 'nt' and nt_name is not None:
370        name = nt_name
371    path = programs.find_program(name)
372    if path is not None:
373        return create_action(parent, text, icon=icon,
374                             triggered=lambda: programs.run_program(name))
375
376
377def create_python_script_action(parent, text, icon, package, module, args=[]):
378    """Create action to run a GUI based Python script"""
379    if is_text_string(icon):
380        icon = get_icon(icon)
381    if programs.python_script_exists(package, module):
382        return create_action(parent, text, icon=icon,
383                             triggered=lambda:
384                             programs.run_python_script(package, module, args))
385
386
387class DialogManager(QObject):
388    """
389    Object that keep references to non-modal dialog boxes for another QObject,
390    typically a QMainWindow or any kind of QWidget
391    """
392    def __init__(self):
393        QObject.__init__(self)
394        self.dialogs = {}
395
396    def show(self, dialog):
397        """Generic method to show a non-modal dialog and keep reference
398        to the Qt C++ object"""
399        for dlg in list(self.dialogs.values()):
400            if to_text_string(dlg.windowTitle()) \
401               == to_text_string(dialog.windowTitle()):
402                dlg.show()
403                dlg.raise_()
404                break
405        else:
406            dialog.show()
407            self.dialogs[id(dialog)] = dialog
408            dialog.accepted.connect(
409                              lambda eid=id(dialog): self.dialog_finished(eid))
410            dialog.rejected.connect(
411                              lambda eid=id(dialog): self.dialog_finished(eid))
412
413    def dialog_finished(self, dialog_id):
414        """Manage non-modal dialog boxes"""
415        return self.dialogs.pop(dialog_id)
416
417    def close_all(self):
418        """Close all opened dialog boxes"""
419        for dlg in list(self.dialogs.values()):
420            dlg.reject()
421
422
423def get_filetype_icon(fname):
424    """Return file type icon"""
425    ext = osp.splitext(fname)[1]
426    if ext.startswith('.'):
427        ext = ext[1:]
428    return get_icon( "%s.png" % ext, ima.icon('FileIcon') )
429
430
431class SpyderAction(QAction):
432    """Spyder QAction class wrapper to handle cross platform patches."""
433
434    def __init__(self, *args, **kwargs):
435        """Spyder QAction class wrapper to handle cross platform patches."""
436        super(SpyderAction, self).__init__(*args, **kwargs)
437        self._action_no_icon = None
438
439        if sys.platform == 'darwin':
440            self._action_no_icon = QAction(*args, **kwargs)
441            self._action_no_icon.setIcon(QIcon())
442            self._action_no_icon.triggered.connect(self.triggered)
443            self._action_no_icon.toggled.connect(self.toggled)
444            self._action_no_icon.changed.connect(self.changed)
445            self._action_no_icon.hovered.connect(self.hovered)
446        else:
447            self._action_no_icon = self
448
449    def __getattribute__(self, name):
450        """Intercept method calls and apply to both actions, except signals."""
451        attr = super(SpyderAction, self).__getattribute__(name)
452
453        if hasattr(attr, '__call__') and name not in ['triggered', 'toggled',
454                                                      'changed', 'hovered']:
455            def newfunc(*args, **kwargs):
456                result = attr(*args, **kwargs)
457                if name not in ['setIcon']:
458                    action_no_icon = self.__dict__['_action_no_icon']
459                    attr_no_icon = super(QAction,
460                                         action_no_icon).__getattribute__(name)
461                    attr_no_icon(*args, **kwargs)
462                return result
463            return newfunc
464        else:
465            return attr
466
467    @property
468    def no_icon_action(self):
469        """Return the action without an Icon."""
470        return self._action_no_icon
471
472
473class ShowStdIcons(QWidget):
474    """
475    Dialog showing standard icons
476    """
477    def __init__(self, parent):
478        QWidget.__init__(self, parent)
479        layout = QHBoxLayout()
480        row_nb = 14
481        cindex = 0
482        for child in dir(QStyle):
483            if child.startswith('SP_'):
484                if cindex == 0:
485                    col_layout = QVBoxLayout()
486                icon_layout = QHBoxLayout()
487                icon = get_std_icon(child)
488                label = QLabel()
489                label.setPixmap(icon.pixmap(32, 32))
490                icon_layout.addWidget( label )
491                icon_layout.addWidget( QLineEdit(child.replace('SP_', '')) )
492                col_layout.addLayout(icon_layout)
493                cindex = (cindex+1) % row_nb
494                if cindex == 0:
495                    layout.addLayout(col_layout)
496        self.setLayout(layout)
497        self.setWindowTitle('Standard Platform Icons')
498        self.setWindowIcon(get_std_icon('TitleBarMenuButton'))
499
500
501def show_std_icons():
502    """
503    Show all standard Icons
504    """
505    app = qapplication()
506    dialog = ShowStdIcons(None)
507    dialog.show()
508    sys.exit(app.exec_())
509
510
511def calc_tools_spacing(tools_layout):
512    """
513    Return a spacing (int) or None if we don't have the appropriate metrics
514    to calculate the spacing.
515
516    We're trying to adapt the spacing below the tools_layout spacing so that
517    the main_widget has the same vertical position as the editor widgets
518    (which have tabs above).
519
520    The required spacing is
521
522        spacing = tabbar_height - tools_height + offset
523
524    where the tabbar_heights were empirically determined for a combination of
525    operating systems and styles. Offsets were manually adjusted, so that the
526    heights of main_widgets and editor widgets match. This is probably
527    caused by a still not understood element of the layout and style metrics.
528    """
529    metrics = {  # (tabbar_height, offset)
530        'nt.fusion': (32, 0),
531        'nt.windowsvista': (21, 3),
532        'nt.windowsxp': (24, 0),
533        'nt.windows': (21, 3),
534        'posix.breeze': (28, -1),
535        'posix.oxygen': (38, -2),
536        'posix.qtcurve': (27, 0),
537        'posix.windows': (26, 0),
538        'posix.fusion': (32, 0),
539    }
540
541    style_name = qapplication().style().property('name')
542    key = '%s.%s' % (os.name, style_name)
543
544    if key in metrics:
545        tabbar_height, offset = metrics[key]
546        tools_height = tools_layout.sizeHint().height()
547        spacing = tabbar_height - tools_height + offset
548        return max(spacing, 0)
549
550
551def create_plugin_layout(tools_layout, main_widget=None):
552    """
553    Returns a layout for a set of controls above a main widget. This is a
554    standard layout for many plugin panes (even though, it's currently
555    more often applied not to the pane itself but with in the one widget
556    contained in the pane.
557
558    tools_layout: a layout containing the top toolbar
559    main_widget: the main widget. Can be None, if you want to add this
560        manually later on.
561    """
562    layout = QVBoxLayout()
563    layout.setContentsMargins(0, 0, 0, 0)
564    spacing = calc_tools_spacing(tools_layout)
565    if spacing is not None:
566        layout.setSpacing(spacing)
567
568    layout.addLayout(tools_layout)
569    if main_widget is not None:
570        layout.addWidget(main_widget)
571    return layout
572
573
574MENU_SEPARATOR = None
575
576
577if __name__ == "__main__":
578    show_std_icons()
579