pesterchum/console.py

516 lines
19 KiB
Python
Raw Normal View History

# vim: set autoindent ts=4 sts=4 sw=4 textwidth=79 expandtab:
# -*- coding=UTF-8; tab-width: 4 -*-
from PyQt5 import QtCore, QtGui, QtWidgets
import re, os, traceback, sys
import time, datetime
from os import remove
import dataobjs, generic, memos, parsetools, ostools
from version import _pcVersion
from pnc.dep.attrdict import AttrDict
#~from styling import styler
_datadir = ostools.getDataDir()
import logging
logging.basicConfig(level=logging.WARNING)
class ConsoleWindow(QtWidgets.QDialog):
#~class ConsoleWindow(styler.PesterBaseWindow):
# A simple console class, cobbled together from the corpse of another.
stylesheet_path = "main/defaultwindow/style"
# This is a holder for our text inputs.
text = AttrDict()
# I should probably put up constants for 'direction' if this is going to
# get this complicated. TODO!
incoming_prefix = "<<<"
miscinfo_prefix = "==>"
outgoing_prefix = ">>>"
neutral_prefix = "!!!"
waiting_prefix = "..."
selected_widget = None
show_info_on_select = True
_CUSTOM_ENV = {}
def __init__(self, parent):
super(ConsoleWindow, self).__init__(parent)
self.prnt = parent
try:
self.mainwindow = parent.mainwindow
except:
self.mainwindow = parent
theme = self.mainwindow.theme
# This won't initialize the sub-objects, because they don't exist yet.
self.initTheme(theme)
self.text = AttrDict()
self.text.area = ConsoleText(theme, self)
self.text.input = ConsoleInput(theme, self)
self.text.input.setFocus()
self.text.input.returnPressed.connect(self.sentMessage)
self.text.history = dataobjs.PesterHistory()
# For backing these up
self.stdout = self.stderr = None
layout_0 = QtWidgets.QVBoxLayout()
layout_0.addWidget(self.text.area)
layout_0.addWidget(self.text.input)
self.setLayout(layout_0)
def parent(self):
return self.prnt
def clearNewMessage(self):
pass
@QtCore.pyqtSlot()
def sentMessage(self):
text = self.text.input.text()
# TODO: Make this deal with unicode text, it'll crash and burn as-is.
text = str(text)
text = text.rstrip()
self.text.history.add(text)
self.text.input.setText("")
self.execInConsole(text)
# Scroll down to the bottom so we can see the results.
sb = self.text.area.verticalScrollBar()
sb.setValue(sb.maximum())
def addTraceback(self, tb=None):
# We should do the formatting here, but eventually pass it to text.area
# to addMessage whatever output we produced.
# If we're called by addMessage - and we should be - then sys.stdout is
# still being redirected into the console.
# TODO: Just make an object for setting contexts (and thus optionally
# redirecting prints). Use 'with', of course.
# TODO: Make this exclude *our* processing from the traceback stack.
try:
self.addMessage(traceback.format_exc(), direction=0)
except Exception as err:
PchumLog.error("Failed to display error message (???): %s" % err)
def addMessage(self, msg, direction):
# Redirect to where these things belong.
self.text.area.addMessage(msg, direction=direction)
def closeEvent(self, event):
# TODO: Set up ESC to close the console...or refer to hiding it as
# closing it. Not sure which is preferable.
parent = self.parent()
parent.console.is_open = False
parent.console.window = None
return super(ConsoleWindow, self).closeEvent(event)
def hideEvent(self, event):
parent = self.parent()
parent.console.is_open = False
def initTheme(self, theme):
# Set up our style/window specifics
self.changeTheme(theme)
self.resize(400,600)
def changeTheme(self, theme):
self.setStyleSheet(theme[self.stylesheet_path])
self.setWindowTitle("==> Console")
if "area" in self.text and "input" in self.text:
self.text.area.changeTheme(theme)
self.text.input.changeTheme(theme)
@QtCore.pyqtSlot()
def designateCurrentWidget(self):
# Display and save the current widget!
# TODO: Consider (reversible) highlighting or selection or something
# fancy. It'd help people write styles, wouldn't it?
# ...just remember to use mouseRelease() if you work with hovering.
# Direction: Misc. Info
direction = 2
pos = QtGui.QCursor.pos()
wgt = QtWidgets.QApplication.widgetAt(pos)
if wgt is None:
# Don't set None, for now. May change this later.
self.addMessage("You need to have your cursor over something " + \
"in Pesterchum to use that.",
direction=direction)
return
self.selected_widget = wgt
nchild = len(wgt.children())
output = []
output.append("CONSOLE.selected_widget = {0!r}".format(wgt))
output.append("{0: <4}Parent: {1!r}".format('', wgt.parent()))
output.append("{0: <4}{1:4d} child{2}".format('',
nchild, ("ren" if abs(nchild) != 1 else "") ))
if self.show_info_on_select:
qtss = None
uses_ss = None
try:
qtss = wgt.styleSheet()
except:
pass
else:
if str(qtss) == str(""):
uses_ss, ss_msg = False, "No"
elif qtss is not None:
uses_ss, ss_msg = True, "Yes"
else:
uses_ss, ss_msg = None, "Invalid"
ss_par, ss_par_msg = None, ""
if uses_ss is False:
# TODO: Split this into a sub-function or integrate it into
# Styler or *something*.
# The stylesheet was probably defined on a parent higher up.
# Rungs above the start
i = 0
# qtss is still "" from earlier
while not qtss:
try:
ss_par = wgt.parent()
qtss = ss_par.styleSheet()
except:
# Can't ascend...and we're still in loop, so we don't
# have what we came for.
# Either that, or it's incompatible, which means the
# ones above are anyway.
ss_par = False
break
else:
# Indicate that we got this from a parent
i += 1
if not qtss:
# There are no stylesheets here.
if ss_par is False:
# We had parent issues.
# TODO: Specifically indicate invalid parent.
uses_ss, ss_msg = None, "Invalid"
else:
uses_ss, ss_msg = False, "No"
else:
# We got a stylesheet out of this!
uses_ss, ss_msg = True, "Yes"
#~ss_par_msg = "{0: <4}...on parent ↑{1:d}: {2!r}".format('',
ss_par_msg = "{0: <4}...on parent #{1:d}: {2!r}".format('',
i, ss_par)
msg = []
msg.append("{0: <4}QtSS?: {1}".format('', ss_msg))
# A stylesheet analyzer would be wonderful here. Perhaps something
# that tells us how many parent classes define stylesheets?
if uses_ss:
if ss_par_msg:
# We got this stylesheet from a parent object somewhere.
msg.append(ss_par_msg)
msg.append("{0: <4}".format("Stylesheet:"))
for ln in qtss.split('\n'):
msg.append("{0: <8}".format(ln))
# Actually add this stuff to the result we're constructing
output.extend(msg)
output = '\n'.join(output)
self.addMessage(output, direction=direction)
# Actual console stuff.
def execInConsole(self, scriptstr, env=None):
# Since that's what imports *us*, this should be okay
# Tab completion could be set up in ConsoleInput, and would be nice
import pesterchum as pchum
if env is None:
env = pchum._retrieveGlobals()
# Modify the environment the script will execute in.
# Fetch from the class/instance first.
_CUSTOM_ENV = self._CUSTOM_ENV.copy()
# Modify with some hard-coded environmental additions.
_CUSTOM_ENV.update({
"CONSOLE": self,
"MAINWIN": self.mainwindow,
"PCONFIG": self.mainwindow.config,
"exit": lambda: self.mainwindow.exitaction.trigger()
})
# Aliases.
_CUSTOM_ENV.update({
"quit": _CUSTOM_ENV["exit"]
})
# Add whatever additions were set in the main pesterchum file.
_CUSTOM_ENV.update(pchum._CONSOLE_ENV)
_CUSTOM_ENV_USED = []
cenv = pchum.__dict__
# Display the input we provided
# We do this here, *before* we do our variable injection, so that it
# doesn't have to be part of the try statement, where it could
# potentially complicate matters/give false positives.
self.addMessage(scriptstr, 1)
for k in _CUSTOM_ENV:
if k not in cenv:
# Inject the variable for ease of use.
cenv[k] = _CUSTOM_ENV[k]
# Record that we injected it.
_CUSTOM_ENV_USED.append(k)
else:
# Don't overwrite anything!
warn = "Console environment item {0!r} already exists in CENV."
warn.format(k)
PchumLog.warning(warn)
# Because all we did was change a linked AttrDict, we should be fine
# here.
try:
# Replace the old writer (for now)
sysout, sys.stdout = sys.stdout, self
try:
code = compile(scriptstr + '\n', "<string>", "single")
# Will using cenv instead of env cause problems?...
result = eval(code, cenv)
except:
# Something went wrong.
self.addTraceback(sys.exc_info()[2])
else:
# No errors.
if result is not None:
PchumLog.info(repr(result))
finally:
# Restore system output.
sys.stdout = sysout
finally:
# Try to clean us out of globals - this might be disabled
# later.
for k in _CUSTOM_ENV_USED:
# Remove the key we added.
cenv.pop(k, None)
def write(self, data):
# Replaces sys.stdout briefly
# We only ever use this for receiving, so it's safe to assume the
# direction is always -1.
if not isinstance(data, list):
data = data.split('\n')
for line in data:
if len(line):
self.addMessage(line, -1)
class ConsoleText(QtWidgets.QTextEdit):
stylesheet_template = """
QScrollBar:vertical {{ {style[convo/scrollbar/style]} }}
QScrollBar::handle:vertical {{ {style[convo/scrollbar/handle]} }}
QScrollBar::add-line:vertical {{ {style[convo/scrollbar/downarrow]} }}
QScrollBar::sub-line:vertical {{ {style[convo/scrollbar/uparrow]} }}
QScrollBar:up-arrow:vertical {{ {style[convo/scrollbar/uarrowstyle]} }}
QScrollBar:down-arrow:vertical {{ {style[convo/scrollbar/darrowstyle]} }}
"""
stylesheet_path = "convo/textarea/style"
# NOTE: Qt applies stylesheets like switching CSS files. They are NOT
# applied piecemeal.
# TODO: Consider parsing the themes out into stylesheets with pieces that
# we can hand to each widget.
def __init__(self, theme, parent=None):
super(ConsoleText, self).__init__(parent)
if hasattr(self.window(), 'mainwindow'):
self.mainwindow = self.window().mainwindow
else:
self.mainwindow = self.window()
self.hasTabs = False
self.initTheme(theme)
self.setReadOnly(True)
self.setMouseTracking(True)
self.textSelected = False
self.copyAvailable[bool].connect(self.textReady)
self.urls = {}
# Stripped out animation init - it's all cruft to us.
@QtCore.pyqtSlot(bool)
def textReady(self, ready):
self.textSelected = ready
def initTheme(self, theme):
# The basic style...
stylesheet = "QTextEdit {{ {style[convo/textarea/style]} }}"
if "convo/scrollbar" in theme:
# TODO: Make all of this into a Styler mixin, so we can just feed
# it a theme whenever we want to change.
# We'd have to define the keys we're affecting, but that shouldn't
# be too hard - it's what dicts are for.
# Add the rest.
stylesheet += '\n' + self.stylesheet_template
stylesheet = stylesheet.format(style=theme)
self.setStyleSheet(stylesheet)
def addMessage(self, msg, direction):
# Display a message we've received.
# Direction > 0 == out (sent by us); < 0 == in (sent by script).
if len(msg) == 0:
return
#~color = chum.colorcmd()
#~initials = chum.initials()
parent = self.window()
mwindow = parent.mainwindow
systemColor = QtGui.QColor(mwindow.theme["convo/systemMsgColor"])
if mwindow.config.showTimeStamps():
if mwindow.config.time12Format():
timestamp = time.strftime("[%I:%M")
else:
timestamp = time.strftime("[%H:%M")
if mwindow.config.showSeconds():
timestamp += time.strftime(":%S] ")
else:
timestamp += "] "
else:
timestamp = ""
# Figure out what prefix to use.
if direction > 1:
# Misc. Info
prefix = parent.miscinfo_prefix
elif direction > 0:
# Outgoing.
prefix = parent.outgoing_prefix
elif direction < 0:
# Incoming.
prefix = parent.incoming_prefix
elif direction == 0:
# We could just 'else' here, but there might be some oddness later.
prefix = parent.neutral_prefix
# Later, this will have to escape things so we don't parse them,
# likely...hm.
#~result = "<span style=\"color:#000000\">{} {} {!r}</span>"
# The input we get is already repr'd...we pass it via print, and thus
# do that there.
result = "{}{} {}\n"
result = result.format(timestamp, prefix, msg)
self.appendPlainText(result)
# Direction doesn't matter here - it's the console.
self.lastmsg = datetime.datetime.now()
# This needs to finish being rewritten....
def appendPlainText(self, text):
"""Add plain text to the end of the document, a la insertPlainText."""
# Save the old cursor
oldcur = self.textCursor()
# Move the cursor to the end of the document for insertion
self.moveCursor(QtGui.QTextCursor.End)
# Insert the text
self.insertPlainText(text)
# Return the cursor to wherever it was prior
self.setTextCursor(oldcur)
def changeTheme(self, theme):
self.initTheme(theme)
sb = self.verticalScrollBar()
sb.setValue(sb.maximum())
def focusInEvent(self, event):
self.window().clearNewMessage()
super(ConsoleText, self).focusInEvent(event)
def keyPressEvent(self, event):
# NOTE: This doesn't give focus to the input bar, which it probably
# should.
# karxi: Test for tab changing?
if self.window().text.input:
if event.key() not in (QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown,
QtCore.Qt.Key_Up, QtCore.Qt.Key_Down):
self.window().text.input.keyPressEvent(event)
super(ConsoleText, self).keyPressEvent(event)
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
url = self.anchorAt(event.pos())
if url != "":
# Skip memo/handle recognition
# NOTE: Ctrl+Click copies the URL. Maybe it should select it?
if event.modifiers() == QtCore.Qt.ControlModifier:
QtWidgets.QApplication.clipboard().setText(url)
else:
# This'll probably be removed. May change the lexer out.
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url, QtCore.QUrl.TolerantMode))
super(ConsoleText, self).mousePressEvent(event)
def mouseMoveEvent(self, event):
# Change our cursor when we roll over links (anchors).
super(ConsoleText, self).mouseMoveEvent(event)
if self.anchorAt(event.pos()):
if self.viewport().cursor().shape != QtCore.Qt.PointingHandCursor:
self.viewport().setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
else:
self.viewport().setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
def contextMenuEvent(self, event):
textMenu = self.createStandardContextMenu()
textMenu.exec_(event.globalPos())
class ConsoleInput(QtWidgets.QLineEdit):
"""The actual text entry box on a ConsoleWindow."""
# I honestly feel like this could just be made a private class of
# ConsoleWindow, but...best not to overcomplicate things.
stylesheet_path = "convo/input/style"
def __init__(self, theme, parent=None):
super(ConsoleInput, self).__init__(parent)
self.changeTheme(theme)
def changeTheme(self, theme):
self.setStyleSheet(theme[self.stylesheet_path])
def focusInEvent(self, event):
# We gained focus. Notify the parent window that this happened.
self.window().clearNewMessage()
self.window().text.area.textCursor().clearSelection()
super(ConsoleInput, self).focusInEvent(event)
def keyPressEvent(self, event):
evtkey = event.key()
parent = self.window()
# If a key is pressed here, we're not idle....
# NOTE: Do we really want everyone knowing we're around if we're
# messing around in the console? Hm.
parent.mainwindow.idler.time = 0
if evtkey == QtCore.Qt.Key_Up:
text = str(self.text())
next = parent.text.history.next(text)
if next is not None:
self.setText(next)
elif evtkey == QtCore.Qt.Key_Down:
prev = parent.text.history.prev()
if prev is not None:
self.setText(prev)
elif evtkey in (QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown):
parent.text.area.keyPressEvent(event)
else:
super(ConsoleInput, self).keyPressEvent(event)