Source code for gui.pyconsole

#
##
##  SPDX-FileCopyrightText: © 2007-2023 Benedict Verhegghe <bverheg@gmail.com>
##  SPDX-License-Identifier: GPL-3.0-or-later
##
##  This file is part of pyFormex 3.4  (Thu Nov 16 18:07:39 CET 2023)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: https://pyformex.org
##  Project page: https://savannah.nongnu.org/projects/pyformex/
##  Development: https://gitlab.com/bverheg/pyformex
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##
"""Interactive Python console in a Qt widget.

This module provides the PyConsole class: an interactive Python interpreter
embedded in a Qt widget.
PyConsole was created for the pyFormex GUI, but can be used independently
in any Python Qt application. It can even be run as a standalone application.
PyConsole works with either PyQt5 or PySide2.

Features:

- accepts multiline statements
- accepts continuation lines (ending in backslash)
- keeps a command history with recall and save
- colored output
- indentation adjustement on multiple of 4 columns
- built-in help function
- built-in completer functionality
- can be executed as a standalone program (see below)
- easy to add more functionality

Special Keys:

- RETURN: move input to output and execute if statement complete
- LEFT: go left
- RIGHT: go right
- CTRL-LEFT: go to beginning of line
- CTRL-END: go to end of line
- UP: go up in history
- DOWN: go down in history
- CTRL-UP: go to first line in history
- CTRl-DOWN: go to last line in history
- TAB: if cursor is at start or after a space: increase indent to next
  multiple of 4; else: start completion of the text before the cursor
- BACKTAB (SHIFT-TAB): if cursor is after a space: reduce indent to previous
  multiple of 4

Completion:

If TAB is pressed when the cursor is after a non-space, the built-in completer
is started. It first looks up the possible completions for the text before
the cursor. If None is found, it does nothing. If just one is found, it is
immediately filled in. Another TAB press will remove it again. If multiple
completions were found, it prints the list of possible completions on the
output. Hitting TAB again will fill in the first completion, and repeatedly
pressing TAB cycles through the whole list of completions until the last
possiblility is removed again. Pressing any other key but TAB during the
completion process will accept the currently filled in completion. Note that
any text following the cursor also keeps in place following the completion.

To run the console as a standalone program, use the following command::

    python3 pyconsole.py

You can specify the window geometry in X11 style::

    python pyconsole.py --qwindowgeometry 800x600-0+0

This command will create a window of 800 by 600 pixels in the upper right corner
of the screen.
"""
import sys
import code
import rlcompleter

if 'pyformex' in sys.modules:
    from pyformex.gui import QtCore, QtGui, QtWidgets, Signal#, Slot
else:
    try:
        from PySide2 import QtCore, QtGui, QtWidgets
        from PySide2.QtCore import Signal, Slot
    except ModuleNotFoundError:
        try:
            from PyQt5 import QtCore, QtGui, QtWidgets
            from PyQt5.QtCore import pyqtSignal as Signal
            # from PyQt5.QtCore import pyqtSlot as Slot
        except ModuleNotFoundError:
            print("You need PySide2 or PyQt5 to run this")



