Repository: spyder-ide/spyder

Issue:
Allow to rename IPython consoles
_From [mishok...@gmail.com](https://code.google.com/u/104975048361012621268/) on 2014-09-12T11:33:00Z_

When I open multiple ipython consoles, they have non human readable names, it could be nice to give them custom name.

_Original issue: http://code.google.com/p/spyderlib/issues/detail?id=1962_



Discussion:
_From [ccordoba12](https://code.google.com/u/ccordoba12/) on 2014-09-27T05:28:21Z_

we could a context menu option to let rename a client. This is defined in widgets/ipython.py menu_actions.

**Labels:** Easy  

_From [ccordoba12](https://code.google.com/u/ccordoba12/) on 2014-11-18T15:43:37Z_

**Status:** Accepted  
  **Labels:** MS-v2.3.3  

_From [ccordoba12](https://code.google.com/u/ccordoba12/) on 2015-01-08T15:05:14Z_

**Summary:** Allow to rename IPython consoles  <span class="oldvalue"> (was: Spyder should allow give ipython sessions a logical human readable name) </span>  
  **Labels:** -MS-v2.3.3 MS-v2.3.4  

Hi guys,

I love Spyder and would like to engage on its development. Was looking for an easy feature to start with and this seems doable. Been working with Python for a while, have no experience on collaborative coding but learn fast.

May I help?

Regards
hi @tjolive thanks for your interest in helping us out. I would recommend you to start by reading the wiki the section on contributing and github workflow.

Cheers
hi @goanpeca thanks for the swift reply. Docs are read, fork is made. btw: wiki on contributing has a big "TODO" :)

Do you have already any idea on how to rename the consoles?
Squashing commits is an advanced topic, so you don't really need it :-) at this point

Spyder in general work like this. We have **widgets** stored [here](https://github.com/spyder-ide/spyder/tree/master/spyder/widgets) that should be self contained (should run by themselves) and there are **plugins**, which are wrappers over the widgets that allow communication among them. So a plugin can communicate with another plugin via signals and slots (a Qt/PyQt mechanism)

The plugin is located [https://github.com/spyder-ide/spyder/blob/master/spyder/plugins/ipythonconsole.py](here)
And the widget is located [here](https://github.com/spyder-ide/spyder/tree/master/spyder/widgets/ipythonconsole)

Hope that helps, cheers!
hi @goanpeca , thank you for the intro. I'm now doing reverse engineering on the app's structure and there is a learning curve. Hope you guys are not in a hurry for this one :) Anyway, please assign it to me.

The solutions I'm aiming:
 1. working solution: add "rename tab" to the context menu (right-click), open a QInputDialog for the user to change name
 2. better UX: instead of a QInputDialog, open a QLineEdit on top of the tab's name to give the impression you are actually changing the name on the tab itself
 3. best UX: react to slow double click, windows-explorer-file-rename-style, no need to open context menu
Hi @tjolive sure dude, take your time ;-), you have like a couple of months...

I think solution 1 is a good start. Once you have that working we can do solution 2 which should be straightforward to do. Dont know if solution 3 I understand, but I would expect a normal double click to allow renaming the tab.
@tjolive, please be sure to make your PR against the `3.x` branch instead of `master` if you want to see your work on Spyder 3.2 instead of 4.0 ;-)
@ccordoba12 : thanks for the heads up, forking 3.x now.

I have already something working, with a QLineEdit over the tab but there's still some fine tuning to do. Currently I'm working with "tabs.py" which is used by IPythonConsole but also by externalconsole and history (for now).

Q1: do we want to restrict the "tab name changing" to IPythonConsole only or shall we expose this for any other widgets that utilize the _Tabs_ class?
Q2: the editor widget is using _BaseTabs_, which for example don't allow moving tabs. Do you know the reason for this? I would see value in moving editor tabs and even renaming them (even though that would require changes on the tooltip, etc since now tab name = file name)
>Q1: do we want to restrict the "tab name changing" to IPythonConsole only or shall we expose this for any other widgets that utilize the Tabs class?

For now lets limit this to ipython consoles as is the only place where I this making sense at the moment. Normal python consoles will be removed for 4.0 and we will unify things on the ipython one.

>Q2: the editor widget is using BaseTabs, which for example don't allow moving tabs. Do you know the reason for this? I would see value in moving editor tabs and even renaming them (even though that would require changes on the tooltip, etc since now tab name = file name)

This is being tackled by @rlaverde on another pull request so you can safely ignore those for now and focus on the ones that affect the ipython widget.

Cheers and thanks for working on this :-)

Resolving patch:
diff --git a/spyder/plugins/ipythonconsole.py b/spyder/plugins/ipythonconsole.py
--- a/spyder/plugins/ipythonconsole.py
+++ b/spyder/plugins/ipythonconsole.py
@@ -621,7 +621,7 @@ def __init__(self, parent, testing=False):
                 os.mkdir(programs.TEMPDIR)
 
         layout = QVBoxLayout()
-        self.tabwidget = Tabs(self, self.menu_actions)
+        self.tabwidget = Tabs(self, self.menu_actions, rename_tabs=True)
         if hasattr(self.tabwidget, 'setDocumentMode')\
            and not sys.platform == 'darwin':
             # Don't set document mode to true on OSX because it generates
@@ -630,6 +630,8 @@ def __init__(self, parent, testing=False):
             self.tabwidget.setDocumentMode(True)
         self.tabwidget.currentChanged.connect(self.refresh_plugin)
         self.tabwidget.move_data.connect(self.move_tab)
+        self.tabwidget.tabBar().sig_change_name.connect(
+            self.rename_tabs_after_change)
 
         self.tabwidget.set_close_function(self.close_client)
 
@@ -762,6 +764,10 @@ def get_plugin_actions(self):
                _("Open a new IPython console connected to an existing kernel"),
                triggered=self.create_client_for_kernel)
         
+        rename_tab_action = create_action(self, _("Rename tab"),
+                                       icon=ima.icon('rename'),
+                                       triggered=self.tab_name_editor)
+        
         # Add the action to the 'Consoles' menu on the main window
         main_consoles_menu = self.main.consoles_menu_actions
         main_consoles_menu.insert(0, create_client_action)
@@ -770,7 +776,8 @@ def get_plugin_actions(self):
         
         # Plugin actions
         self.menu_actions = [create_client_action, MENU_SEPARATOR,
-                             restart_action, connect_to_kernel_action]
+                             restart_action, connect_to_kernel_action,
+                             MENU_SEPARATOR, rename_tab_action]
         
         return self.menu_actions
 
