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"""Mix-in classes
8
9These classes were created to be able to provide Spyder's regular text and
10console widget features to an independant widget based on QTextEdit for the
11IPython console plugin.
12"""
13
14# Standard library imports
15from xml.sax.saxutils import escape
16import os
17import os.path as osp
18import re
19import sre_constants
20import textwrap
21
22# Third party imports
23from qtpy.QtCore import QPoint, Qt
24from qtpy.QtGui import QCursor, QTextCursor, QTextDocument
25from qtpy.QtWidgets import QApplication, QToolTip
26from qtpy import QT_VERSION
27
28# Local imports
29from spyder.config.base import _
30from spyder.py3compat import is_text_string, to_text_string
31from spyder.utils import encoding, sourcecode, programs
32from spyder.utils.dochelpers import (getargspecfromtext, getobj,
33                                     getsignaturefromtext)
34from spyder.utils.misc import get_error_match
35from spyder.widgets.arraybuilder import NumpyArrayDialog
36
37QT55_VERSION = programs.check_version(QT_VERSION, "5.5", ">=")
38
39if QT55_VERSION:
40    from qtpy.QtCore import QRegularExpression
41else:
42    from qtpy.QtCore import QRegExp
43
44
45class BaseEditMixin(object):
46
47    def __init__(self):
48        self.eol_chars = None
49        self.calltip_size = 600
50
51    #------Line number area
52    def get_linenumberarea_width(self):
53        """Return line number area width"""
54        # Implemented in CodeEditor, but needed for calltip/completion widgets
55        return 0
56
57
58    #------Calltips
59    def _format_signature(self, text):
60        formatted_lines = []
61        name = text.split('(')[0]
62        rows = textwrap.wrap(text, width=50,
63                             subsequent_indent=' '*(len(name)+1))
64        for r in rows:
65            r = escape(r) # Escape most common html chars
66            r = r.replace(' ', ' ')
67            for char in ['=', ',', '(', ')', '*', '**']:
68                r = r.replace(char,
69                       '<span style=\'color: red; font-weight: bold\'>' + \
70                       char + '</span>')
71            formatted_lines.append(r)
72        signature = '<br>'.join(formatted_lines)
73        return signature, rows
74
75    def show_calltip(self, title, text, signature=False, color='#2D62FF',
76                     at_line=None, at_position=None):
77        """Show calltip"""
78        if text is None or len(text) == 0:
79            return
80
81        # Saving cursor position:
82        if at_position is None:
83            at_position = self.get_position('cursor')
84        self.calltip_position = at_position
85
86        # Preparing text:
87        if signature:
88            text, wrapped_textlines = self._format_signature(text)
89        else:
90            if isinstance(text, list):
91                text = "\n    ".join(text)
92            text = text.replace('\n', '<br>')
93            if len(text) > self.calltip_size:
94                text = text[:self.calltip_size] + " ..."
95
96        # Formatting text
97        font = self.font()
98        size = font.pointSize()
99        family = font.family()
100        format1 = '<div style=\'font-family: "%s"; font-size: %spt; color: %s\'>'\
101                  % (family, size, color)
102        format2 = '<div style=\'font-family: "%s"; font-size: %spt\'>'\
103                  % (family, size-1 if size > 9 else size)
104        tiptext = format1 + ('<b>%s</b></div>' % title) + '<hr>' + \
105                  format2 + text + "</div>"
106
107        # Showing tooltip at cursor position:
108        cx, cy = self.get_coordinates('cursor')
109        if at_line is not None:
110            cx = 5
111            cursor = QTextCursor(self.document().findBlockByNumber(at_line-1))
112            cy = self.cursorRect(cursor).top()
113        point = self.mapToGlobal(QPoint(cx, cy))
114        point.setX(point.x()+self.get_linenumberarea_width())
115        point.setY(point.y()+font.pointSize()+5)
116        if signature:
117            self.calltip_widget.show_tip(point, tiptext, wrapped_textlines)
118        else:
119            QToolTip.showText(point, tiptext)
120
121
122    #------EOL characters
123    def set_eol_chars(self, text):
124        """Set widget end-of-line (EOL) characters from text (analyzes text)"""
125        if not is_text_string(text): # testing for QString (PyQt API#1)
126            text = to_text_string(text)
127        eol_chars = sourcecode.get_eol_chars(text)
128        is_document_modified = eol_chars is not None and self.eol_chars is not None
129        self.eol_chars = eol_chars
130        if is_document_modified:
131            self.document().setModified(True)
132            if self.sig_eol_chars_changed is not None:
133                self.sig_eol_chars_changed.emit(eol_chars)
134
135    def get_line_separator(self):
136        """Return line separator based on current EOL mode"""
137        if self.eol_chars is not None:
138            return self.eol_chars
139        else:
140            return os.linesep
141
142    def get_text_with_eol(self):
143        """Same as 'toPlainText', replace '\n'
144        by correct end-of-line characters"""
145        utext = to_text_string(self.toPlainText())
146        lines = utext.splitlines()
147        linesep = self.get_line_separator()
148        txt = linesep.join(lines)
149        if utext.endswith('\n'):
150            txt += linesep
151        return txt
152
153
154    #------Positions, coordinates (cursor, EOF, ...)
155    def get_position(self, subject):
156        """Get offset in character for the given subject from the start of
157           text edit area"""
158        cursor = self.textCursor()
159        if subject == 'cursor':
160            pass
161        elif subject == 'sol':
162            cursor.movePosition(QTextCursor.StartOfBlock)
163        elif subject == 'eol':
164            cursor.movePosition(QTextCursor.EndOfBlock)
165        elif subject == 'eof':
166            cursor.movePosition(QTextCursor.End)
167        elif subject == 'sof':
168            cursor.movePosition(QTextCursor.Start)
169        else:
170            # Assuming that input argument was already a position
171            return subject
172        return cursor.position()
173
174    def get_coordinates(self, position):
175        position = self.get_position(position)
176        cursor = self.textCursor()
177        cursor.setPosition(position)
178        point = self.cursorRect(cursor).center()
179        return point.x(), point.y()
180
181    def get_cursor_line_column(self):
182        """Return cursor (line, column) numbers"""
183        cursor = self.textCursor()
184        return cursor.blockNumber(), cursor.columnNumber()
185
186    def get_cursor_line_number(self):
187        """Return cursor line number"""
188        return self.textCursor().blockNumber()+1
189
190    def set_cursor_position(self, position):
191        """Set cursor position"""
192        position = self.get_position(position)
193        cursor = self.textCursor()
194        cursor.setPosition(position)
195        self.setTextCursor(cursor)
196        self.ensureCursorVisible()
197
198    def move_cursor(self, chars=0):
199        """Move cursor to left or right (unit: characters)"""
200        direction = QTextCursor.Right if chars > 0 else QTextCursor.Left
201        for _i in range(abs(chars)):
202            self.moveCursor(direction, QTextCursor.MoveAnchor)
203
204    def is_cursor_on_first_line(self):
205        """Return True if cursor is on the first line"""
206        cursor = self.textCursor()
207        cursor.movePosition(QTextCursor.StartOfBlock)
208        return cursor.atStart()
209
210    def is_cursor_on_last_line(self):
211        """Return True if cursor is on the last line"""
212        cursor = self.textCursor()
213        cursor.movePosition(QTextCursor.EndOfBlock)
214        return cursor.atEnd()
215
216    def is_cursor_at_end(self):
217        """Return True if cursor is at the end of the text"""
218        return self.textCursor().atEnd()
219
220    def is_cursor_before(self, position, char_offset=0):
221        """Return True if cursor is before *position*"""
222        position = self.get_position(position) + char_offset
223        cursor = self.textCursor()
224        cursor.movePosition(QTextCursor.End)
225        if position < cursor.position():
226            cursor.setPosition(position)
227            return self.textCursor() < cursor
228
229    def __move_cursor_anchor(self, what, direction, move_mode):
230        assert what in ('character', 'word', 'line')
231        if what == 'character':
232            if direction == 'left':
233                self.moveCursor(QTextCursor.PreviousCharacter, move_mode)
234            elif direction == 'right':
235                self.moveCursor(QTextCursor.NextCharacter, move_mode)
236        elif what == 'word':
237            if direction == 'left':
238                self.moveCursor(QTextCursor.PreviousWord, move_mode)
239            elif direction == 'right':
240                self.moveCursor(QTextCursor.NextWord, move_mode)
241        elif what == 'line':
242            if direction == 'down':
243                self.moveCursor(QTextCursor.NextBlock, move_mode)
244            elif direction == 'up':
245                self.moveCursor(QTextCursor.PreviousBlock, move_mode)
246
247    def move_cursor_to_next(self, what='word', direction='left'):
248        """
249        Move cursor to next *what* ('word' or 'character')
250        toward *direction* ('left' or 'right')
251        """
252        self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor)
253
254
255    #------Selection
256    def clear_selection(self):
257        """Clear current selection"""
258        cursor = self.textCursor()
259        cursor.clearSelection()
260        self.setTextCursor(cursor)
261
262    def extend_selection_to_next(self, what='word', direction='left'):
263        """
264        Extend selection to next *what* ('word' or 'character')
265        toward *direction* ('left' or 'right')
266        """
267        self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor)
268
269
270    #------Text: get, set, ...
271    def __select_text(self, position_from, position_to):
272        position_from = self.get_position(position_from)
273        position_to = self.get_position(position_to)
274        cursor = self.textCursor()
275        cursor.setPosition(position_from)
276        cursor.setPosition(position_to, QTextCursor.KeepAnchor)
277        return cursor
278
279    def get_text_line(self, line_nb):
280        """Return text line at line number *line_nb*"""
281        # Taking into account the case when a file ends in an empty line,
282        # since splitlines doesn't return that line as the last element
283        # TODO: Make this function more efficient
284        try:
285            return to_text_string(self.toPlainText()).splitlines()[line_nb]
286        except IndexError:
287            return self.get_line_separator()
288
289    def get_text(self, position_from, position_to):
290        """
291        Return text between *position_from* and *position_to*
292        Positions may be positions or 'sol', 'eol', 'sof', 'eof' or 'cursor'
293        """
294        cursor = self.__select_text(position_from, position_to)
295        text = to_text_string(cursor.selectedText())
296        all_text = position_from == 'sof' and position_to == 'eof'
297        if text and not all_text:
298            while text.endswith("\n"):
299                text = text[:-1]
300            while text.endswith(u"\u2029"):
301                text = text[:-1]
302        return text
303
304    def get_character(self, position, offset=0):
305        """Return character at *position* with the given offset."""
306        position = self.get_position(position) + offset
307        cursor = self.textCursor()
308        cursor.movePosition(QTextCursor.End)
309        if position < cursor.position():
310            cursor.setPosition(position)
311            cursor.movePosition(QTextCursor.Right,
312                                QTextCursor.KeepAnchor)
313            return to_text_string(cursor.selectedText())
314        else:
315            return ''
316
317    def insert_text(self, text):
318        """Insert text at cursor position"""
319        if not self.isReadOnly():
320            self.textCursor().insertText(text)
321
322    def replace_text(self, position_from, position_to, text):
323        cursor = self.__select_text(position_from, position_to)
324        cursor.removeSelectedText()
325        cursor.insertText(text)
326
327    def remove_text(self, position_from, position_to):
328        cursor = self.__select_text(position_from, position_to)
329        cursor.removeSelectedText()
330
331    def get_current_word(self):
332        """Return current word, i.e. word at cursor position"""
333        cursor = self.textCursor()
334
335        if cursor.hasSelection():
336            # Removes the selection and moves the cursor to the left side
337            # of the selection: this is required to be able to properly
338            # select the whole word under cursor (otherwise, the same word is
339            # not selected when the cursor is at the right side of it):
340            cursor.setPosition(min([cursor.selectionStart(),
341                                    cursor.selectionEnd()]))
342        else:
343            # Checks if the first character to the right is a white space
344            # and if not, moves the cursor one word to the left (otherwise,
345            # if the character to the left do not match the "word regexp"
346            # (see below), the word to the left of the cursor won't be
347            # selected), but only if the first character to the left is not a
348            # white space too.
349            def is_space(move):
350                curs = self.textCursor()
351                curs.movePosition(move, QTextCursor.KeepAnchor)
352                return not to_text_string(curs.selectedText()).strip()
353            if is_space(QTextCursor.NextCharacter):
354                if is_space(QTextCursor.PreviousCharacter):
355                    return
356                cursor.movePosition(QTextCursor.WordLeft)
357
358        cursor.select(QTextCursor.WordUnderCursor)
359        text = to_text_string(cursor.selectedText())
360        # find a valid python variable name
361        match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE)
362        if match:
363            return match[0]
364
365    def get_current_line(self):
366        """Return current line's text"""
367        cursor = self.textCursor()
368        cursor.select(QTextCursor.BlockUnderCursor)
369        return to_text_string(cursor.selectedText())
370
371    def get_current_line_to_cursor(self):
372        """Return text from prompt to cursor"""
373        return self.get_text(self.current_prompt_pos, 'cursor')
374
375    def get_line_number_at(self, coordinates):
376        """Return line number at *coordinates* (QPoint)"""
377        cursor = self.cursorForPosition(coordinates)
378        return cursor.blockNumber()-1
379
380    def get_line_at(self, coordinates):
381        """Return line at *coordinates* (QPoint)"""
382        cursor = self.cursorForPosition(coordinates)
383        cursor.select(QTextCursor.BlockUnderCursor)
384        return to_text_string(cursor.selectedText()).replace(u'\u2029', '')
385
386    def get_word_at(self, coordinates):
387        """Return word at *coordinates* (QPoint)"""
388        cursor = self.cursorForPosition(coordinates)
389        cursor.select(QTextCursor.WordUnderCursor)
390        return to_text_string(cursor.selectedText())
391
392    def get_block_indentation(self, block_nb):
393        """Return line indentation (character number)"""
394        text = to_text_string(self.document().findBlockByNumber(block_nb).text())
395        text = text.replace("\t", " "*self.tab_stop_width_spaces)
396        return len(text)-len(text.lstrip())
397
398    def get_selection_bounds(self):
399        """Return selection bounds (block numbers)"""
400        cursor = self.textCursor()
401        start, end = cursor.selectionStart(), cursor.selectionEnd()
402        block_start = self.document().findBlock(start)
403        block_end = self.document().findBlock(end)
404        return sorted([block_start.blockNumber(), block_end.blockNumber()])
405
406
407    #------Text selection
408    def has_selected_text(self):
409        """Returns True if some text is selected"""
410        return bool(to_text_string(self.textCursor().selectedText()))
411
412    def get_selected_text(self):
413        """
414        Return text selected by current text cursor, converted in unicode
415
416        Replace the unicode line separator character \u2029 by
417        the line separator characters returned by get_line_separator
418        """
419        return to_text_string(self.textCursor().selectedText()).replace(u"\u2029",
420                                                     self.get_line_separator())
421
422    def remove_selected_text(self):
423        """Delete selected text"""
424        self.textCursor().removeSelectedText()
425
426    def replace(self, text, pattern=None):
427        """Replace selected text by *text*
428        If *pattern* is not None, replacing selected text using regular
429        expression text substitution"""
430        cursor = self.textCursor()
431        cursor.beginEditBlock()
432        if pattern is not None:
433            seltxt = to_text_string(cursor.selectedText())
434        cursor.removeSelectedText()
435        if pattern is not None:
436            text = re.sub(to_text_string(pattern),
437                          to_text_string(text), to_text_string(seltxt))
438        cursor.insertText(text)
439        cursor.endEditBlock()
440
441
442    #------Find/replace
443    def find_multiline_pattern(self, regexp, cursor, findflag):
444        """Reimplement QTextDocument's find method
445
446        Add support for *multiline* regular expressions"""
447        pattern = to_text_string(regexp.pattern())
448        text = to_text_string(self.toPlainText())
449        try:
450            regobj = re.compile(pattern)
451        except sre_constants.error:
452            return
453        if findflag & QTextDocument.FindBackward:
454            # Find backward
455            offset = min([cursor.selectionEnd(), cursor.selectionStart()])
456            text = text[:offset]
457            matches = [_m for _m in regobj.finditer(text, 0, offset)]
458            if matches:
459                match = matches[-1]
460            else:
461                return
462        else:
463            # Find forward
464            offset = max([cursor.selectionEnd(), cursor.selectionStart()])
465            match = regobj.search(text, offset)
466        if match:
467            pos1, pos2 = match.span()
468            fcursor = self.textCursor()
469            fcursor.setPosition(pos1)
470            fcursor.setPosition(pos2, QTextCursor.KeepAnchor)
471            return fcursor
472
473    def find_text(self, text, changed=True, forward=True, case=False,
474                  words=False, regexp=False):
475        """Find text"""
476        cursor = self.textCursor()
477        findflag = QTextDocument.FindFlag()
478        if not forward:
479            findflag = findflag | QTextDocument.FindBackward
480        if case:
481            findflag = findflag | QTextDocument.FindCaseSensitively
482        moves = [QTextCursor.NoMove]
483        if forward:
484            moves += [QTextCursor.NextWord, QTextCursor.Start]
485            if changed:
486                if to_text_string(cursor.selectedText()):
487                    new_position = min([cursor.selectionStart(),
488                                        cursor.selectionEnd()])
489                    cursor.setPosition(new_position)
490                else:
491                    cursor.movePosition(QTextCursor.PreviousWord)
492        else:
493            moves += [QTextCursor.End]
494        if not regexp:
495            text = re.escape(to_text_string(text))
496        if QT55_VERSION:
497            pattern = QRegularExpression(r"\b{}\b".format(text) if words else
498                                         text)
499            if case:
500                pattern.setPatternOptions(
501                    QRegularExpression.CaseInsensitiveOption)
502        else:
503            pattern = QRegExp(r"\b{}\b".format(text)
504                              if words else text, Qt.CaseSensitive if case else
505                              Qt.CaseInsensitive, QRegExp.RegExp2)
506
507        for move in moves:
508            cursor.movePosition(move)
509            if regexp and '\\n' in text:
510                # Multiline regular expression
511                found_cursor = self.find_multiline_pattern(pattern, cursor,
512                                                           findflag)
513            else:
514                # Single line find: using the QTextDocument's find function,
515                # probably much more efficient than ours
516                found_cursor = self.document().find(pattern, cursor, findflag)
517            if found_cursor is not None and not found_cursor.isNull():
518                self.setTextCursor(found_cursor)
519                return True
520        return False
521
522    def is_editor(self):
523        """Needs to be overloaded in the codeeditor where it will be True"""
524        return False
525
526    def get_number_matches(self, pattern, source_text='', case=False):
527        """Get the number of matches for the searched text."""
528        pattern = to_text_string(pattern)
529        if not pattern:
530            return 0
531        if not source_text:
532            source_text = to_text_string(self.toPlainText())
533        try:
534            if case:
535                regobj = re.compile(pattern)
536            else:
537                regobj = re.compile(pattern, re.IGNORECASE)
538        except sre_constants.error:
539            return
540
541        number_matches = 0
542        for match in regobj.finditer(source_text):
543            number_matches += 1
544
545        return number_matches
546
547    def get_match_number(self, pattern, case=False):
548        """Get number of the match for the searched text."""
549        position = self.textCursor().position()
550        source_text = self.get_text(position_from='sof', position_to=position)
551        match_number = self.get_number_matches(pattern,
552                                               source_text=source_text,
553                                               case=case)
554        return match_number
555
556    # --- Numpy matrix/array helper / See 'spyder/widgets/arraybuilder.py'
557    def enter_array_inline(self):
558        """ """
559        self._enter_array(True)
560
561    def enter_array_table(self):
562        """ """
563        self._enter_array(False)
564
565    def _enter_array(self, inline):
566        """ """
567        offset = self.get_position('cursor') - self.get_position('sol')
568        rect = self.cursorRect()
569        dlg = NumpyArrayDialog(self, inline, offset)
570
571        # TODO: adapt to font size
572        x = rect.left()
573        x = x + self.get_linenumberarea_width() - 14
574        y = rect.top() + (rect.bottom() - rect.top())/2
575        y = y - dlg.height()/2 - 3
576
577        pos = QPoint(x, y)
578        dlg.move(self.mapToGlobal(pos))
579
580        # called from editor
581        if self.is_editor():
582            python_like_check = self.is_python_like()
583            suffix = '\n'
584        # called from a console
585        else:
586            python_like_check = True
587            suffix = ''
588
589        if python_like_check and dlg.exec_():
590            text = dlg.text() + suffix
591            if text != '':
592                cursor = self.textCursor()
593                cursor.beginEditBlock()
594                cursor.insertText(text)
595                cursor.endEditBlock()
596
597
598class TracebackLinksMixin(object):
599    """ """
600    QT_CLASS = None
601    go_to_error = None
602
603    def __init__(self):
604        self.__cursor_changed = False
605        self.setMouseTracking(True)
606
607    #------Mouse events
608    def mouseReleaseEvent(self, event):
609        """Go to error"""
610        self.QT_CLASS.mouseReleaseEvent(self, event)
611        text = self.get_line_at(event.pos())
612        if get_error_match(text) and not self.has_selected_text():
613            if self.go_to_error is not None:
614                self.go_to_error.emit(text)
615
616    def mouseMoveEvent(self, event):
617        """Show Pointing Hand Cursor on error messages"""
618        text = self.get_line_at(event.pos())
619        if get_error_match(text):
620            if not self.__cursor_changed:
621                QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor))
622                self.__cursor_changed = True
623            event.accept()
624            return
625        if self.__cursor_changed:
626            QApplication.restoreOverrideCursor()
627            self.__cursor_changed = False
628        self.QT_CLASS.mouseMoveEvent(self, event)
629
630    def leaveEvent(self, event):
631        """If cursor has not been restored yet, do it now"""
632        if self.__cursor_changed:
633            QApplication.restoreOverrideCursor()
634            self.__cursor_changed = False
635        self.QT_CLASS.leaveEvent(self, event)
636
637
638class GetHelpMixin(object):
639    def __init__(self):
640        self.help = None
641        self.help_enabled = False
642
643    def set_help(self, help_plugin):
644        """Set Help DockWidget reference"""
645        self.help = help_plugin
646
647    def set_help_enabled(self, state):
648        self.help_enabled = state
649
650    def inspect_current_object(self):
651        text = ''
652        text1 = self.get_text('sol', 'cursor')
653        tl1 = re.findall(r'([a-zA-Z_]+[0-9a-zA-Z_\.]*)', text1)
654        if tl1 and text1.endswith(tl1[-1]):
655            text += tl1[-1]
656        text2 = self.get_text('cursor', 'eol')
657        tl2 = re.findall(r'([0-9a-zA-Z_\.]+[0-9a-zA-Z_\.]*)', text2)
658        if tl2 and text2.startswith(tl2[0]):
659            text += tl2[0]
660        if text:
661            self.show_object_info(text, force=True)
662
663    def show_object_info(self, text, call=False, force=False):
664        """Show signature calltip and/or docstring in the Help plugin"""
665        text = to_text_string(text)
666
667        # Show docstring
668        help_enabled = self.help_enabled or force
669        if force and self.help is not None:
670            self.help.dockwidget.setVisible(True)
671            self.help.dockwidget.raise_()
672        if help_enabled and (self.help is not None) and \
673           (self.help.dockwidget.isVisible()):
674            # Help widget exists and is visible
675            if hasattr(self, 'get_doc'):
676                self.help.set_shell(self)
677            else:
678                self.help.set_shell(self.parent())
679            self.help.set_object_text(text, ignore_unknown=False)
680            self.setFocus() # if help was not at top level, raising it to
681                            # top will automatically give it focus because of
682                            # the visibility_changed signal, so we must give
683                            # focus back to shell
684
685        # Show calltip
686        if call and self.calltips:
687            # Display argument list if this is a function call
688            iscallable = self.iscallable(text)
689            if iscallable is not None:
690                if iscallable:
691                    arglist = self.get_arglist(text)
692                    name =  text.split('.')[-1]
693                    argspec = signature = ''
694                    if isinstance(arglist, bool):
695                        arglist = []
696                    if arglist:
697                        argspec = '(' + ''.join(arglist) + ')'
698                    else:
699                        doc = self.get__doc__(text)
700                        if doc is not None:
701                            # This covers cases like np.abs, whose docstring is
702                            # the same as np.absolute and because of that a
703                            # proper signature can't be obtained correctly
704                            argspec = getargspecfromtext(doc)
705                            if not argspec:
706                                signature = getsignaturefromtext(doc, name)
707                    if argspec or signature:
708                        if argspec:
709                            tiptext = name + argspec
710                        else:
711                            tiptext = signature
712                        self.show_calltip(_("Arguments"), tiptext,
713                                          signature=True, color='#2D62FF')
714
715    def get_last_obj(self, last=False):
716        """
717        Return the last valid object on the current line
718        """
719        return getobj(self.get_current_line_to_cursor(), last=last)
720
721
722class SaveHistoryMixin(object):
723
724    INITHISTORY = None
725    SEPARATOR = None
726    HISTORY_FILENAMES = []
727
728    append_to_history = None
729
730    def __init__(self, history_filename=''):
731        self.history_filename = history_filename
732        self.create_history_filename()
733
734    def create_history_filename(self):
735        """Create history_filename with INITHISTORY if it doesn't exist."""
736        if self.history_filename and not osp.isfile(self.history_filename):
737            encoding.writelines(self.INITHISTORY, self.history_filename)
738
739    def add_to_history(self, command):
740        """Add command to history"""
741        command = to_text_string(command)
742        if command in ['', '\n'] or command.startswith('Traceback'):
743            return
744        if command.endswith('\n'):
745            command = command[:-1]
746        self.histidx = None
747        if len(self.history) > 0 and self.history[-1] == command:
748            return
749        self.history.append(command)
750        text = os.linesep + command
751
752        # When the first entry will be written in history file,
753        # the separator will be append first:
754        if self.history_filename not in self.HISTORY_FILENAMES:
755            self.HISTORY_FILENAMES.append(self.history_filename)
756            text = self.SEPARATOR + text
757        # Needed to prevent errors when writing history to disk
758        # See issue 6431
759        try:
760            encoding.write(text, self.history_filename, mode='ab')
761        except (IOError, OSError):
762            pass
763        if self.append_to_history is not None:
764            self.append_to_history.emit(self.history_filename, text)
765
766
767class BrowseHistoryMixin(object):
768
769    def __init__(self):
770        self.history = []
771        self.histidx = None
772        self.hist_wholeline = False
773
774    def clear_line(self):
775        """Clear current line (without clearing console prompt)"""
776        self.remove_text(self.current_prompt_pos, 'eof')
777
778    def browse_history(self, backward):
779        """Browse history"""
780        if self.is_cursor_before('eol') and self.hist_wholeline:
781            self.hist_wholeline = False
782        tocursor = self.get_current_line_to_cursor()
783        text, self.histidx = self.find_in_history(tocursor, self.histidx,
784                                                  backward)
785        if text is not None:
786            if self.hist_wholeline:
787                self.clear_line()
788                self.insert_text(text)
789            else:
790                cursor_position = self.get_position('cursor')
791                # Removing text from cursor to the end of the line
792                self.remove_text('cursor', 'eol')
793                # Inserting history text
794                self.insert_text(text)
795                self.set_cursor_position(cursor_position)
796
797    def find_in_history(self, tocursor, start_idx, backward):
798        """Find text 'tocursor' in history, from index 'start_idx'"""
799        if start_idx is None:
800            start_idx = len(self.history)
801        # Finding text in history
802        step = -1 if backward else 1
803        idx = start_idx
804        if len(tocursor) == 0 or self.hist_wholeline:
805            idx += step
806            if idx >= len(self.history) or len(self.history) == 0:
807                return "", len(self.history)
808            elif idx < 0:
809                idx = 0
810            self.hist_wholeline = True
811            return self.history[idx], idx
812        else:
813            for index in range(len(self.history)):
814                idx = (start_idx+step*(index+1)) % len(self.history)
815                entry = self.history[idx]
816                if entry.startswith(tocursor):
817                    return entry[len(tocursor):], idx
818            else:
819                return None, start_idx
820
821    def reset_search_pos(self):
822        """Reset the position from which to search the history"""
823        self.histidx = None
824