Repository: spyder-ide/spyder

Issue:
History plugin: add an option to show/hide line numbers
_From [Con...@gmail.com](https://code.google.com/u/116100724622290328257/) on 2012-03-11T00:27:55Z_

Enhancement Request

When using Spyder with IPython it is often helpful to make quick macros of code when working on the command line.  To do this efficiently, one must know which lines from the history to place in the macro.  As I often work with the history visible it would be helpful if there were an option to display the history line numbers in the pane for easy reference. 

Spyder 2.1.17
Windows 7 x64

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



Discussion:
_From [pierre.raybaut](https://code.google.com/u/pierre.raybaut/) on 2012-03-18T08:55:30Z_

**Summary:** History plugin: add an option to show/hide line numbers  
  **Labels:** -Type-Defect -Priority-Medium Type-Enhancement Priority-Low  

_From [pierre.raybaut](https://code.google.com/u/pierre.raybaut/) on 2012-03-18T08:55:39Z_

**Labels:** Cat-HistoryLog  

_From [pierre.raybaut](https://code.google.com/u/pierre.raybaut/) on 2012-03-18T14:05:22Z_

**Labels:** -Type-Enhancement Type-Enh  

_From [ccordoba12](https://code.google.com/u/ccordoba12/) on 2013-02-13T09:19:28Z_

**Labels:** MS-v2.3  

_From [pierre.raybaut](https://code.google.com/u/pierre.raybaut/) on 2013-05-11T05:51:59Z_

**Labels:** -MS-v2.3 MS-v2.4  

Would it be OK for me to work on this issue?  I'll try to add tests for history.py at the same time.
Hi @csabella thanks for willing to help! and go ahead! just make sure to take as base of your PR the master branch :) 
@csabella Thanks again for contributing to spyder 😄 

You need to add a `PanelManager` and a `LineNumberArea` panel to it, you could take a look to the `CodeEditor` code (History shares the same base class with the editor and works somehow similar), If you need help ask us in gitter

Resolving patch:
diff --git a/spyder/config/main.py b/spyder/config/main.py
--- a/spyder/config/main.py
+++ b/spyder/config/main.py
@@ -231,6 +231,7 @@
               'max_entries': 100,
               'wrap': True,
               'go_to_eof': True,
+              'line_numbers': False,
               }),
             ('help',
              {
diff --git a/spyder/plugins/history.py b/spyder/plugins/history.py
--- a/spyder/plugins/history.py
+++ b/spyder/plugins/history.py
@@ -43,6 +43,8 @@ def setup_page(self):
 
         sourcecode_group = QGroupBox(_("Source code"))
         wrap_mode_box = self.create_checkbox(_("Wrap lines"), 'wrap')
+        linenumbers_mode_box = self.create_checkbox(_("Show line numbers"),
+                                                    'line_numbers')
         go_to_eof_box = self.create_checkbox(
                         _("Scroll automatically to last entry"), 'go_to_eof')
 
@@ -52,6 +54,7 @@ def setup_page(self):
 
         sourcecode_layout = QVBoxLayout()
         sourcecode_layout.addWidget(wrap_mode_box)
+        sourcecode_layout.addWidget(linenumbers_mode_box)
         sourcecode_layout.addWidget(go_to_eof_box)
         sourcecode_group.setLayout(sourcecode_layout)
 
@@ -77,6 +80,7 @@ def __init__(self, parent):
         self.menu_actions = None
         self.dockviewer = None
         self.wrap_action = None
+        self.linenumbers_action = None
         
         self.editors = []
         self.filenames = []
@@ -147,14 +151,18 @@ def refresh_plugin(self):
         
     def get_plugin_actions(self):
         """Return a list of actions related to plugin"""
-        history_action = create_action(self, _("History..."),
+        self.history_action = create_action(self, _("History..."),
                                        None, ima.icon('history'),
                                        _("Set history maximum entries"),
                                        triggered=self.change_history_depth)
         self.wrap_action = create_action(self, _("Wrap lines"),
                                     toggled=self.toggle_wrap_mode)
         self.wrap_action.setChecked( self.get_option('wrap') )
-        self.menu_actions = [history_action, self.wrap_action]
+        self.linenumbers_action = create_action(
+                self, _("Show line numbers"), toggled=self.toggle_line_numbers)
+        self.linenumbers_action.setChecked(self.get_option('line_numbers'))
+        self.menu_actions = [self.history_action, self.wrap_action,
+                             self.linenumbers_action]
         return self.menu_actions
 
     def on_first_registration(self):
@@ -184,6 +192,8 @@ def apply_plugin_settings(self, options):
         wrap_n = 'wrap'
         wrap_o = self.get_option(wrap_n)
         self.wrap_action.setChecked(wrap_o)
+        linenb_n = 'line_numbers'
+        linenb_o = self.get_option(linenb_n)
         for editor in self.editors:
             if font_n in options:
                 scs = color_scheme_o if color_scheme_n in options else None
@@ -192,7 +202,9 @@ def apply_plugin_settings(self, options):
                 editor.set_color_scheme(color_scheme_o)
             if wrap_n in options:
                 editor.toggle_wrap_mode(wrap_o)
-        
+            if linenb_n in options:
+                editor.toggle_line_numbers(linenumbers=linenb_o, markers=False)
+
     #------ Private API --------------------------------------------------------
     def move_tab(self, index_from, index_to):
         """
@@ -218,7 +230,8 @@ def add_history(self, filename):
             language = 'py'
         else:
             language = 'bat'
-        editor.setup_editor(linenumbers=False, language=language,
+        editor.setup_editor(linenumbers=self.get_option('line_numbers'),
+                            language=language,
                             scrollflagarea=False)
         editor.focus_changed.connect(lambda: self.focus_changed.emit())
         editor.setReadOnly(True)
@@ -270,3 +283,12 @@ def toggle_wrap_mode(self, checked):
         for editor in self.editors:
             editor.toggle_wrap_mode(checked)
         self.set_option('wrap', checked)
+
+    @Slot(bool)
+    def toggle_line_numbers(self, checked):
+        """Toggle line numbers."""
+        if self.tabwidget is None:
+            return
+        for editor in self.editors:
+            editor.toggle_line_numbers(linenumbers=checked, markers=False)
+        self.set_option('line_numbers', checked)
diff --git a/spyder/widgets/sourcecode/codeeditor.py b/spyder/widgets/sourcecode/codeeditor.py
--- a/spyder/widgets/sourcecode/codeeditor.py
+++ b/spyder/widgets/sourcecode/codeeditor.py
@@ -613,6 +613,10 @@ def toggle_wrap_mode(self, enable):
         """Enable/disable wrap mode"""
         self.set_wrap_mode('word' if enable else None)
 
+    def toggle_line_numbers(self, linenumbers=True, markers=False):
+        """Enable/disable line numbers."""
+        self.linenumberarea.setup_margins(linenumbers, markers)
+
     @property
     def panels(self):
         """
@@ -671,7 +675,7 @@ def setup_editor(self, linenumbers=True, language=None, markers=False,
         # Line number area
         if cloned_from:
             self.setFont(font) # this is required for line numbers area
-        self.linenumberarea.setup_margins(linenumbers, markers)
+        self.toggle_line_numbers(linenumbers, markers)
 
         # Lexer
         self.set_language(language, filename)


Test patch:
diff --git a/spyder/plugins/tests/test_history.py b/spyder/plugins/tests/test_history.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/tests/test_history.py
@@ -0,0 +1,266 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+import pytest
+
+from qtpy.QtGui import QTextOption
+
+from spyder.plugins import history
+
+#==============================================================================
+# Utillity Functions
+#==============================================================================
+options = {'wrap': False,
+           'line_numbers': False,
+           'go_to_eof': True,
+           'max_entries': 100}
+
+
+def get_option(self, option):
+    global options
+    return options[option]
+
+
+def set_option(self, option, value):
+    global options
+    options[option] = value
+
+#==============================================================================
+# Qt Test Fixtures
+#==============================================================================
+@pytest.fixture
+def historylog(qtbot, monkeypatch):
+    """Return a fixture for base history log, which is a plugin widget."""
+    monkeypatch.setattr(history.HistoryLog,
+                        'register_widget_shortcuts',
+                        lambda *args: None)
+    historylog = history.HistoryLog(None)
+    qtbot.addWidget(historylog)
+    historylog.show()
+    yield historylog
+    historylog.closing_plugin()
+    historylog.close()
+
+
+@pytest.fixture
+def historylog_with_tab(historylog, mocker, monkeypatch):
+    """Return a fixture for a history log with one tab.
+
+    The base history log is a plugin widget.  Within the plugin widget,
+    the method add_history creates a tab containing a code editor
+    for each history file.  This fixture creates a history log with
+    one tab containing no text.
+    """
+    hl = historylog
+    # Mock read so file doesn't have to exist.
+    mocker.patch.object(history.encoding, 'read')
+    history.encoding.read.return_value = ('', '')
+
+    # Monkeypatch current options.
+    monkeypatch.setattr(history.HistoryLog, 'get_option', get_option)
+    monkeypatch.setattr(history.HistoryLog, 'set_option', set_option)
+
+    # Create tab for page.
+    hl.set_option('wrap', False)
+    hl.set_option('line_numbers', False)
+    hl.set_option('max_entries', 100)
+    hl.set_option('go_to_eof', True)
+    hl.add_history('test_history.py')
+    return hl
+
+#==============================================================================
+# Tests
+#==============================================================================
+
+def test_init(historylog):
+    """Test HistoryLog.__init__.
+
+    Test that the initialization created the expected instance variables
+    and widgets for a new HistoryLog instance.
+    """
+    hl = historylog
+    assert hl.editors == []
+    assert hl.filenames == []
+    assert hl.plugin_actions == hl.menu_actions
+    assert hl.tabwidget.menu.actions() == hl.menu_actions
+    assert hl.tabwidget.cornerWidget().menu().actions() == hl.menu_actions
+
+
+def test_add_history(historylog, mocker, monkeypatch):
+    """Test the add_history method.
+
+    Test adding a history file to the history log widget and the
+    code editor properties that are enabled/disabled.
+    """
+    hl = historylog
+    hle = hl.editors
+
+    # Mock read so file doesn't have to exist.
+    mocker.patch.object(history.encoding, 'read')
+
+    # Monkeypatch current options.
+    monkeypatch.setattr(history.HistoryLog, 'get_option', get_option)
+    monkeypatch.setattr(history.HistoryLog, 'set_option', set_option)
+    # No editors yet.
+    assert len(hl.editors) == 0
+
+    # Add one file.
+    tab1 = 'test_history.py'
+    text1 = 'a = 5\nb= 10\na + b\n'
+    hl.set_option('line_numbers', False)
+    hl.set_option('wrap', False)
+    history.encoding.read.return_value = (text1, '')
+    hl.add_history(tab1)
+    # Check tab and editor were created correctly.
+    assert len(hle) == 1
+    assert hl.filenames == [tab1]
+    assert hl.tabwidget.currentIndex() == 0
+    assert not hle[0].linenumberarea.isVisible()
+    assert hle[0].wordWrapMode() == QTextOption.NoWrap
+    assert hl.tabwidget.tabText(0) == tab1
+    assert hl.tabwidget.tabToolTip(0) == tab1
+
+    hl.set_option('line_numbers', True)
+    hl.set_option('wrap', True)
+    # Try to add same file -- does not process filename again, so
+    # linenumbers and wrap doesn't change.
+    hl.add_history(tab1)
+    assert hl.tabwidget.currentIndex() == 0
+    assert not hl.editors[0].linenumberarea.isVisible()
+
+    # Add another file.
+    tab2 = 'history2.js'
+    text2 = 'random text\nspam line\n\n\n\n'
+    history.encoding.read.return_value = (text2, '')
+    hl.add_history(tab2)
+    # Check second tab and editor were created correctly.
+    assert len(hle) == 2
+    assert hl.filenames == [tab1, tab2]
+    assert hl.tabwidget.currentIndex() == 1
+    assert hle[1].linenumberarea.isVisible()
+    assert hle[1].wordWrapMode() == QTextOption.WrapAtWordBoundaryOrAnywhere
+    assert hl.tabwidget.tabText(1) == tab2
+    assert hl.tabwidget.tabToolTip(1) == tab2
+
+    assert hl.filenames == [tab1, tab2]
+
+    # Check differences between tabs based on setup.
+    assert hle[0].supported_language
+    assert hle[0].is_python()
+    assert hle[0].isReadOnly()
+    assert not hle[0].isVisible()
+    assert hle[0].toPlainText() == text1
+
+    assert not hle[1].supported_language
+    assert not hle[1].is_python()
+    assert hle[1].isReadOnly()
+    assert hle[1].isVisible()
+    assert hle[1].toPlainText() == text2
+
+
+def test_append_to_history(historylog_with_tab, mocker):
+    """Test the append_to_history method.
+
+    Test adding text to a history file.  Also test the go_to_eof config
+    option for positioning the cursor.
+    """
+    hl = historylog_with_tab
+
+    # Toggle to move to the end of the file after appending.
+    hl.set_option('go_to_eof', True)
+    # Force cursor to the beginning of the file.
+    hl.editors[0].set_cursor_position('sof')
+    hl.append_to_history('test_history.py', 'import re\n')
+    assert hl.editors[0].toPlainText() == 'import re\n'
+    assert hl.tabwidget.currentIndex() == 0
+    # Cursor moved to end.
+    assert hl.editors[0].is_cursor_at_end()
+    assert not hl.editors[0].linenumberarea.isVisible()
+
+    # Toggle to not move cursor after appending.
+    hl.set_option('go_to_eof', False)
+    # Force cursor to the beginning of the file.
+    hl.editors[0].set_cursor_position('sof')
+    hl.append_to_history('test_history.py', 'a = r"[a-z]"\n')
+    assert hl.editors[0].toPlainText() == 'import re\na = r"[a-z]"\n'
+    # Cursor not at end.
+    assert not hl.editors[0].is_cursor_at_end()
+
+
+def test_change_history_depth(historylog_with_tab, mocker):
+    """Test the change_history_depth method.
+
+    Modify the 'Maximum history entries' values to test the config action.
+    """
+    hl = historylog_with_tab
+    action = hl.history_action
+    # Mock dialog.
+    mocker.patch.object(history.QInputDialog, 'getInt')
+
+    # Starts with default.
+    assert hl.get_option('max_entries') == 100
+
+    # Invalid data.
+    history.QInputDialog.getInt.return_value = (10, False)
+    action.trigger()
+    assert hl.get_option('max_entries') == 100  # No change.
+
+    # Valid data.
+    history.QInputDialog.getInt.return_value = (475, True)
+    action.trigger()
+    assert hl.get_option('max_entries') == 475
+
+
+def test_toggle_wrap_mode(historylog_with_tab):
+    """Test the toggle_wrap_mode method.
+
+    Toggle the 'Wrap lines' config action.
+    """
+    hl = historylog_with_tab
+    action = hl.wrap_action
+    action.setChecked(False)
+
+    # Starts with wrap mode off.
+    assert hl.editors[0].wordWrapMode() == QTextOption.NoWrap
+    assert not hl.get_option('wrap')
+
+    # Toggles wrap mode on.
+    action.setChecked(True)
+    assert hl.editors[0].wordWrapMode() == QTextOption.WrapAtWordBoundaryOrAnywhere
+    assert hl.get_option('wrap')
+
+    # Toggles wrap mode off.
+    action.setChecked(False)
+    assert hl.editors[0].wordWrapMode() == QTextOption.NoWrap
+    assert not hl.get_option('wrap')
+
+
+def test_toggle_line_numbers(historylog_with_tab):
+    """Test toggle_line_numbers method.
+
+    Toggle the 'Show line numbers' config action.
+    """
+    hl = historylog_with_tab
+    action = hl.linenumbers_action
+    action.setChecked(False)
+
+    # Starts without line numbers.
+    assert not hl.editors[0].linenumberarea.isVisible()
+    assert not hl.get_option('line_numbers')
+
+    # Toggles line numbers on.
+    action.setChecked(True)
+    assert hl.editors[0].linenumberarea.isVisible()
+    assert hl.get_option('line_numbers')
+
+    # Toggles line numbers off.
+    action.setChecked(False)
+    assert not hl.editors[0].linenumberarea.isVisible()
+    assert not hl.get_option('line_numbers')
+
+
+if __name__ == "__main__":
+    pytest.main()