@@ -889,9 +896,10 @@ def write_to_stdin(self, line):
     def create_new_client(self, give_focus=True, path=''):
         """Create a new client"""
         self.master_clients += 1
-        name = "%d/A" % self.master_clients
+        client_id = dict(int_id=to_text_string(self.master_clients),
+                         str_id='A')
         cf = self._new_connection_file()
-        client = ClientWidget(self, name=name,
+        client = ClientWidget(self, id_=client_id,
                               history_filename=get_conf_path('history.py'),
                               config_options=self.config_options(),
                               additional_options=self.additional_options(),
@@ -1183,11 +1191,6 @@ def get_client_index_from_id(self, client_id):
             if id(client) == client_id:
                 return index
 
-    def rename_client_tab(self, client):
-        """Rename client's tab"""
-        index = self.get_client_index_from_id(id(client))
-        self.tabwidget.setTabText(index, client.get_name())
-
     def get_related_clients(self, client):
         """
         Get all other clients that are connected to the same kernel as `client`
@@ -1336,6 +1339,30 @@ def move_tab(self, index_from, index_to):
         self.clients.insert(index_to, client)
         self.update_plugin_title.emit()
 
+    def rename_client_tab(self, client):
+        """Rename client's tab"""
+        index = self.get_client_index_from_id(id(client))
+        self.tabwidget.setTabText(index, client.get_name())
+
+    def rename_tabs_after_change(self, given_name):
+        client = self.get_current_client()
+
+        # Rename current client tab to add str_id
+        if client.allow_rename:
+            client.given_name = given_name
+        self.rename_client_tab(client)
+
+        # Rename related clients
+        if client.allow_rename:
+            for cl in self.get_related_clients(client):
+                cl.given_name = given_name
+                self.rename_client_tab(cl)
+
+    def tab_name_editor(self):
+        """Trigger the tab name editor."""
+        index = self.tabwidget.currentIndex()
+        self.tabwidget.tabBar().tab_name_editor.edit_tab(index)
+
     #------ Public API (for help) ---------------------------------------------
     def go_to_error(self, text):
         """Go to error if relevant"""
@@ -1425,35 +1452,41 @@ def _create_client_for_kernel(self, connection_file, hostname, sshkey,
                                    "<b>%s</b>") % connection_file)
             return
 
-        # Getting the master name that corresponds to the client
+        # Getting the master id that corresponds to the client
         # (i.e. the i in i/A)
-        master_name = None
+        master_id = None
+        given_name = None
         external_kernel = False
         slave_ord = ord('A') - 1
         kernel_manager = None
+
         for cl in self.get_clients():
             if connection_file in cl.connection_file:
                 if cl.get_kernel() is not None:
                     kernel_manager = cl.get_kernel()
                 connection_file = cl.connection_file
-                if master_name is None:
-                    master_name = cl.name.split('/')[0]
-                new_slave_ord = ord(cl.name.split('/')[1])
+                if master_id is None:
+                    master_id = cl.id_['int_id']
+                given_name = cl.given_name
+                new_slave_ord = ord(cl.id_['str_id'])
                 if new_slave_ord > slave_ord:
                     slave_ord = new_slave_ord
         
         # If we couldn't find a client with the same connection file,
         # it means this is a new master client
-        if master_name is None:
+        if master_id is None:
             self.master_clients += 1
-            master_name = to_text_string(self.master_clients)
+            master_id = to_text_string(self.master_clients)
             external_kernel = True
 
         # Set full client name
-        name = master_name + '/' + chr(slave_ord + 1)
+        client_id = dict(int_id=master_id,
+                         str_id=chr(slave_ord + 1))
 
         # Creating the client
-        client = ClientWidget(self, name=name,
+        client = ClientWidget(self,
+                              id_=client_id,
+                              given_name=given_name,
                               history_filename=get_conf_path('history.py'),
                               config_options=self.config_options(),
                               additional_options=self.additional_options(),
diff --git a/spyder/widgets/ipythonconsole/client.py b/spyder/widgets/ipythonconsole/client.py
--- a/spyder/widgets/ipythonconsole/client.py
+++ b/spyder/widgets/ipythonconsole/client.py
@@ -90,26 +90,29 @@ class ClientWidget(QWidget, SaveHistoryMixin):
 
     append_to_history = Signal(str, str)
 
-    def __init__(self, plugin, name, history_filename, config_options,
+    def __init__(self, plugin, id_,
+                 history_filename, config_options,
                  additional_options, interpreter_versions,
                  connection_file=None, hostname=None,
                  menu_actions=None, slave=False,
-                 external_kernel=False):
+                 external_kernel=False, given_name=None):
         super(ClientWidget, self).__init__(plugin)
         SaveHistoryMixin.__init__(self, history_filename)
 
         # --- Init attrs
-        self.name = name
+        self.id_ = id_
         self.connection_file = connection_file
         self.hostname = hostname
         self.menu_actions = menu_actions
         self.slave = slave
+        self.given_name = given_name
 
         # --- Other attrs
         self.options_button = None
         self.stop_button = None
         self.stop_icon = ima.icon('stop')
         self.history = []
+        self.allow_rename = True
 
         # --- Widgets
         self.shellwidget = ShellWidget(config=config_options,
@@ -249,8 +252,18 @@ def show_kernel_error(self, error):
 
     def get_name(self):
         """Return client name"""
-        return ((_("Console") if self.hostname is None else self.hostname)
-                + " " + self.name)
+        if self.given_name is None:
+            # Name according to host
+            if self.hostname is None:
+                name = _("Console")
+            else:
+                name = self.hostname
+            # Adding id to name
+            client_id = self.id_['int_id'] + u'/' + self.id_['str_id']
+            name = name + u' ' + client_id
+        else:
+            name = self.given_name + u'/' + self.id_['str_id']
+        return name
 
     def get_control(self):
         """Return the text widget (or similar) to give focus to"""
@@ -419,6 +432,8 @@ def kernel_restarted_message(self, msg):
                 stderr = self._read_stderr()
             except:
                 stderr = None
+        except FileNotFoundError:
+            stderr = None
 
         if stderr:
             self.show_kernel_error('<tt>%s</tt>' % stderr)
diff --git a/spyder/widgets/tabs.py b/spyder/widgets/tabs.py
--- a/spyder/widgets/tabs.py
+++ b/spyder/widgets/tabs.py
@@ -16,10 +16,11 @@
 import sys
 
 # Third party imports
-from qtpy.QtCore import QByteArray, QMimeData, QPoint, Qt, Signal
+from qtpy import PYQT5
+from qtpy.QtCore import QByteArray, QMimeData, QPoint, Qt, Signal, QEvent
 from qtpy.QtGui import QDrag
 from qtpy.QtWidgets import (QApplication, QHBoxLayout, QMenu, QTabBar,
-                            QTabWidget, QWidget)
+                            QTabWidget, QWidget, QLineEdit)
 
 # Local imports
 from spyder.config.base import _
@@ -31,11 +32,103 @@
                                     create_toolbutton)
 
 
+class EditTabNamePopup(QLineEdit):
+    """Popup on top of the tab to edit its name."""
+
+    def __init__(self, parent):
+        """Popup on top of the tab to edit its name."""
+
+        # Variables
+        # Parent (main)
+        self.main = parent if parent is not None else self.parent()
+        # Track with tab is being edited
+        self.tab_index = None
+
+        # Widget setup
+        QLineEdit.__init__(self, parent=None)
+
+        # Slot to handle tab name update
+        self.editingFinished.connect(self.edit_finished)
+
+        # Even filter to catch clicks and ESC key
+        self.installEventFilter(self)
+
+        # Clean borders and no shadow to blend with tab
+        if PYQT5:
+            self.setWindowFlags(
+                Qt.Popup |
+                Qt.FramelessWindowHint |
+                Qt.NoDropShadowWindowHint
+            )
+        else:
+            self.setWindowFlags(
+                Qt.Popup |
+                Qt.FramelessWindowHint
+            )
+        self.setFrame(False)
+
+        # Align with tab name
+        self.setTextMargins(9, 0, 0, 0)
+
+    def eventFilter(self, widget, event):
+        """Catch clicks outside the object and ESC key press."""
+        if ((event.type() == QEvent.MouseButtonPress and
+                 not self.geometry().contains(event.globalPos())) or
+                (event.type() == QEvent.KeyPress and
+                 event.key() == Qt.Key_Escape)):
+            # Exits editing
+            self.hide()
+            self.setFocus(False)
+            return True
+
+        # Event is not interessant, raise to parent
+        return QLineEdit.eventFilter(self, widget, event)
+
+    def edit_tab(self, index):
+        """Activate the edit tab."""
+
+        # Sets focus, shows cursor
+        self.setFocus(True)
+
+        # Updates tab index
+        self.tab_index = index
+
+        # Gets tab size and shrinks to avoid overlapping tab borders
+        rect = self.main.tabRect(index)
+        rect.adjust(1, 1, -2, -1)
+
+        # Sets size
+        self.setFixedSize(rect.size())
+
+        # Places on top of the tab
+        self.move(self.main.mapToGlobal(rect.topLeft()))
+
+        # Copies tab name and selects all
+        self.setText(self.main.tabText(index))
+        self.selectAll()
+
+        if not self.isVisible():
+            # Makes editor visible
+            self.show()
+
+    def edit_finished(self):
+        """On clean exit, update tab name."""
+        # Hides editor
+        self.hide()
+
+        if isinstance(self.tab_index, int) and self.tab_index >= 0:
+            # We are editing a valid tab, update name
+            tab_text = to_text_string(self.text())
+            self.main.setTabText(self.tab_index, tab_text)
+            self.main.sig_change_name.emit(tab_text)
+
+
 class TabBar(QTabBar):
     """Tabs base class with drag and drop support"""
     sig_move_tab = Signal((int, int), (str, int, int))
+    sig_change_name = Signal(str)
     
-    def __init__(self, parent, ancestor):
+    def __init__(self, parent, ancestor, rename_tabs=False):
         QTabBar.__init__(self, parent)
         self.ancestor = ancestor
 
@@ -48,6 +141,14 @@ def __init__(self, parent, ancestor):
         self.setAcceptDrops(True)
         self.setUsesScrollButtons(True)
 
+        # Tab name editor
+        self.rename_tabs = rename_tabs
+        if self.rename_tabs:
+            # Creates tab name editor
+            self.tab_name_editor = EditTabNamePopup(self)
+        else:
+            self.tab_name_editor = None
+
     def mousePressEvent(self, event):
         """Reimplement Qt method"""
         if event.button() == Qt.LeftButton:
@@ -111,7 +212,20 @@ def dropEvent(self, event):
             self.sig_move_tab.emit(index_from, index_to)
             event.acceptProposedAction()
         QTabBar.dropEvent(self, event)
-        
+
+    def mouseDoubleClickEvent(self, event):
+        """Override Qt method to trigger the tab name editor."""
+        if self.rename_tabs is True and \
+                event.buttons() == Qt.MouseButtons(Qt.LeftButton):
+            # Tab index
+            index = self.tabAt(event.pos())
+            if index >= 0:
+                # Tab is valid, call tab name editor
+                self.tab_name_editor.edit_tab(index)
+        else:
+            # Event is not interesting, raise to parent
+            QTabBar.mouseDoubleClickEvent(self, event)
+
         
 class BaseTabs(QTabWidget):
     """TabWidget with context menu and corner widgets"""
@@ -284,10 +398,11 @@ class Tabs(BaseTabs):
     sig_move_tab = Signal(str, str, int, int)
     
     def __init__(self, parent, actions=None, menu=None,
-                 corner_widgets=None, menu_use_tooltips=False):
+                 corner_widgets=None, menu_use_tooltips=False,
+                 rename_tabs=False):
         BaseTabs.__init__(self, parent, actions, menu,
                           corner_widgets, menu_use_tooltips)
-        tab_bar = TabBar(self, parent)
+        tab_bar = TabBar(self, parent, rename_tabs=rename_tabs)
         tab_bar.sig_move_tab.connect(self.move_tab)
         tab_bar.sig_move_tab[(str, int, int)].connect(
                                           self.move_tab_from_another_tabwidget)


Test patch:
diff --git a/spyder/plugins/tests/test_ipythonconsole.py b/spyder/plugins/tests/test_ipythonconsole.py
--- a/spyder/plugins/tests/test_ipythonconsole.py
+++ b/spyder/plugins/tests/test_ipythonconsole.py
@@ -484,7 +484,7 @@ def test_load_kernel_file_from_id(ipyconsole, qtbot):
     qtbot.wait(1000)
 
     new_client = ipyconsole.get_clients()[1]
-    assert new_client.name == '1/B'
+    assert new_client.id_ == dict(int_id='1', str_id='B')
 
 
 @flaky(max_runs=3)
@@ -533,7 +533,7 @@ def test_load_kernel_file(ipyconsole, qtbot):
     with qtbot.waitSignal(new_shell.executed):
         new_shell.execute('a = 10')
 
-    assert new_client.name == '1/B'
+    assert new_client.id_ == dict(int_id='1', str_id='B')
     assert shell.get_value('a') == new_shell.get_value('a')