From 8cb509d6f367231925c77c16870afe065347245a Mon Sep 17 00:00:00 2001 From: Juan Pablo Caram Date: Sat, 13 Sep 2014 17:29:07 -0400 Subject: [PATCH] Initial implementation of console. --- FlatCAM.py | 10 +- FlatCAMApp.py | 239 +++++++++++++++++++++++++++++++++++++++++++++++- FlatCAMGUI.py | 4 +- FlatCAMObj.py | 20 +++- FlatCAMShell.py | 27 ++++++ recent.json | 2 +- termwidget.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 FlatCAMShell.py create mode 100644 termwidget.py diff --git a/FlatCAM.py b/FlatCAM.py index 2a9fc9d2..1c1b1f7f 100644 --- a/FlatCAM.py +++ b/FlatCAM.py @@ -3,11 +3,11 @@ from PyQt4 import QtGui from FlatCAMApp import App def debug_trace(): - '''Set a tracepoint in the Python debugger that works with Qt''' - from PyQt4.QtCore import pyqtRemoveInputHook - #from pdb import set_trace - pyqtRemoveInputHook() - #set_trace() + '''Set a tracepoint in the Python debugger that works with Qt''' + from PyQt4.QtCore import pyqtRemoveInputHook + #from pdb import set_trace + pyqtRemoveInputHook() + #set_trace() debug_trace() app = QtGui.QApplication(sys.argv) diff --git a/FlatCAMApp.py b/FlatCAMApp.py index 464c532f..90dc6d3e 100644 --- a/FlatCAMApp.py +++ b/FlatCAMApp.py @@ -8,6 +8,7 @@ import simplejson as json import re import webbrowser import os +import Tkinter from PyQt4 import QtCore @@ -22,6 +23,8 @@ from FlatCAMGUI import * from FlatCAMCommon import LoudDict from FlatCAMTool import * +from FlatCAMShell import FCShell + ######################################## ## App ## @@ -251,7 +254,7 @@ class App(QtCore.QObject): #### Check for updates #### # Separate thread (Not worker) - self.version = 6 + self.version = 7 App.log.info("Checking for updates in backgroud (this is version %s)." % str(self.version)) self.worker2 = Worker(self, name="worker2") @@ -293,6 +296,7 @@ class App(QtCore.QObject): self.ui.menuviewdisableall.triggered.connect(self.disable_plots) self.ui.menuviewdisableother.triggered.connect(lambda: self.disable_plots(except_current=True)) self.ui.menuviewenable.triggered.connect(self.enable_all_plots) + self.ui.menutoolshell.triggered.connect(lambda: self.shell.show()) self.ui.menuhelp_about.triggered.connect(self.on_about) self.ui.menuhelp_manual.triggered.connect(lambda: webbrowser.open(self.app_url)) # Toolbar @@ -302,6 +306,7 @@ class App(QtCore.QObject): self.ui.clear_plot_btn.triggered.connect(self.plotcanvas.clear) self.ui.replot_btn.triggered.connect(self.on_toolbar_replot) self.ui.delete_btn.triggered.connect(self.on_delete) + self.ui.shell_btn.triggered.connect(lambda: self.shell.show()) # Object list self.collection.view.activated.connect(self.on_row_activated) # Options @@ -324,6 +329,20 @@ class App(QtCore.QObject): self.measeurement_tool = Measurement(self) self.measeurement_tool.install() + ############# + ### Shell ### + ############# + # TODO: Move this to its own class + self.shell = FCShell(self) + self.shell.setWindowIcon(self.ui.app_icon) + self.shell.setWindowTitle("FlatCAM Shell") + self.shell.show() + self.shell.resize(550, 300) + self.shell.append_output("FlatCAM Alpha 7\n(c) 2014 Juan Pablo Caram\n\n") + self.shell.append_output("Type help to get started.\n\n") + self.tcl = Tkinter.Tcl() + self.setup_shell() + App.log.debug("END of constructor. Releasing control.") def defaults_read_form(self): @@ -368,6 +387,110 @@ class App(QtCore.QObject): # Send to worker self.worker_task.emit({'fcn': worker_task, 'params': [self]}) + def execCommand(self, text): + """ + Hadles input from the shell. + + :param text: Input command + :return: None + """ + text = str(text) + + try: + result = self.tcl.eval(str(text)) + self.shell.append_output(result + '\n') + except Tkinter.TclError, e: + self.shell.append_error('ERROR: ' + str(e) + '\n') + raise + return + + def shhelp(p=None): + if not p: + return "Available commands:\n" + '\n'.join([' ' + cmd for cmd in commands]) + + if p not in commands: + return "Unknown command: %s" % p + + return commands[p]["help"] + + + commands = { + "open_gerber": { + "fcn": self.open_gerber, + "params": 1, + "converters": [lambda x: x], + "retfcn": lambda x: None, + "help": "Opens a Gerber file.\n> open_gerber \n filename: Path to file to open." + }, + "open_excellon": { + "fcn": self.open_excellon, + "params": 1, + "converters": [lambda x: x], + "retfcn": lambda x: None, + "help": "Opens an Excellon file.\n> open_excellon \n filename: Path to file to open." + }, + "open_gcode": { + "fcn": self.open_gcode, + "params": 1, + "converters": [lambda x: x], + "retfcn": lambda x: None, + "help": "Opens an G-Code file.\n> open_gcode \n filename: Path to file to open." + }, + "open_project": { + "fcn": self.open_project, + "params": 1, + "converters": [lambda x: x], + "retfcn": lambda x: None, + "help": "Opens a FlatCAM project.\n> open_project \n filename: Path to file to open." + }, + "save_project": { + "fcn": self.save_project, + "params": 1, + "converters": [lambda x: x], + "retfcn": lambda x: None, + "help": "Saves the FlatCAM project to file.\n> save_project \n filename: Path to file to save." + }, + "help": { + "fcn": shhelp, + "params": [0, 1], + "converters": [lambda x: x], + "retfcn": lambda x: x, + "help": "Shows list of commands." + } + } + + parts = re.findall(r'([\w\\:\.]+|".*?")+', text) + parts = [p.replace('\n', '').replace('"', '') for p in parts] + self.log.debug(parts) + try: + if parts[0] not in commands: + self.shell.append_error("Unknown command\n") + return + + #import inspect + #inspect.getargspec(someMethod) + if (type(commands[parts[0]]["params"]) is not list and len(parts)-1 != commands[parts[0]]["params"]) or \ + (type(commands[parts[0]]["params"]) is list and len(parts)-1 not in commands[parts[0]]["params"]): + self.shell.append_error( + "Command %s takes %d arguments. %d given.\n" % + (parts[0], commands[parts[0]]["params"], len(parts)-1) + ) + return + + cmdfcn = commands[parts[0]]["fcn"] + cmdconv = commands[parts[0]]["converters"] + if len(parts)-1 > 0: + retval = cmdfcn(*[cmdconv[i](parts[i+1]) for i in range(len(parts)-1)]) + else: + retval = cmdfcn() + retfcn = commands[parts[0]]["retfcn"] + if retval and retfcn(retval): + self.shell.append_output(retfcn(retval) + "\n") + + except: + self.shell.append_error(''.join(traceback.format_exc())) + #self.shell.append_error("?\n") + def info(self, text): self.ui.info_label.setText(QtCore.QString(text)) @@ -540,7 +663,7 @@ class App(QtCore.QObject): title = QtGui.QLabel( "FlatCAM
" - "Version Alpha 6 (2014/09)
" + "Version Alpha 7 (2014/10)
" "
" "2D Post-processing for Manufacturing specialized in
" "Printed Circuit Boards
" @@ -1379,6 +1502,114 @@ class App(QtCore.QObject): def set_progress_bar(self, percentage, text=""): self.ui.progress_bar.setValue(int(percentage)) + def setup_shell(self): + self.log.debug("setup_shell()") + + def shelp(p=None): + if not p: + return "Available commands:\n" + '\n'.join([' ' + cmd for cmd in commands]) + \ + "\n\nType help for usage.\n Example: help open_gerber" + + if p not in commands: + return "Unknown command: %s" % p + + return commands[p]["help"] + + def options(name): + ops = self.collection.get_by_name(str(name)).options + return '\n'.join(["%s: %s" % (o, ops[o]) for o in ops]) + + def isolate(name, dia=None, passes=None, overlap=None): + dia = float(dia) if dia is not None else None + passes = int(passes) if passes is not None else None + overlap = float(overlap) if overlap is not None else None + self.collection.get_by_name(str(name)).isolate(dia, passes, overlap) + + commands = { + 'help': { + 'fcn': shelp, + 'help': "Shows list of commands." + }, + 'open_gerber': { + 'fcn': self.open_gerber, + 'help': "Opens a Gerber file.\n> open_gerber \n filename: Path to file to open." + }, + 'open_excellon': { + 'fcn': self.open_excellon, + 'help': "Opens an Excellon file.\n> open_excellon \n filename: Path to file to open." + }, + 'open_gcode': { + 'fcn': self.open_gcode, + 'help': "Opens an G-Code file.\n> open_gcode \n filename: Path to file to open." + }, + 'open_project': { + 'fcn': self.open_project, + "help": "Opens a FlatCAM project.\n> open_project \n filename: Path to file to open." + }, + 'save_project': { + 'fcn': self.save_project, + 'help': "Saves the FlatCAM project to file.\n> save_project \n filename: Path to file to save." + }, + 'set_active': { + 'fcn': self.collection.set_active, + 'help': "Sets a FlatCAM object as active.\n > set_active \n name: Name of the object." + }, + 'get_names': { + 'fcn': lambda: '\n'.join(self.collection.get_names()), + 'help': "Lists the names of objects in the project.\n > get_names" + }, + 'new': { + 'fcn': self.on_file_new, + 'help': "Starts a new project. Clears objects from memory.\n > new" + }, + 'options': { + 'fcn': options, + 'help': "Shows the settings for an object.\n > options \n name: Object name." + }, + 'isolate': { + 'fcn': isolate, + 'help': "Creates isolation routing geometry for the given Gerber.\n" + + "> isolate [dia [passes [overlap]]]\n" + + " name: Name if the object\n" + " dia: Tool diameter\n passes: # of tool width\n" + + " overlap: Fraction of tool diameter to overlap passes" + }, + 'scale': { + 'fcn': lambda name, factor: self.collection.get_by_name(str(name)).scale(float(factor)), + 'help': "Resizes the object by a factor.\n" + + "> scale \n" + + " name: Name of the object\n factor: Fraction by which to scale" + }, + 'offset': { + 'fcn': lambda name, x, y: self.collection.get_by_name(str(name)).offset([float(x), float(y)]), + 'help': "Changes the position of the object.\n" + + "> offset \n" + + " name: Name of the object\n" + + " x: X-axis distance\n" + + " y: Y-axis distance" + }, + 'plot': { + 'fcn': self.plot_all, + 'help': 'Updates the plot on the user interface' + } + } + + for cmd in commands: + self.tcl.createcommand(cmd, commands[cmd]['fcn']) + + self.tcl.eval(''' + rename puts original_puts + proc puts {args} { + if {[llength $args] == 1} { + return "[lindex $args 0]" + } else { + eval original_puts $args + } + } + ''') + + + def setup_recent_items(self): self.log.debug("setup_recent_items()") @@ -1473,8 +1704,8 @@ class App(QtCore.QObject): try: data = json.load(f) except: - App.log.error("Could nor parse information about latest version.") - self.inform.emit("Could nor parse information about latest version.") + App.log.error("Could not parse information about latest version.") + self.inform.emit("Could not parse information about latest version.") f.close() return diff --git a/FlatCAMGUI.py b/FlatCAMGUI.py index bbb8d1a2..7b2e02b8 100644 --- a/FlatCAMGUI.py +++ b/FlatCAMGUI.py @@ -88,6 +88,7 @@ class FlatCAMGUI(QtGui.QMainWindow): ### Tool ### self.menutool = self.menu.addMenu('&Tool') + self.menutoolshell = self.menutool.addAction(QtGui.QIcon('share/shell16.png'), '&Command Line') ### Help ### self.menuhelp = self.menu.addMenu('&Help') @@ -106,6 +107,7 @@ class FlatCAMGUI(QtGui.QMainWindow): self.clear_plot_btn = self.toolbar.addAction(QtGui.QIcon('share/clear_plot32.png'), "&Clear Plot") self.replot_btn = self.toolbar.addAction(QtGui.QIcon('share/replot32.png'), "&Replot") self.delete_btn = self.toolbar.addAction(QtGui.QIcon('share/delete32.png'), "&Delete") + self.shell_btn = self.toolbar.addAction(QtGui.QIcon('share/shell32.png'), "&Command Line") ################ ### Splitter ### @@ -217,7 +219,7 @@ class FlatCAMGUI(QtGui.QMainWindow): self.setWindowIcon(self.app_icon) self.setGeometry(100, 100, 1024, 650) - self.setWindowTitle('FlatCAM - Alpha 6') + self.setWindowTitle('FlatCAM - Alpha 7') self.show() diff --git a/FlatCAMObj.py b/FlatCAMObj.py index 93e66db8..9245c578 100644 --- a/FlatCAMObj.py +++ b/FlatCAMObj.py @@ -388,9 +388,23 @@ class FlatCAMGerber(FlatCAMObj, Gerber): def on_iso_button_click(self, *args): self.read_form() - dia = self.options["isotooldia"] - passes = int(self.options["isopasses"]) - overlap = self.options["isooverlap"] * dia + self.isolate() + + def isolate(self, dia=None, passes=None, overlap=None): + """ + Creates an isolation routing geometry object in the project. + + :param dia: Tool diameter + :param passes: Number of tool widths to cut + :param overlap: Overlap between passes in fraction of tool diameter + :return: None + """ + if dia is None: + dia = self.options["isotooldia"] + if passes is None: + passes = int(self.options["isopasses"]) + if overlap is None: + overlap = self.options["isooverlap"] * dia for i in range(passes): diff --git a/FlatCAMShell.py b/FlatCAMShell.py new file mode 100644 index 00000000..5539fc25 --- /dev/null +++ b/FlatCAMShell.py @@ -0,0 +1,27 @@ +import sys +from PyQt4.QtGui import QApplication +import termwidget + + +class FCShell(termwidget.TermWidget): + def __init__(self, sysShell, *args): + termwidget.TermWidget.__init__(self, *args) + self._sysShell = sysShell + + def is_command_complete(self, text): + def skipQuotes(text): + quote = text[0] + text = text[1:] + endIndex = str(text).index(quote) + return text[endIndex:] + while text: + if text[0] in ('"', "'"): + try: + text = skipQuotes(text) + except ValueError: + return False + text = text[1:] + return True + + def child_exec_command(self, text): + self._sysShell.execCommand(text) diff --git a/recent.json b/recent.json index fc7000f2..77b569e4 100644 --- a/recent.json +++ b/recent.json @@ -1 +1 @@ -[{"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/not_loaded.gtl"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/diag_1TOP.art"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/22TOP.art"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/Top.gbr"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/holes.drl"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/cirkuix/tests/CBS-F_Cu.gtl"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/BLDC2003Through.drl"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/cirkuix/tests/CBS-B_Cu.gbl"}, {"kind": "project", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/gerber_project.fcam"}, {"kind": "project", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/drill_project.fcam"}] \ No newline at end of file +[{"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/maitest.gtl"}, {"kind": "gerber", "filename": "C:\\Users\\jpcaram\\Dropbox\\CNC\\pcbcam\\test_files\\maitest.gtl"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/cirkuix/tests/CBS-F_Cu.gtl"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/cirkuix/tests/CBS.drl"}, {"kind": "gerber", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/Gerbers/AVR_Transistor_Tester_copper_top.GTL"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/LockController_v1.0_pcb-RoundHoles.TXT/LockController_v1.0_pcb-RoundHoles.TXT"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/Gerbers/AVR_Transistor_Tester.DRL"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/FlatCam_Drilling_Test/FlatCam_Drilling_Test.drl"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/Excellon_Planck/X-Y CONTROLLER - Drill Data - Through Hole.drl"}, {"kind": "excellon", "filename": "C:/Users/jpcaram/Dropbox/CNC/pcbcam/test_files/Scrivania/girasettori_v3.drd"}] \ No newline at end of file diff --git a/termwidget.py b/termwidget.py new file mode 100644 index 00000000..0580ec83 --- /dev/null +++ b/termwidget.py @@ -0,0 +1,238 @@ +""" +Terminal emulator widget. +Shows intput and output text. Allows to enter commands. Supports history. +""" + +import cgi + +from PyQt4.QtCore import pyqtSignal +from PyQt4.QtGui import QColor, QKeySequence, QLineEdit, QPalette, \ + QSizePolicy, QTextCursor, QTextEdit, \ + QVBoxLayout, QWidget + + +class _ExpandableTextEdit(QTextEdit): + """ + Class implements edit line, which expands themselves automatically + """ + + historyNext = pyqtSignal() + historyPrev = pyqtSignal() + + def __init__(self, termWidget, *args): + QTextEdit.__init__(self, *args) + self.setStyleSheet("font: 9pt \"Courier\";") + self._fittedHeight = 1 + self.textChanged.connect(self._fit_to_document) + self._fit_to_document() + self._termWidget = termWidget + + def sizeHint(self): + """ + QWidget sizeHint impelemtation + """ + hint = QTextEdit.sizeHint(self) + hint.setHeight(self._fittedHeight) + return hint + + def _fit_to_document(self): + """ + Update widget height to fit all text + """ + documentSize = self.document().size().toSize() + self._fittedHeight = documentSize.height() + (self.height() - self.viewport().height()) + self.setMaximumHeight(self._fittedHeight) + self.updateGeometry(); + + def keyPressEvent(self, event): + """ + Catch keyboard events. Process Enter, Up, Down + """ + if event.matches(QKeySequence.InsertParagraphSeparator): + text = self.toPlainText() + if self._termWidget.is_command_complete(text): + self._termWidget.exec_current_command() + return + elif event.matches(QKeySequence.MoveToNextLine): + text = self.toPlainText() + cursorPos = self.textCursor().position() + textBeforeEnd = text[cursorPos:] + # if len(textBeforeEnd.splitlines()) <= 1: + if len(textBeforeEnd.split('\n')) <= 1: + self.historyNext.emit() + return + elif event.matches(QKeySequence.MoveToPreviousLine): + text = self.toPlainText() + cursorPos = self.textCursor().position() + textBeforeStart = text[:cursorPos] + # lineCount = len(textBeforeStart.splitlines()) + lineCount = len(textBeforeStart.split('\n')) + if len(textBeforeStart) > 0 and \ + (textBeforeStart[-1] == '\n' or textBeforeStart[-1] == '\r'): + lineCount += 1 + if lineCount <= 1: + self.historyPrev.emit() + return + elif event.matches(QKeySequence.MoveToNextPage) or \ + event.matches(QKeySequence.MoveToPreviousPage): + return self._termWidget.browser().keyPressEvent(event) + + QTextEdit.keyPressEvent(self, event) + + +class TermWidget(QWidget): + """ + Widget wich represents terminal. It only displays text and allows to enter text. + All highlevel logic should be implemented by client classes + + User pressed Enter. Client class should decide, if command must be executed or user may continue edit it + """ + + def __init__(self, *args): + QWidget.__init__(self, *args) + + self._browser = QTextEdit(self) + self._browser.setStyleSheet("font: 9pt \"Courier\";") + self._browser.setReadOnly(True) + self._browser.document().setDefaultStyleSheet(self._browser.document().defaultStyleSheet() + + "span {white-space:pre;}") + + self._edit = _ExpandableTextEdit(self, self) + self._edit.historyNext.connect(self._on_history_next) + self._edit.historyPrev.connect(self._on_history_prev) + self.setFocusProxy(self._edit) + + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._browser) + layout.addWidget(self._edit) + + self._history = [''] # current empty line + self._historyIndex = 0 + + self._edit.setFocus() + + def _append_to_browser(self, style, text): + """ + Convert text to HTML for inserting it to browser + """ + assert style in ('in', 'out', 'err') + + text = cgi.escape(text) + + text = text.replace('\n', '
') + + if style != 'out': + def_bg = self._browser.palette().color(QPalette.Base) + h, s, v, a = def_bg.getHsvF() + + if style == 'in': + if v > 0.5: # white background + v = v - (v / 8) # make darker + else: + v = v + ((1 - v) / 4) # make ligher + else: # err + if v < 0.5: + v = v + ((1 - v) / 4) # make ligher + + if h == -1: # make red + h = 0 + s = .4 + else: + h = h + ((1 - h) * 0.5) # make more red + + bg = QColor.fromHsvF(h, s, v).name() + text = '%s' % (str(bg), text) + else: + text = '%s' % text # without span
is ignored!!! + + scrollbar = self._browser.verticalScrollBar() + old_value = scrollbar.value() + scrollattheend = old_value == scrollbar.maximum() + + self._browser.moveCursor(QTextCursor.End) + self._browser.insertHtml(text) + + """TODO When user enters second line to the input, and input is resized, scrollbar changes its positon + and stops moving. As quick fix of this problem, now we always scroll down when add new text. + To fix it correctly, srcoll to the bottom, if before intput has been resized, + scrollbar was in the bottom, and remove next lien + """ + scrollattheend = True + + if scrollattheend: + scrollbar.setValue(scrollbar.maximum()) + else: + scrollbar.setValue(old_value) + + def exec_current_command(self): + """ + Save current command in the history. Append it to the log. Clear edit line + Reimplement in the child classes to actually execute command + """ + text = self._edit.toPlainText() + self._append_to_browser('in', '> ' + text + '\n') + + if len(self._history) < 2 or\ + self._history[-2] != text: # don't insert duplicating items + self._history.insert(-1, text) + + self._historyIndex = len(self._history) - 1 + + self._history[-1] = '' + self._edit.clear() + + if not text[-1] == '\n': + text += '\n' + + self.child_exec_command(text) + + def child_exec_command(self, text): + """ + Reimplement in the child classes + """ + pass + + def add_line_break_to_input(self): + self._edit.textCursor().insertText('\n') + + def append_output(self, text): + """Appent text to output widget + """ + self._append_to_browser('out', text) + + def append_error(self, text): + """Appent error text to output widget. Text is drawn with red background + """ + self._append_to_browser('err', text) + + def is_command_complete(self, text): + """ + Executed by _ExpandableTextEdit. Reimplement this function in the child classes. + """ + return True + + def browser(self): + return self._browser + + def _on_history_next(self): + """ + Down pressed, show next item from the history + """ + if (self._historyIndex + 1) < len(self._history): + self._historyIndex += 1 + self._edit.setPlainText(self._history[self._historyIndex]) + self._edit.moveCursor(QTextCursor.End) + + def _on_history_prev(self): + """ + Up pressed, show previous item from the history + """ + if self._historyIndex > 0: + if self._historyIndex == (len(self._history) - 1): + self._history[-1] = self._edit.toPlainText() + self._historyIndex -= 1 + self._edit.setPlainText(self._history[self._historyIndex]) + self._edit.moveCursor(QTextCursor.End) +