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