From 9f626240aad30e7c3113fd606f7eb1c966c1f534 Mon Sep 17 00:00:00 2001 From: Ircama Date: Sun, 17 Nov 2024 07:55:16 +0100 Subject: [PATCH] Fix bugs, improve prompts and add debug console (F7) --- .gitignore | 2 + ui.py | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 371 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 7c02179..984f09a 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ devices.xml *.toml *.srs *.pickle + +.python_history diff --git a/ui.py b/ui.py index d29920a..817869e 100644 --- a/ui.py +++ b/ui.py @@ -18,6 +18,10 @@ import webbrowser import pickle +from code import InteractiveConsole +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO + import black import tkinter as tk from tkinter import ttk, Menu @@ -48,6 +52,342 @@ ) +class History(list): + def __init__(self, history_file=".python_history"): + super().__init__() + self.history_file = history_file + + # Load history from the file + if os.path.exists(self.history_file): + with open(self.history_file, "r", encoding="utf-8") as file: + self.extend(line.strip() for line in file if line.strip()) + + def __getitem__(self, index): + try: + return super().__getitem__(index) + except IndexError: + return None + + def append(self, item): + # Ensure only strings are added + if isinstance(item, list): + item = " ".join(item) # Convert list to string + super().append(item) + + def save(self): + # Save history back to the file + with open(self.history_file, "w", encoding="utf-8") as file: + file.writelines(item + "\n" for item in self) + + +class TextConsole(tk.Text): + def __init__(self, main, master, **kw): + kw.setdefault('width', 50) + kw.setdefault('wrap', 'word') + kw.setdefault('prompt1', '>>> ') + kw.setdefault('prompt2', '... ') + banner = kw.pop('banner', 'Python %s\n' % sys.version) + self._prompt1 = kw.pop('prompt1') + self._prompt2 = kw.pop('prompt2') + tk.Text.__init__(self, master, **kw) + # --- history + self.history = History() + self._hist_item = 0 + self._hist_match = '' + + # --- initialization + console_locals = { + "self": main, + "master": master, + "kw": kw, + "local": self + } + self._console = InteractiveConsole(locals=console_locals) # python console + self.insert('end', banner, 'banner') + self.prompt() + self.mark_set('input', 'insert') + self.mark_gravity('input', 'left') + + # --- bindings + self.bind('', self.on_ctrl_return) + self.bind('', self.on_shift_return) + self.bind('', self.on_key_press) + self.bind('', self.on_key_release) + self.bind('', self.on_tab) + self.bind('', self.on_down) + self.bind('', self.on_up) + self.bind('', self.on_return) + self.bind('', self.on_backspace) + self.bind('', self.on_ctrl_c) + self.bind('<>', self.on_paste) + + @property + def h(self): + """Override the history property to return the formatted history as a string.""" + self.insert( + 'end', "\n".join( + f"{i + 1}: {command}" for i, command in enumerate(self.history) + ) + '\n' + ) + + def on_ctrl_c(self, event): + """Copy selected code, removing prompts first""" + sel = self.tag_ranges('sel') + if sel: + txt = self.get('sel.first', 'sel.last').splitlines() + lines = [] + for i, line in enumerate(txt): + if line.startswith(self._prompt1): + lines.append(line[len(self._prompt1):]) + elif line.startswith(self._prompt2): + lines.append(line[len(self._prompt2):]) + else: + lines.append(line) + self.clipboard_clear() + self.clipboard_append('\n'.join(lines)) + return 'break' + + def on_paste(self, event): + """Paste commands""" + if self.compare('insert', '<', 'input'): + return "break" + sel = self.tag_ranges('sel') + if sel: + self.delete('sel.first', 'sel.last') + txt = self.clipboard_get() + self.insert("insert", txt) + self.insert_cmd(self.get("input", "end")) + return 'break' + + def prompt(self, result=False): + """Insert a prompt""" + if result: + self.insert('end', self._prompt2, 'prompt') + else: + self.insert('end', self._prompt1, 'prompt') + self.mark_set('input', 'end-1c') + + def on_key_press(self, event): + """Prevent text insertion in command history""" + if self.compare('insert', '<', 'input') and event.keysym not in ['Left', 'Right']: + self._hist_item = len(self.history) + self.mark_set('insert', 'input lineend') + if not event.char.isalnum(): + return 'break' + + def on_key_release(self, event): + """Reset history scrolling""" + if self.compare('insert', '<', 'input') and event.keysym not in ['Left', 'Right']: + self._hist_item = len(self.history) + return 'break' + + def on_up(self, event): + """Handle up arrow key press""" + # Handle cursor position outside the input area + if self.compare('insert', '<', 'input'): + self.mark_set('insert', 'end') + return 'break' + + # Check if at the start of the input line + elif self.index('input linestart') == self.index('insert linestart'): + # Get the current input line for partial matching + line = self.get('input', 'insert') + self._hist_match = line + + # Save the current history index and move one step back + hist_item = self._hist_item + self._hist_item -= 1 + + # Search for a matching history entry + while self._hist_item >= 0: + # Convert the current history item to a string + item = self.history[self._hist_item] + if item.startswith(line): # Match the current input + break + self._hist_item -= 1 + + if self._hist_item >= 0: + # Found a match: insert the command + index = self.index('insert') + self.insert_cmd(item) # Update input with the matched command + self.mark_set('insert', index) + else: + # No match: reset the history index + self._hist_item = hist_item + + return 'break' + + def on_down(self, event): + """Handle down arrow key press""" + # Handle cursor position outside the input area + if self.compare('insert', '<', 'input'): + self.mark_set('insert', 'end') + return 'break' + + # Check if at the end of the last input line + elif self.compare('insert lineend', '==', 'end-1c'): + # Get the prefix to match (from the previous navigation step) + line = self._hist_match + + # Move one step forward in history + self._hist_item += 1 + + # Search for a matching history entry + while self._hist_item < len(self.history): + # Convert the current history item to a string + item = self.history[self._hist_item] + if item.startswith(line): # Match the prefix + break + self._hist_item += 1 + + if self._hist_item < len(self.history): + # Found a match: insert the command + self.insert_cmd(item) + self.mark_set('insert', 'input+%ic' % len(self._hist_match)) + else: + # No match: reset to the end of the history + self._hist_item = len(self.history) + self.delete('input', 'end') + self.insert('insert', line) + + return 'break' + + def on_tab(self, event): + """Handle tab key press""" + if self.compare('insert', '<', 'input'): + self.mark_set('insert', 'input lineend') + return "break" + # indent code + sel = self.tag_ranges('sel') + if sel: + start = str(self.index('sel.first')) + end = str(self.index('sel.last')) + start_line = int(start.split('.')[0]) + end_line = int(end.split('.')[0]) + 1 + for line in range(start_line, end_line): + self.insert('%i.0' % line, ' ') + else: + txt = self.get('insert-1c') + if not txt.isalnum() and txt != '.': + self.insert('insert', ' ') + return "break" + + def on_shift_return(self, event): + """Handle Shift+Return key press""" + if self.compare('insert', '<', 'input'): + self.mark_set('insert', 'input lineend') + return 'break' + else: # execute commands + self.mark_set('insert', 'end') + self.insert('insert', '\n') + self.insert('insert', self._prompt2, 'prompt') + self.eval_current(True) + + def on_return(self, event=None): + """Handle Return key press""" + if self.compare('insert', '<', 'input'): + self.mark_set('insert', 'input lineend') + return 'break' + else: + self.eval_current(True) + self.see('end') + self.history.save() + return 'break' + + def on_ctrl_return(self, event=None): + """Handle Ctrl+Return key press""" + self.insert('insert', '\n' + self._prompt2, 'prompt') + return 'break' + + def on_backspace(self, event): + """Handle delete key press""" + if self.compare('insert', '<=', 'input'): + self.mark_set('insert', 'input lineend') + return 'break' + sel = self.tag_ranges('sel') + if sel: + self.delete('sel.first', 'sel.last') + else: + linestart = self.get('insert linestart', 'insert') + if re.search(r' $', linestart): + self.delete('insert-4c', 'insert') + else: + self.delete('insert-1c') + return 'break' + + def insert_cmd(self, cmd): + """Insert lines of code, adding prompts""" + input_index = self.index('input') + self.delete('input', 'end') + lines = cmd.splitlines() + if lines: + indent = len(re.search(r'^( )*', lines[0]).group()) + self.insert('insert', lines[0][indent:]) + for line in lines[1:]: + line = line[indent:] + self.insert('insert', '\n') + self.prompt(True) + self.insert('insert', line) + self.mark_set('input', input_index) + self.see('end') + + def eval_current(self, auto_indent=False): + """Evaluate code""" + index = self.index('input') + lines = self.get('input', 'insert lineend').splitlines() # commands to execute + self.mark_set('insert', 'insert lineend') + if lines: # there is code to execute + # remove prompts + lines = [lines[0].rstrip()] + [line[len(self._prompt2):].rstrip() for line in lines[1:]] + for i, l in enumerate(lines): + if l.endswith('?'): + lines[i] = 'help(%s)' % l[:-1] + cmds = '\n'.join(lines) + self.insert('insert', '\n') + out = StringIO() # command output + err = StringIO() # command error traceback + with redirect_stderr(err): # redirect error traceback to err + with redirect_stdout(out): # redirect command output + # execute commands in interactive console + res = self._console.push(cmds) + # if res is True, this is a partial command, e.g. 'def test():' and we need to wait for the rest of the code + errors = err.getvalue() + if errors: # there were errors during the execution + self.insert('end', errors) # display the traceback + self.mark_set('input', 'end') + self.see('end') + self.prompt() # insert new prompt + else: + output = out.getvalue() # get output + if output: + self.insert('end', output, 'output') + self.mark_set('input', 'end') + self.see('end') + if not res and self.compare('insert linestart', '>', 'insert'): + self.insert('insert', '\n') + self.prompt(res) + if auto_indent and lines: + # insert indentation similar to previous lines + indent = re.search(r'^( )*', lines[-1]).group() + line = lines[-1].strip() + if line and line[-1] == ':': + indent = indent + ' ' + self.insert('insert', indent) + self.see('end') + if res: + self.mark_set('input', index) + self._console.resetbuffer() # clear buffer since the whole command will be retrieved from the text widget + elif lines: + if not self.history or [self.history[-1]] != lines: + self.history.append(lines) # Add commands to history + self._hist_item = len(self.history) + out.close() + err.close() + else: + self.insert('insert', '\n') + self.prompt() + + class MultiLineInputDialog(simpledialog.Dialog): def __init__(self, parent, title=None, text=""): self.text=text @@ -267,6 +607,9 @@ def __init__( help_menu.add_command(label="Clear printer list", command=self.clear_printer_list) help_menu.entryconfig("Clear printer list", accelerator="F6") + help_menu.add_command(label="Run debug shell", command=self.tk_console) + help_menu.entryconfig("Run debug shell", accelerator="F7") + help_menu.add_command(label="Get next local IP addresss", command=lambda: self.next_ip(0)) help_menu.entryconfig("Get next local IP addresss", accelerator="F9") @@ -337,6 +680,7 @@ def __init__( self.model_dropdown.bind("", lambda event: self.remove_printer_conf()) self.model_dropdown.bind("", lambda event: self.keep_printer_conf()) self.model_dropdown.bind("", lambda event: self.clear_printer_list()) + self.model_dropdown.bind("", lambda event: self.tk_console()) # BOX IP address ip_frame = ttk.LabelFrame( @@ -1021,6 +1365,18 @@ def clear_printer_list(self): f"[INFO] Printer list cleared.\n" ) + def tk_console(self): + console_window = tk.Toplevel(self) + console_window.title("Debug Console") + console_window.geometry("600x400") + + console = TextConsole(self, console_window) + console.pack(fill='both', expand=True) # Use pack within the frame + + # Configure grid resizing for the frame + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + def open_help_browser(self): # Opens a web browser to a help URL url = "https://ircama.github.io/epson_print_conf" @@ -1396,7 +1752,7 @@ def get_current_eeprom_values(self, values, label): self.status_text.insert( tk.END, f'[ERROR] Cannot read EEPROM values for "{label}"' - ': invalid printer model selected.\n' + f': invalid printer model selected: {self.printer.model}.\n' ) self.config(cursor="") self.update_idletasks() @@ -1675,10 +2031,17 @@ def get_ti_date(self, cursor=True): self.update_idletasks() return try: - date_string = datetime.strptime( - self.printer.stats( - )["stats"]["First TI received time"], "%d %b %Y" - ).strftime("%Y-%m-%d") + d = self.printer.stats()["stats"]["First TI received time"] + if d == "?": + self.status_text.insert( + tk.END, + "[ERROR]: No data from 'First TI received time'." + " Check printer configuration.\n", + ) + self.config(cursor="") + self.update_idletasks() + return + date_string = datetime.strptime(d, "%d %b %Y").strftime("%Y-%m-%d") self.status_text.insert( tk.END, f"[INFO] First TI received time (YYYY-MM-DD): {date_string}.\n", @@ -2493,8 +2856,7 @@ def detect_sequence(eeprom, sequence): if not eeprom or eeprom == {0: None}: self.status_text.insert( tk.END, - '[ERROR] Cannot read EEPROM values' - ': invalid printer model selected.\n' + '[ERROR] Cannot read EEPROM values: invalid printer model selected.\n' ) self.update() self.config(cursor="")