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