#
##
## 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