[docs]class LineEdit(QtWidgets.QLineEdit): """Input line with a history buffer for recalling previous lines. Parameters ---------- historysize: int Maximum number of lines kept in the history. If <= 0, the history size is unlimited. """ newline = Signal(str) # Signal when return key pressed def __init__(self, historysize=0): super().__init__() self.historymax = historysize self.clearhistory() self.pending = '' # pending text not in the history yet self.completer = None self.completions = [] self.completertext = '' self.completerstate = 0
[docs] def clearhistory(self): """Clear history buffer""" self.history = [] self.historyindex = 0 # index of next line to append
# We need to override event() because we want to catch TAB # otherwise, we could just use keyPressEvent
[docs] def event(self, ev): """Event handler: handles special keys.""" Qt = QtCore.Qt if ev.type() == QtCore.QEvent.KeyPress: # First handle TAB if ev.key() == Qt.Key_Tab: pos = self.cursorPosition() text = self.text()[:pos] if text and (text[-1:] != ' ' or self.completions): # Try completion if not self.completions: completions = self.getcompletions(text) if not completions: return True self.completions = completions self.completerstate = 0 self.completerpos = pos if len(completions) > 1: print(f"Completions: {completions}") return True if self.hasSelectedText(): self.backspace() if self.completerstate < len(self.completions): compl = self.completions[self.completerstate] compl = compl[len(self.completertext):] self.setCursorPosition(self.completerpos) self.insert(compl) self.setSelection(self.completerpos, len(compl)) self.completerstate += 1 else: self.setCursorPosition(self.completerpos) self.deselect() self.completerstate = 0 return True else: pass # to use tab as indent self.completions = [] self.deselect() # First handle history navigation keys # and keys that do not end it if ev.key() == Qt.Key_Up: if self.historyindex == len(self.history): self.pending = self.text() if ev.modifiers() == Qt.NoModifier: self.recall(self.historyindex - 1) elif ev.modifiers() == Qt.ControlModifier: self.recall(0) return True elif ev.key() == Qt.Key_Down: if ev.modifiers() == Qt.NoModifier: if self.historyindex != len(self.history): self.recall(self.historyindex + 1) elif ev.modifiers() == Qt.ControlModifier: self.recall(len(self.history) - 1) return True elif ev.key() == Qt.Key_Tab: self.tabindent(True) return True elif ev.key() == Qt.Key_Backtab: self.tabindent(False) return True elif ev.key() == ord('?') and ev.modifiers() & Qt.ControlModifier: self.printhistory() return True # Any other key turns off history navigation if ev.key() == Qt.Key_Return: self.returnkey() return True return super().event(ev) # pass on
[docs] def tabindent(self, forward=True): """Add/remove blanks to next/previous multiple of 4 position""" text = self.text() pos = len(text) - len(text.lstrip()) self.setCursorPosition(pos) if forward: add = 4 - (pos % 4) self.insert(' ' * add) else: if pos > 0: delete = (pos+3) % 4 + 1 self.setSelection(pos-delete, delete) self.backspace()
[docs] def getcompletions(self, text): """Return possible completions of text in context""" i = text.rfind(' ') self.completertext = text[i+1:] completions = [] state = 0 while True: comp = self.completer.complete(self.completertext, state) if comp is None: break state += 1 completions.append(comp) return completions
[docs] def returnkey(self): """Handle return key. Record line and emit newline signal""" text = self.text()#.rstrip() self.record(text) self.newline.emit(text) self.pending = '' self.setText('')
[docs] def recall(self, index): """Select a line from the history list""" if index < 0: # print("== Top of history ==") pass elif index < len(self.history): self.setText(self.history[index]) self.historyindex = index else: self.setText(self.pending) self.historyindex = len(self.history)
[docs] def record(self, line): """Add line to history buffer""" if self.historymax > 0: while len(self.history) >= self.historymax: self.history.pop(0) if len(self.history) == 0 or line != self.history[-1]: self.history.append(line) self.historyindex = len(self.history)
[docs] def printhistory(self): """Print the history to stdout""" print("History:") for i, line in enumerate(self.history): c = '*' if i == self.historyindex else ' ' print(f"{i:4}:{c} {line}") print(f"Pending: {self.pending}")
[docs] def savehistory(self, filename, maxlines=0): """Save the history to file""" with open(filename, 'w') as fil: for line in self.history[-maxlines:]: fil.write(line+'\n')
[docs] def loadhistory(self, filename): """Load a history file""" with open(filename, 'r') as fil: hist = [s.strip('\n').strip() for s in fil.readlines()] hist = [s for s in hist if s] self.history = hist if self.historymax > 0: self.history = self.history[-self.historymax:] self.recall(len(self.history))
# TODO: add a method setcolor
[docs]def help(obj=None): """Print help anbout any object""" if obj is None: print(__doc__) print("help(object) will print the __doc__ of any object)") else: print(obj.__doc__)
###########################################################################
[docs]class PyConsole(QtWidgets.QWidget): """An interactive Python console in a Qt widget. PyConsole is a Qt5 widget exposing an interactive Python console. It combines a read-only output area and a one line input area. When pressing ENTER, it copies the input line to the output area and executes the statement if it is complete. Parameters ---------- parent: QWidget If provided, the PyConsole will be made a child of that widget. context: :class:`InteractiveInterpreter` | dict The context where the source will be executed. If an interpreter, it is used as is. If a dict, a new interpreter will be created with the specified dict as its locals. historysize: int Maximum number of lines in the history buffer. If 0, the history buffer is unlimited. blockcount: int Maximum number of lines in the output buffer. If 0, the output buffer is unlimited. fontname: str Name of the font to be used. It better be a monospace font! fontsize: int The font size """ encoding = 'utf-8' # text colors COLOR_DEFAULT = None COLOR_INPUT = 'blue' # command input COLOR_OUTPUT = 'black' # result output COLOR_ERROR = 'red' # error messages # input line prompts PROMPT_1 = '>>> ' # single line prompt PROMPT_2 = '... ' # continuation line prompt def __init__(self, parent=None, context=dict(), historysize=0, blockcount=0, font="DejaVu Sans Mono", fontsize=10): super().__init__(parent=parent) self.buffer = [] self.errorproxy = ConsoleErrorProxy(self) self.content = QtWidgets.QGridLayout(self) self.content.setContentsMargins(0, 0, 0, 0) self.content.setSpacing(0) # Display for stdout and stderr self.outdisplay = QtWidgets.QPlainTextEdit(self) self.outdisplay.setMaximumBlockCount(blockcount) self.outdisplay.setReadOnly(True) self.content.addWidget(self.outdisplay, 0, 0, 1, 2) # Display input prompt left of input edit self.promptdisp = QtWidgets.QLineEdit(self) self.promptdisp.setReadOnly(True) self.promptdisp.setFixedWidth(50) self.promptdisp.setFrame(False) self.content.addWidget(self.promptdisp, 1, 0) # Enter commands here self.editline = LineEdit(historysize=historysize) self.editline.newline.connect(self.push) self.editline.setFrame(False) self.content.addWidget(self.editline, 1, 1) self.lastline = '' # buffer for last line self.setfont(QtGui.QFont(font, fontsize)) self.setprompt(PyConsole.PROMPT_1) # Set context self.setcontext(context)
[docs] def setFocus(self): self.editline.setFocus()
[docs] def setcontext(self, context): """Set context for interpreter""" if isinstance(context, code.InteractiveInterpreter): self.interpreter = context else: self.interpreter = code.InteractiveInterpreter(context) self.interpreter.locals['help'] = help self.editline.completer = rlcompleter.Completer(self.interpreter.locals)
[docs] def resetbuffer(self): """Reset the input buffer.""" self.buffer = []
def setprompt(self, text: str): self.prompt = text self.promptdisp.setText(text) def clear(self): self.outdisplay.clear() def clearall(self): self.editline.clearhistory() self.outdisplay.clear() def printhistory(self): self.editline.printhistory()
[docs] def savehistory(self, filename): """Save history to a file""" self.editline.savehistory(filename)
[docs] def saveoutput(self, filename, contents='all'): """Save the console output to a file Parameters ---------- filename: str File name for the saved output. Existing file will be overwritten. contents: str One of 'all', 'commands' or 'output'. The first character suffices. Determines what to write: all output, only commands, or only the output resulting from the commands. See also -------- savehistory: save the command history to a file. """ txt = self.outdisplay.toPlainText() contents = contents[:1] if contents == 'c': self.editline.savehistory(filename) return if contents == 'o': txt = '\n'.join([ line for line in txt.split('\n') if not ( line.startswith(PyConsole.PROMPT_1) or line.startswith(PyConsole.PROMPT_2)) ]) with open(filename, 'w') as fil: fil.write(txt) fil.write('\n')
[docs] def push(self, line: str): """Execute entered command. Command may span multiple lines""" lines = line.split('\n') if self.lastline: self.writecolor('\n', PyConsole.COLOR_INPUT) for line in lines: self.writecolor(self.prompt + line + '\n', PyConsole.COLOR_INPUT) self.setprompt(PyConsole.PROMPT_2) self.buffer.append(line) # Built a command string from lines in the buffer source = '\n'.join(self.buffer) more = self.interpreter.runsource(source, '<PyConsole>') if not more: self.setprompt(PyConsole.PROMPT_1) self.resetbuffer()
[docs] def setfont(self, font: QtGui.QFont): """Set font for input and display widgets. Should be monospaced""" self.outdisplay.setFont(font) self.promptdisp.setFont(font) self.editline.setFont(font)
[docs] def write(self, line, color=None): """Capture stdout and display in outdisplay""" if color is None: color = PyConsole.COLOR_OUTPUT self.writecolor(line, color)
[docs] def writeerror(self, line: str): """Capture stderr and display in outdisplay""" self.writecolor(line, PyConsole.COLOR_ERROR)
# This should be the only method to write to the outdisplay
[docs] def writecolor(self, text, color=None): """Color can be anything accepted by QColor""" if color is not None: fmt = self.outdisplay.currentCharFormat() if isinstance(color, (tuple, list)): color = QtGui.QColor.fromRgbF(*color) else: color = QtGui.QColor(color) fmt.setForeground(QtGui.QBrush(color)) self.outdisplay.setCurrentCharFormat(fmt) lastnewline = text.rfind('\n') self.lastline = text[lastnewline+1:] self.outdisplay.insertPlainText(text) self.movetoend()
[docs] def movetoend(self): """Move the output cursor to the end.""" cursor = self.outdisplay.textCursor() cursor.movePosition(QtGui.QTextCursor.End) self.outdisplay.setTextCursor(cursor) self.outdisplay.ensureCursorVisible()
[docs] def keyPressEvent(self, ev): if ev.key() == 17 and ev.modifiers() & Qt.ControlModifier: # CTRL-Q sys.exit()
class ConsoleErrorProxy: def __init__(self, console): self.console = console def write(self, s): self.console.writeerror(s) if __name__ == '__main__': import contextlib app = QtWidgets.QApplication(sys.argv) window = QtWidgets.QMainWindow() if '--qwindowgeometry' not in sys.argv[1:]: window.move(600,0) window.resize(800, 600) window.setWindowTitle('Python Console') _central = QtWidgets.QWidget(window) _layout = QtWidgets.QGridLayout(_central) console = PyConsole(context=locals()) _layout.addWidget(console, 0, 0, 1, 1) window.setCentralWidget(_central) window.show() del _layout del _central console.setFocus() console.writecolor("** Welcome to the Python Console **\n" "** (c) 2022 Benedict Verhegghe ** GPLv3 **", 'red') with (contextlib.redirect_stdout(console), contextlib.redirect_stderr(console.errorproxy)): sys.exit(app.exec_()) # End