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"""Tabs widget""" 8 9# pylint: disable=C0103 10# pylint: disable=R0903 11# pylint: disable=R0911 12# pylint: disable=R0201 13 14# Standard library imports 15import os.path as osp 16import sys 17 18# Third party imports 19from qtpy import PYQT5 20from qtpy.QtCore import (QByteArray, QEvent, QMimeData, QPoint, Qt, Signal, 21 Slot) 22from qtpy.QtGui import QDrag 23from qtpy.QtWidgets import (QApplication, QHBoxLayout, QMenu, QTabBar, 24 QTabWidget, QWidget, QLineEdit) 25 26# Local imports 27from spyder.config.base import _ 28from spyder.config.gui import config_shortcut 29from spyder.py3compat import PY2, to_binary_string, to_text_string 30from spyder.utils import icon_manager as ima 31from spyder.utils.misc import get_common_path 32from spyder.utils.qthelpers import (add_actions, create_action, 33 create_toolbutton) 34 35 36class EditTabNamePopup(QLineEdit): 37 """Popup on top of the tab to edit its name.""" 38 39 def __init__(self, parent, split_char, split_index): 40 """Popup on top of the tab to edit its name.""" 41 42 # Variables 43 # Parent (main) 44 self.main = parent if parent is not None else self.parent() 45 self.split_char = split_char 46 self.split_index = split_index 47 48 # Track which tab is being edited 49 self.tab_index = None 50 51 # Widget setup 52 QLineEdit.__init__(self, parent=None) 53 54 # Slot to handle tab name update 55 self.editingFinished.connect(self.edit_finished) 56 57 # Even filter to catch clicks and ESC key 58 self.installEventFilter(self) 59 60 # Clean borders and no shadow to blend with tab 61 if PYQT5: 62 self.setWindowFlags( 63 Qt.Popup | 64 Qt.FramelessWindowHint | 65 Qt.NoDropShadowWindowHint 66 ) 67 else: 68 self.setWindowFlags( 69 Qt.Popup | 70 Qt.FramelessWindowHint 71 ) 72 self.setFrame(False) 73 74 # Align with tab name 75 self.setTextMargins(9, 0, 0, 0) 76 77 def eventFilter(self, widget, event): 78 """Catch clicks outside the object and ESC key press.""" 79 if ((event.type() == QEvent.MouseButtonPress and 80 not self.geometry().contains(event.globalPos())) or 81 (event.type() == QEvent.KeyPress and 82 event.key() == Qt.Key_Escape)): 83 # Exits editing 84 self.hide() 85 self.setFocus(False) 86 return True 87 88 # Event is not interessant, raise to parent 89 return QLineEdit.eventFilter(self, widget, event) 90 91 def edit_tab(self, index): 92 """Activate the edit tab.""" 93 94 # Sets focus, shows cursor 95 self.setFocus(True) 96 97 # Updates tab index 98 self.tab_index = index 99 100 # Gets tab size and shrinks to avoid overlapping tab borders 101 rect = self.main.tabRect(index) 102 rect.adjust(1, 1, -2, -1) 103 104 # Sets size 105 self.setFixedSize(rect.size()) 106 107 # Places on top of the tab 108 self.move(self.main.mapToGlobal(rect.topLeft())) 109 110 # Copies tab name and selects all 111 text = self.main.tabText(index) 112 text = text.replace(u'&', u'') 113 if self.split_char: 114 text = text.split(self.split_char)[self.split_index] 115 116 self.setText(text) 117 self.selectAll() 118 119 if not self.isVisible(): 120 # Makes editor visible 121 self.show() 122 123 def edit_finished(self): 124 """On clean exit, update tab name.""" 125 # Hides editor 126 self.hide() 127 128 if isinstance(self.tab_index, int) and self.tab_index >= 0: 129 # We are editing a valid tab, update name 130 tab_text = to_text_string(self.text()) 131 self.main.setTabText(self.tab_index, tab_text) 132 self.main.sig_change_name.emit(tab_text) 133 134 135class TabBar(QTabBar): 136 """Tabs base class with drag and drop support""" 137 sig_move_tab = Signal((int, int), (str, int, int)) 138 sig_change_name = Signal(str) 139 140 def __init__(self, parent, ancestor, rename_tabs=False, split_char='', 141 split_index=0): 142 QTabBar.__init__(self, parent) 143 self.ancestor = ancestor 144 145 # To style tabs on Mac 146 if sys.platform == 'darwin': 147 self.setObjectName('plugin-tab') 148 149 # Dragging tabs 150 self.__drag_start_pos = QPoint() 151 self.setAcceptDrops(True) 152 self.setUsesScrollButtons(True) 153 self.setMovable(True) 154 155 # Tab name editor 156 self.rename_tabs = rename_tabs 157 if self.rename_tabs: 158 # Creates tab name editor 159 self.tab_name_editor = EditTabNamePopup(self, split_char, 160 split_index) 161 else: 162 self.tab_name_editor = None 163 164 def mousePressEvent(self, event): 165 """Reimplement Qt method""" 166 if event.button() == Qt.LeftButton: 167 self.__drag_start_pos = QPoint(event.pos()) 168 QTabBar.mousePressEvent(self, event) 169 170 def mouseMoveEvent(self, event): 171 """Override Qt method""" 172 # FIXME: This was added by Pierre presumably to move tabs 173 # between plugins, but righit now it's breaking the regular 174 # Qt drag behavior for tabs, so we're commenting it for 175 # now 176 #if event.buttons() == Qt.MouseButtons(Qt.LeftButton) and \ 177 # (event.pos() - self.__drag_start_pos).manhattanLength() > \ 178 # QApplication.startDragDistance(): 179 # drag = QDrag(self) 180 # mimeData = QMimeData()# 181 182 # ancestor_id = to_text_string(id(self.ancestor)) 183 # parent_widget_id = to_text_string(id(self.parentWidget())) 184 # self_id = to_text_string(id(self)) 185 # source_index = to_text_string(self.tabAt(self.__drag_start_pos)) 186 187 # mimeData.setData("parent-id", to_binary_string(ancestor_id)) 188 # mimeData.setData("tabwidget-id", 189 # to_binary_string(parent_widget_id)) 190 # mimeData.setData("tabbar-id", to_binary_string(self_id)) 191 # mimeData.setData("source-index", to_binary_string(source_index)) 192 193 # drag.setMimeData(mimeData) 194 # drag.exec_() 195 QTabBar.mouseMoveEvent(self, event) 196 197 def dragEnterEvent(self, event): 198 """Override Qt method""" 199 mimeData = event.mimeData() 200 formats = list(mimeData.formats()) 201 202 if "parent-id" in formats and \ 203 int(mimeData.data("parent-id")) == id(self.ancestor): 204 event.acceptProposedAction() 205 206 QTabBar.dragEnterEvent(self, event) 207 208 def dropEvent(self, event): 209 """Override Qt method""" 210 mimeData = event.mimeData() 211 index_from = int(mimeData.data("source-index")) 212 index_to = self.tabAt(event.pos()) 213 if index_to == -1: 214 index_to = self.count() 215 if int(mimeData.data("tabbar-id")) != id(self): 216 tabwidget_from = to_text_string(mimeData.data("tabwidget-id")) 217 218 # We pass self object ID as a QString, because otherwise it would 219 # depend on the platform: long for 64bit, int for 32bit. Replacing 220 # by long all the time is not working on some 32bit platforms 221 # (see Issue 1094, Issue 1098) 222 self.sig_move_tab[(str, int, int)].emit(tabwidget_from, index_from, 223 index_to) 224 event.acceptProposedAction() 225 elif index_from != index_to: 226 self.sig_move_tab.emit(index_from, index_to) 227 event.acceptProposedAction() 228 QTabBar.dropEvent(self, event) 229 230 def mouseDoubleClickEvent(self, event): 231 """Override Qt method to trigger the tab name editor.""" 232 if self.rename_tabs is True and \ 233 event.buttons() == Qt.MouseButtons(Qt.LeftButton): 234 # Tab index 235 index = self.tabAt(event.pos()) 236 if index >= 0: 237 # Tab is valid, call tab name editor 238 self.tab_name_editor.edit_tab(index) 239 else: 240 # Event is not interesting, raise to parent 241 QTabBar.mouseDoubleClickEvent(self, event) 242 243 244class BaseTabs(QTabWidget): 245 """TabWidget with context menu and corner widgets""" 246 sig_close_tab = Signal(int) 247 248 def __init__(self, parent, actions=None, menu=None, 249 corner_widgets=None, menu_use_tooltips=False): 250 QTabWidget.__init__(self, parent) 251 self.setUsesScrollButtons(True) 252 253 # To style tabs on Mac 254 if sys.platform == 'darwin': 255 self.setObjectName('plugin-tab') 256 257 self.corner_widgets = {} 258 self.menu_use_tooltips = menu_use_tooltips 259 260 if menu is None: 261 self.menu = QMenu(self) 262 if actions: 263 add_actions(self.menu, actions) 264 else: 265 self.menu = menu 266 267 # Corner widgets 268 if corner_widgets is None: 269 corner_widgets = {} 270 corner_widgets.setdefault(Qt.TopLeftCorner, []) 271 corner_widgets.setdefault(Qt.TopRightCorner, []) 272 self.browse_button = create_toolbutton(self, 273 icon=ima.icon('browse_tab'), 274 tip=_("Browse tabs")) 275 self.browse_tabs_menu = QMenu(self) 276 self.browse_button.setMenu(self.browse_tabs_menu) 277 self.browse_button.setPopupMode(self.browse_button.InstantPopup) 278 self.browse_tabs_menu.aboutToShow.connect(self.update_browse_tabs_menu) 279 corner_widgets[Qt.TopLeftCorner] += [self.browse_button] 280 281 self.set_corner_widgets(corner_widgets) 282 283 def update_browse_tabs_menu(self): 284 """Update browse tabs menu""" 285 self.browse_tabs_menu.clear() 286 names = [] 287 dirnames = [] 288 for index in range(self.count()): 289 if self.menu_use_tooltips: 290 text = to_text_string(self.tabToolTip(index)) 291 else: 292 text = to_text_string(self.tabText(index)) 293 names.append(text) 294 if osp.isfile(text): 295 # Testing if tab names are filenames 296 dirnames.append(osp.dirname(text)) 297 offset = None 298 299 # If tab names are all filenames, removing common path: 300 if len(names) == len(dirnames): 301 common = get_common_path(dirnames) 302 if common is None: 303 offset = None 304 else: 305 offset = len(common)+1 306 if offset <= 3: 307 # Common path is not a path but a drive letter... 308 offset = None 309 310 for index, text in enumerate(names): 311 tab_action = create_action(self, text[offset:], 312 icon=self.tabIcon(index), 313 toggled=lambda state, index=index: 314 self.setCurrentIndex(index), 315 tip=self.tabToolTip(index)) 316 tab_action.setChecked(index == self.currentIndex()) 317 self.browse_tabs_menu.addAction(tab_action) 318 319 def set_corner_widgets(self, corner_widgets): 320 """ 321 Set tabs corner widgets 322 corner_widgets: dictionary of (corner, widgets) 323 corner: Qt.TopLeftCorner or Qt.TopRightCorner 324 widgets: list of widgets (may contains integers to add spacings) 325 """ 326 assert isinstance(corner_widgets, dict) 327 assert all(key in (Qt.TopLeftCorner, Qt.TopRightCorner) 328 for key in corner_widgets) 329 self.corner_widgets.update(corner_widgets) 330 for corner, widgets in list(self.corner_widgets.items()): 331 cwidget = QWidget() 332 cwidget.hide() 333 prev_widget = self.cornerWidget(corner) 334 if prev_widget: 335 prev_widget.close() 336 self.setCornerWidget(cwidget, corner) 337 clayout = QHBoxLayout() 338 clayout.setContentsMargins(0, 0, 0, 0) 339 for widget in widgets: 340 if isinstance(widget, int): 341 clayout.addSpacing(widget) 342 else: 343 clayout.addWidget(widget) 344 cwidget.setLayout(clayout) 345 cwidget.show() 346 347 def add_corner_widgets(self, widgets, corner=Qt.TopRightCorner): 348 self.set_corner_widgets({corner: 349 self.corner_widgets.get(corner, [])+widgets}) 350 351 def contextMenuEvent(self, event): 352 """Override Qt method""" 353 self.setCurrentIndex(self.tabBar().tabAt(event.pos())) 354 if self.menu: 355 self.menu.popup(event.globalPos()) 356 357 def mousePressEvent(self, event): 358 """Override Qt method""" 359 if event.button() == Qt.MidButton: 360 index = self.tabBar().tabAt(event.pos()) 361 if index >= 0: 362 self.sig_close_tab.emit(index) 363 event.accept() 364 return 365 QTabWidget.mousePressEvent(self, event) 366 367 def keyPressEvent(self, event): 368 """Override Qt method""" 369 ctrl = event.modifiers() & Qt.ControlModifier 370 key = event.key() 371 handled = False 372 if ctrl and self.count() > 0: 373 index = self.currentIndex() 374 if key == Qt.Key_PageUp: 375 if index > 0: 376 self.setCurrentIndex(index - 1) 377 else: 378 self.setCurrentIndex(self.count() - 1) 379 handled = True 380 elif key == Qt.Key_PageDown: 381 if index < self.count() - 1: 382 self.setCurrentIndex(index + 1) 383 else: 384 self.setCurrentIndex(0) 385 handled = True 386 if not handled: 387 QTabWidget.keyPressEvent(self, event) 388 389 def tab_navigate(self, delta=1): 390 """Ctrl+Tab""" 391 if delta > 0 and self.currentIndex() == self.count()-1: 392 index = delta-1 393 elif delta < 0 and self.currentIndex() == 0: 394 index = self.count()+delta 395 else: 396 index = self.currentIndex()+delta 397 self.setCurrentIndex(index) 398 399 def set_close_function(self, func): 400 """Setting Tabs close function 401 None -> tabs are not closable""" 402 state = func is not None 403 if state: 404 self.sig_close_tab.connect(func) 405 try: 406 # Assuming Qt >= 4.5 407 QTabWidget.setTabsClosable(self, state) 408 self.tabCloseRequested.connect(func) 409 except AttributeError: 410 # Workaround for Qt < 4.5 411 close_button = create_toolbutton(self, triggered=func, 412 icon=ima.icon('fileclose'), 413 tip=_("Close current tab")) 414 self.setCornerWidget(close_button if state else None) 415 416 417class Tabs(BaseTabs): 418 """BaseTabs widget with movable tabs and tab navigation shortcuts""" 419 # Signals 420 move_data = Signal(int, int) 421 move_tab_finished = Signal() 422 sig_move_tab = Signal(str, str, int, int) 423 424 def __init__(self, parent, actions=None, menu=None, 425 corner_widgets=None, menu_use_tooltips=False, 426 rename_tabs=False, split_char='', 427 split_index=0): 428 BaseTabs.__init__(self, parent, actions, menu, 429 corner_widgets, menu_use_tooltips) 430 tab_bar = TabBar(self, parent, 431 rename_tabs=rename_tabs, 432 split_char=split_char, 433 split_index=split_index) 434 tab_bar.sig_move_tab.connect(self.move_tab) 435 tab_bar.sig_move_tab[(str, int, int)].connect( 436 self.move_tab_from_another_tabwidget) 437 self.setTabBar(tab_bar) 438 439 config_shortcut(lambda: self.tab_navigate(1), context='editor', 440 name='go to next file', parent=parent) 441 config_shortcut(lambda: self.tab_navigate(-1), context='editor', 442 name='go to previous file', parent=parent) 443 config_shortcut(lambda: self.sig_close_tab.emit(self.currentIndex()), 444 context='editor', name='close file 1', parent=parent) 445 config_shortcut(lambda: self.sig_close_tab.emit(self.currentIndex()), 446 context='editor', name='close file 2', parent=parent) 447 448 @Slot(int, int) 449 def move_tab(self, index_from, index_to): 450 """Move tab inside a tabwidget""" 451 self.move_data.emit(index_from, index_to) 452 453 tip, text = self.tabToolTip(index_from), self.tabText(index_from) 454 icon, widget = self.tabIcon(index_from), self.widget(index_from) 455 current_widget = self.currentWidget() 456 457 self.removeTab(index_from) 458 self.insertTab(index_to, widget, icon, text) 459 self.setTabToolTip(index_to, tip) 460 461 self.setCurrentWidget(current_widget) 462 self.move_tab_finished.emit() 463 464 @Slot(str, int, int) 465 def move_tab_from_another_tabwidget(self, tabwidget_from, 466 index_from, index_to): 467 """Move tab from a tabwidget to another""" 468 469 # We pass self object IDs as QString objs, because otherwise it would 470 # depend on the platform: long for 64bit, int for 32bit. Replacing 471 # by long all the time is not working on some 32bit platforms 472 # (see Issue 1094, Issue 1098) 473 self.sig_move_tab.emit(tabwidget_from, to_text_string(id(self)), 474 index_from, index_to) 475