From e4ce53922b41429bedea1971cac1b7a92d10f34c Mon Sep 17 00:00:00 2001 From: Billy Date: Fri, 19 Apr 2024 00:50:06 +0530 Subject: [PATCH 1/2] feat: PTY output parser --- mono/ansi.py | 46 ++++++++++++++++++++++++++++++++++++++-------- mono/terminal.py | 22 ++++++++++------------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/mono/ansi.py b/mono/ansi.py index 5bc298f..53b09d1 100644 --- a/mono/ansi.py +++ b/mono/ansi.py @@ -1,11 +1,41 @@ -import re - +from __future__ import annotations -SEQ = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') -NEWLINE = re.compile(r'\x1b\[\d+\;1H') +import re +import typing -def strip_ansi_escape_sequences(string): - return SEQ.sub('', string) +if typing.TYPE_CHECKING: + from .terminal import Terminal -def replace_newline(string): - return NEWLINE.sub('\n', string) +class OutputParser: + def __init__(self, terminal:Terminal): + self.terminal = terminal + + def parse(self, buf: str): + display_text = "" + i = 0 + while i < len(buf): + match buf[i]: + case '\x07': + # bell + ... + case '\x08': + # backspace + ... + case '\x09': + # tab + ... + case '\x0a': + # newline + # self.terminal._newline() + ... + case '\x0d': + # carriage return + ... + case '\x1b': + # parse escape sequence + ... + case _: + display_text += buf[i] + i += 1 + + return display_text diff --git a/mono/terminal.py b/mono/terminal.py index 37b5e7a..1c499a2 100644 --- a/mono/terminal.py +++ b/mono/terminal.py @@ -1,4 +1,5 @@ import os +import re import tkinter as tk from threading import Thread from tkinter import ttk @@ -8,10 +9,10 @@ else: from ptyprocess import PtyProcessUnicode as PTY +from mono.ansi import OutputParser from mono.theme import Theme from mono.utils import Scrollbar -from .ansi import replace_newline, strip_ansi_escape_sequences from .text import TerminalText @@ -55,6 +56,8 @@ def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, * self.text.grid(row=0, column=0, sticky=tk.NSEW) self.text.bind("", self.enter) + self.parser = OutputParser(self) + self.terminal_scrollbar = Scrollbar(self, style="MonoScrollbar") self.terminal_scrollbar.grid(row=0, column=1, sticky='NSW') @@ -94,6 +97,11 @@ def run_command(self, command: str) -> None: self.text.insert("end", command, "command") self.enter() + def clear(self) -> None: + """Clear the terminal.""" + + self.text.clear() + def enter(self, *_) -> None: """Enter key event handler for running commands.""" @@ -109,12 +117,7 @@ def enter(self, *_) -> None: def _write_loop(self) -> None: while self.alive: if buf := self.p.read(): - p = buf.find('\x1b]0;') - - if p != -1: - buf = buf[:p] - buf = [strip_ansi_escape_sequences(i) for i in replace_newline(buf).splitlines()] - self._insert('\n'.join(buf)) + self._insert('\n'.join(self.parser.parse(buf))) def _insert(self, output: str, tag='') -> None: self.text.insert(tk.END, output, tag) @@ -125,11 +128,6 @@ def _insert(self, output: str, tag='') -> None: def _newline(self): self._insert('\n') - def clear(self) -> None: - """Clear the terminal.""" - - self.text.clear() - # TODO: Implement a better way to handle key events. def _ctrl_key(self, key: str) -> None: if key == 'c': From 76aa01ea96141d36ef65c9911e6d4cb689442286 Mon Sep 17 00:00:00 2001 From: Billy Date: Sun, 21 Jul 2024 17:06:24 +0900 Subject: [PATCH 2/2] feat: Handlers CSI, OSC, DCS and simple sequences, function templates added --- mono/ansi.py | 41 ---- mono/parser.py | 603 +++++++++++++++++++++++++++++++++++++++++++++++ mono/terminal.py | 233 ++++++++++++++---- mono/test.py | 9 + mono/text.py | 81 +++++-- 5 files changed, 855 insertions(+), 112 deletions(-) delete mode 100644 mono/ansi.py create mode 100644 mono/parser.py create mode 100644 mono/test.py diff --git a/mono/ansi.py b/mono/ansi.py deleted file mode 100644 index 53b09d1..0000000 --- a/mono/ansi.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import re -import typing - -if typing.TYPE_CHECKING: - from .terminal import Terminal - -class OutputParser: - def __init__(self, terminal:Terminal): - self.terminal = terminal - - def parse(self, buf: str): - display_text = "" - i = 0 - while i < len(buf): - match buf[i]: - case '\x07': - # bell - ... - case '\x08': - # backspace - ... - case '\x09': - # tab - ... - case '\x0a': - # newline - # self.terminal._newline() - ... - case '\x0d': - # carriage return - ... - case '\x1b': - # parse escape sequence - ... - case _: - display_text += buf[i] - i += 1 - - return display_text diff --git a/mono/parser.py b/mono/parser.py new file mode 100644 index 0000000..531ee2e --- /dev/null +++ b/mono/parser.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import base64 +import re +import typing +from typing import List + +if typing.TYPE_CHECKING: + from .terminal import Terminal + + +class ANSI: + BEL = "\x07" + BS = "\x08" + HT = "\x09" + LF = "\x0a" + FF = "\x0c" + CR = "\x0d" + ESC = "\x1b" + + +class OutputParser: + def __init__(self, terminal: Terminal): + self.terminal = terminal + self.buf = "" + self.escape_sequence = "" + self.in_escape_sequence = False + + def parse(self, buf: str) -> List[str]: + self.buf += buf + return self.parse_ansi() + + def parse_ansi(self) -> List[str]: + output = [] + i = 0 + while i < len(self.buf): + char = self.buf[i] + + if self.in_escape_sequence: + self.escape_sequence += char + if self.is_escape_sequence_complete(char): + self.handle_escape_sequence(self.escape_sequence) + self.in_escape_sequence = False + self.escape_sequence = "" + i += 1 + continue + + match char: + case ANSI.BEL: + self.terminal._bell() + case ANSI.BS: + self.terminal._backspace() + case ANSI.HT: + self.terminal._tab() + case ANSI.LF: + self.terminal._newline() + case ANSI.FF: + self.terminal._formfeed() + case ANSI.CR: + self.terminal._carriage_return() + case ANSI.ESC: + self.in_escape_sequence = True + self.escape_sequence = char + case _: + output.append(char) + + i += 1 + + self.buf = "" + return output + + def is_escape_sequence_complete(self, char: str) -> bool: + if self.escape_sequence.startswith(ANSI.ESC + "["): # CSI + return char.isalpha() + elif self.escape_sequence.startswith(ANSI.ESC + "]"): # OSC + return char == ANSI.BEL or ( + len(self.escape_sequence) >= 2 + and self.escape_sequence[-2:] == ANSI.ESC + "\\" + ) + elif self.escape_sequence.startswith(ANSI.ESC + "P"): # DCS + return ( + len(self.escape_sequence) >= 2 + and self.escape_sequence[-2:] == ANSI.ESC + "\\" + ) + else: + return len(self.escape_sequence) == 2 # Simple escape sequences + + def handle_escape_sequence(self, sequence: str): + if sequence.startswith(ANSI.ESC + "["): # CSI + self.handle_csi_sequence(sequence) + elif sequence.startswith(ANSI.ESC + "]"): # OSC + self.handle_osc_sequence(sequence) + elif sequence.startswith(ANSI.ESC + "P"): # DCS + self.handle_dcs_sequence(sequence) + else: + self.handle_simple_escape_sequence(sequence) + + def handle_csi_sequence(self, sequence: str): + match = re.match(r"\x1b\[(\d*;?)*([A-Za-z])", sequence) + if match: + params = match.group(1).split(";") if match.group(1) else [] + command = match.group(2) + + match command: + case "@": # Insert Characters + self.terminal._insert_characters(params) + case "A": # Cursor Up + self.terminal._cursor_up(params) + case "B": # Cursor Down + self.terminal._cursor_down(params) + case "C": # Cursor Forward + self.terminal._cursor_forward(params) + case "D": # Cursor Backward + self.terminal._cursor_backward(params) + case "E": # Cursor Next Line + self.terminal._cursor_next_line(params) + case "F": # Cursor Previous Line + self.terminal._cursor_previous_line(params) + case "G": # Cursor Horizontal Absolute + self.terminal._cursor_horizontal_absolute(params) + case "H" | "f": # Cursor Position + self.terminal._cursor_position(params) + case "I": # Cursor Forward Tabulation + self.terminal._cursor_forward_tabulation(params) + case "J": # Erase in Display + self.terminal._erase_in_display(params) + case "K": # Erase in Line + self.terminal._erase_in_line(params) + case "L": # Insert Lines + self.terminal._insert_lines(params) + case "M": # Delete Lines + self.terminal._delete_lines(params) + case "P": # Delete Characters + self.terminal._delete_characters(params) + case "S": # Scroll Up + self.terminal._scroll_up(params) + case "T": # Scroll Down + self.terminal._scroll_down(params) + case "X": # Erase Characters + self.terminal._erase_characters(params) + case "Z": # Cursor Backward Tabulation + self.terminal._cursor_backward_tabulation(params) + case "`": # Character Position Absolute + self.terminal._character_position_absolute(params) + case "a": # Character Position Relative + self.terminal._character_position_relative(params) + case "b": # Repeat Preceding Character + self.terminal._repeat_preceding_character(params) + case "c": # Send Device Attributes + self.terminal._send_device_attributes(params) + case "d": # Line Position Absolute + self.terminal._line_position_absolute(params) + case "e": # Line Position Relative + self.terminal._line_position_relative(params) + case "g": # Tab Clear + self.terminal._tab_clear(params) + case "h": # Set Mode + self.terminal._set_mode(params) + case "l": # Reset Mode + self.terminal._reset_mode(params) + case "m": # Select Graphic Rendition (SGR) + self.handle_sgr(params) + case "n": # Device Status Report + self.terminal._device_status_report(params) + case "p": # Set Keyboard String / Set Conformance Level + self.terminal._set_keyboard_string(params) + case "q": # Set Cursor Style (DECSCUSR) + self.terminal._set_cursor_style(params) + case "r": # Set Scrolling Region + self.terminal._set_scrolling_region(params) + case "s": # Save Cursor Position + self.terminal._save_cursor_position() + case "t": # Window Manipulation + self.terminal._window_manipulation(params) + case "u": # Restore Cursor Position + self.terminal._restore_cursor_position() + case "x": # Request Terminal Parameters + self.terminal._request_terminal_parameters(params) + case "{": # Select Locator Events + self.terminal._select_locator_events(params) + case "|": # Request Locator Position + self.terminal._request_locator_position(params) + case _: + print(f"Unhandled CSI sequence: {sequence}") + + def handle_osc_sequence(self, sequence: str): + # Handle Operating System Command sequences + # OSC sequences start with ESC ] and end with BEL (^G) or ST (ESC \) + if sequence.endswith("\x07"): + osc_content = sequence[2:-1] # Remove ESC ] at start and BEL at end + elif sequence.endswith("\x1b\\"): + osc_content = sequence[2:-2] # Remove ESC ] at start and ESC \ at end + else: + print(f"Malformed OSC sequence: {sequence}") + return + + parts = osc_content.split(";") + osc_code = parts[0] + + match osc_code: + case "0" | "1" | "2": + # Set window title and icon name + title = ";".join(parts[1:]) + self.terminal._set_window_title(title) + case "3": + # Set X property on top-level window + prop, value = parts[1], ";".join(parts[2:]) + self.terminal._set_x_property(prop, value) + case "4": + # Change/query color palette + self.handle_color_palette(parts[1:]) + case "5": + # Change/query special color number + self.handle_special_color(parts[1:]) + case "6": + # Enable/disable special color number + self.terminal._set_color_mode(parts[1]) + case "7": + # Query/set current directory + self.terminal._set_current_directory(";".join(parts[1:])) + case "8": + # Create/update hyperlink + self.handle_hyperlink(parts[1:]) + case "9": + # iTerm2 Growl notification + self.terminal._send_notification(";".join(parts[1:])) + case "10": + # Set foreground color (deprecated) + self.terminal._set_foreground_color(parts[1]) + case "11": + # Set background color (deprecated) + self.terminal._set_background_color(parts[1]) + case "12": + # Set cursor color + self.terminal._set_cursor_color(parts[1]) + case "13": + # Set mouse foreground color + self.terminal._set_mouse_fore_color(parts[1]) + case "14": + # Set mouse background color + self.terminal._set_mouse_back_color(parts[1]) + case "15": + # Set Tektronix foreground color + self.terminal._set_tek_fore_color(parts[1]) + case "16": + # Set Tektronix background color + self.terminal._set_tek_back_color(parts[1]) + case "17": + # Set highlight background color + self.terminal._set_highlight_bg_color(parts[1]) + case "18": + # Set Tektronix cursor color + self.terminal._set_tek_cursor_color(parts[1]) + case "19": + # Set highlight foreground color + self.terminal._set_highlight_fg_color(parts[1]) + case "46": + # Change log file + self.terminal._set_log_file(parts[1]) + case "50": + # Set font + self.terminal._set_font(";".join(parts[1:])) + case "51": + # Set emoji font + self.terminal._set_emoji_font(";".join(parts[1:])) + case "52": + # Manipulate selection data + self.handle_selection_data(parts[1:]) + case "104": + # Reset color palette + self.terminal._reset_color_palette() + case "105": + # Reset special color + self.terminal._reset_special_color(parts[1]) + case "110": + # Reset foreground color + self.terminal._reset_foreground_color() + case "111": + # Reset background color + self.terminal._reset_background_color() + case "112": + # Reset cursor color + self.terminal._reset_cursor_color() + case "113": + # Reset mouse foreground color + self.terminal._reset_mouse_fore_color() + case "114": + # Reset mouse background color + self.terminal._reset_mouse_back_color() + case "115": + # Reset Tektronix foreground color + self.terminal._reset_tek_fore_color() + case "116": + # Reset Tektronix background color + self.terminal._reset_tek_back_color() + case "117": + # Reset highlight color + self.terminal._reset_highlight_bg_color() + case "118": + # Reset Tektronix cursor color + self.terminal._reset_tek_cursor_color() + case "119": + # Reset highlight foreground color + self.terminal._reset_highlight_fg_color() + case _: + print(f"Unknown OSC sequence: {sequence}") + + def handle_color_palette(self, params: List[str]): + # Handle color palette changes + if len(params) == 1: + # Query color + color_index = int(params[0]) + self.terminal._query_color(color_index) + elif len(params) == 2: + # Set color + color_index = int(params[0]) + color_spec = params[1] + self.terminal._set_color(color_index, color_spec) + + def handle_special_color(self, params: List[str]): + # Handle special color changes + if len(params) == 1: + # Query special color + color_name = params[0] + self.terminal._query_special_color(color_name) + elif len(params) == 2: + # Set special color + color_name = params[0] + color_spec = params[1] + self.terminal._set_special_color(color_name, color_spec) + + def handle_hyperlink(self, params: List[str]): + # Handle hyperlink creation/update + if len(params) >= 2: + params_dict = dict( + param.split("=") for param in params[0].split(":") if "=" in param + ) + uri = params[1] + self.terminal._set_hyperlink(params_dict, uri) + + def handle_selection_data(self, params: List[str]): + # Handle selection data manipulation + if len(params) == 2: + clipboard = params[0] + data = params[1] + if data.startswith("?"): + self.terminal._query_selection_data(clipboard) + else: + decoded_data = self.base64_decode(data) + self.terminal._set_selection_data(clipboard, decoded_data) + + def base64_decode(self, data: str) -> str: + return base64.b64decode(data).decode("utf-8") + + def handle_dcs_sequence(self, sequence: str): + # Handle Device Control String sequences + # DCS sequences start with ESC P and end with ST (String Terminator, which is ESC \) + dcs_content = sequence[2:-2] # Remove ESC P at start and ESC \ at end + + if dcs_content.startswith("$q"): + # DECRQSS (Request Status String) + self.handle_decrqss(dcs_content[2:]) + elif dcs_content.startswith("+q"): + # Request Terminfo String + self.handle_request_terminfo(dcs_content[2:]) + elif dcs_content.startswith("1$r"): + # DECCIR (Cursor Information Report) + self.handle_deccir(dcs_content[3:]) + elif dcs_content.startswith("$t"): + # DECRSPS (Restore Presentation State) + self.handle_decrsps(dcs_content[2:]) + elif dcs_content.startswith(">|"): + # DECREGIS (ReGIS graphics) + self.handle_regis(dcs_content[2:]) + elif dcs_content.startswith("="): + # DECPFK (Program Function Key) + self.handle_decpfk(dcs_content[1:]) + elif dcs_content.startswith("+p"): + # DECPKA (Program Key Action) + self.handle_decpka(dcs_content[2:]) + elif dcs_content.startswith("$s"): + # DECSCA (Select Character Attributes) + self.handle_decsca(dcs_content[2:]) + elif re.match(r"\d+\*\|", dcs_content): + # DECUDK (User Defined Keys) + self.handle_decudk(dcs_content) + else: + print(f"Unknown DCS sequence: {sequence}") + + def handle_decrqss(self, param: str): + # DECRQSS - Request Status String + # The terminal should respond with a status report + if param == "m": + self.terminal._report_sgr_attributes() + elif param == "r": + self.terminal._report_margins() + # Add more DECRQSS parameters as needed + + def handle_request_terminfo(self, param: str): + # Request Terminfo String + # The terminal should respond with the requested terminfo capability + self.terminal._report_terminfo(param) + + def handle_deccir(self, param: str): + # DECCIR - Cursor Information Report + # The terminal should report cursor position and page size + self.terminal._report_cursor_info() + + def handle_decrsps(self, param: str): + # DECRSPS - Restore Presentation State + # Restores a previously saved presentation state + self.terminal._restore_presentation_state(param) + + def handle_regis(self, param: str): + # DECREGIS - ReGIS graphics + # Handles ReGIS (Remote Graphic Instruction Set) commands + self.terminal._process_regis(param) + + def handle_decpfk(self, param: str): + # DECPFK - Program Function Key + # Programs a function key + key, string = param.split("/") + self.terminal._program_function_key(key, string) + + def handle_decpka(self, param: str): + # DECPKA - Program Key Action + # Programs a key to perform a specific action + key, action = param.split("/") + self.terminal._program_key_action(key, action) + + def handle_decsca(self, param: str): + # DECSCA - Select Character Attributes + # Sets the character protection attribute + self.terminal._set_character_attributes(param) + + def handle_decudk(self, param: str): + # DECUDK - User Defined Keys + # Defines a string to be returned when a particular key is pressed + match = re.match(r"(\d+)\*\|(.*)", param) + if match: + clear_flag = match.group(1) + definitions = match.group(2).split(";") + self.terminal._define_user_keys(clear_flag, definitions) + + def handle_simple_escape_sequence(self, sequence: str): + # Handle simple two-character escape sequences + match sequence: + case "\x1bD": # Index (IND) + # Moves the cursor down one line, scrolling if necessary + self.terminal._index() + case "\x1bM": # Reverse Index (RI) + # Moves the cursor up one line, scrolling if necessary + self.terminal._reverse_index() + case "\x1bE": # Next Line (NEL) + # Moves the cursor to the beginning of the next line, scrolling if necessary + self.terminal._next_line() + case "\x1b7": # Save Cursor (DECSC) + # Saves the current cursor position, character attribute, character set, and origin mode selection + self.terminal._save_cursor() + case "\x1b8": # Restore Cursor (DECRC) + # Restores the previously saved cursor position, character attribute, character set, and origin mode selection + self.terminal._restore_cursor() + case "\x1bH": # Horizontal Tab Set (HTS) + # Sets a tab stop at the current cursor position + self.terminal._set_tab_stop() + case "\x1bN": # Single Shift Select of G2 Character Set (SS2) + # Temporarily shifts to G2 character set for the next character + self.terminal._shift_to_g2() + case "\x1bO": # Single Shift Select of G3 Character Set (SS3) + # Temporarily shifts to G3 character set for the next character + self.terminal._shift_to_g3() + case "\x1b=": # Application Keypad (DECKPAM) + # Switches the keypad to application mode + self.terminal._set_keypad_application_mode() + case "\x1b>": # Normal Keypad (DECKPNM) + # Switches the keypad to numeric mode + self.terminal._set_keypad_numeric_mode() + case "\x1bc": # Full Reset (RIS) + # Resets the terminal to its initial state + self.terminal._full_reset() + case "\x1b#3": # Double-Height Letters, Top Half (DECDHL) + # Selects top half of double-height characters + self.terminal._set_double_height_top() + case "\x1b#4": # Double-Height Letters, Bottom Half (DECDHL) + # Selects bottom half of double-height characters + self.terminal._set_double_height_bottom() + case "\x1b#5": # Single-Width Line (DECSWL) + # Sets normal single-width characters + self.terminal._set_single_width() + case "\x1b#6": # Double-Width Line (DECDWL) + # Sets double-width characters + self.terminal._set_double_width() + case "\x1b#8": # Screen Alignment Pattern (DECALN) + # Fills the screen with 'E' characters for screen focus and alignment + self.terminal._screen_alignment_test() + case _: + # Unknown escape sequence, you might want to log this or handle it in some way + print(f"Unknown escape sequence: {sequence}") + + def handle_sgr(self, params: List[str]): + # Handle Select Graphic Rendition + + for param in params: + match param: + case "0": # Reset / Normal + self.terminal._reset_attributes() + case "1": # Bold or increased intensity + self.terminal._set_bold() + case "2": # Faint, decreased intensity or second colour + self.terminal._set_faint() + case "3": # Italic + self.terminal._set_italic() + case "4": # Underline + self.terminal._set_underline() + case "5": # Slow Blink + self.terminal._set_slow_blink() + case "6": # Rapid Blink + self.terminal._set_rapid_blink() + case "7": # Reverse video + self.terminal._set_reverse_video() + case "8": # Conceal + self.terminal._set_conceal() + case "9": # Crossed-out + self.terminal._set_crossed_out() + case "10": # Primary (default) font + self.terminal._set_primary_font() + case ( + "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18" | "19" + ): # Alternative font + self.terminal._set_alternative_font(int(param) - 10) + case "20": # Fraktur (rarely used) + self.terminal._set_fraktur() + case "21": # Doubly underlined or Bold off + self.terminal._set_doubly_underlined() + case "22": # Normal colour or intensity + self.terminal._reset_intensity() + case "23": # Not italic, not Fraktur + self.terminal._reset_italic_fraktur() + case "24": # Underline off + self.terminal._reset_underline() + case "25": # Blink off + self.terminal._reset_blink() + case "27": # Inverse off + self.terminal._reset_inverse() + case "28": # Reveal (conceal off) + self.terminal._reset_conceal() + case "29": # Not crossed out + self.terminal._reset_crossed_out() + case ( + "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37" + ): # Set foreground color + self.terminal._set_foreground_color(int(param) - 30) + case "38": # Set foreground color (next arguments are 5;n or 2;r;g;b) + self.terminal._set_foreground_color_extended( + params[params.index("38") + 1 :] + ) + break + case "39": # Default foreground color + self.terminal._reset_foreground_color() + case ( + "40" | "41" | "42" | "43" | "44" | "45" | "46" | "47" + ): # Set background color + self.terminal._set_background_color(int(param) - 40) + case "48": # Set background color (next arguments are 5;n or 2;r;g;b) + self.terminal._set_background_color_extended( + params[params.index("48") + 1 :] + ) + break + case "49": # Default background color + self.terminal._reset_background_color() + case ( + "90" | "91" | "92" | "93" | "94" | "95" | "96" | "97" + ): # Set bright foreground color + self.terminal._set_bright_foreground_color(int(param) - 90) + case ( + "100" | "101" | "102" | "103" | "104" | "105" | "106" | "107" + ): # Set bright background color + self.terminal._set_bright_background_color(int(param) - 100) + case _: + if param.startswith("38;2;") or param.startswith( + "48;2;" + ): # 24-bit color + parts = param.split(";") + if len(parts) == 5: + if parts[0] == "38": + self.terminal._set_foreground_color_rgb( + int(parts[2]), int(parts[3]), int(parts[4]) + ) + elif parts[0] == "48": + self.terminal._set_background_color_rgb( + int(parts[2]), int(parts[3]), int(parts[4]) + ) + elif param.startswith("38;5;") or param.startswith( + "48;5;" + ): # 256 color + parts = param.split(";") + if len(parts) == 3: + if parts[0] == "38": + self.terminal._set_foreground_color_256(int(parts[2])) + elif parts[0] == "48": + self.terminal._set_background_color_256(int(parts[2])) + else: + print(f"Unhandled SGR parameter: {param}") diff --git a/mono/terminal.py b/mono/terminal.py index 1c499a2..b6e6193 100644 --- a/mono/terminal.py +++ b/mono/terminal.py @@ -1,35 +1,38 @@ import os -import re +import shutil import tkinter as tk from threading import Thread -from tkinter import ttk +from typing import List -if os.name == 'nt': +if os.name == "nt": from winpty import PtyProcess as PTY else: from ptyprocess import PtyProcessUnicode as PTY -from mono.ansi import OutputParser +from mono.parser import OutputParser from mono.theme import Theme from mono.utils import Scrollbar from .text import TerminalText -class Terminal(ttk.Frame): +class Terminal(tk.Frame): """Terminal abstract class. All shell types should inherit from this class. + The inherited class should implement following attributes: name (str): Name of the terminal. shell (str): command / path to shell executable. - + Args: master (tk.Tk): Main window. cwd (str): Working directory.""" - + name: str shell: str - def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, **kwargs) -> None: + def __init__( + self, master, cwd=".", theme: Theme = None, standalone=True, *args, **kwargs + ) -> None: super().__init__(master, *args, **kwargs) self.master = master self.standalone = standalone @@ -45,21 +48,28 @@ def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, * from .styles import Styles from .theme import Theme + self.theme = theme or Theme() self.style = Styles(self, self.theme) else: self.base = master.base self.theme = self.base.theme - self.text = TerminalText(self, relief=tk.FLAT, padx=10, pady=10, font=("Consolas", 11)) - self.text.config(bg=self.theme.terminal[0], fg=self.theme.terminal[1], insertbackground=self.theme.terminal[1]) + self.text = TerminalText( + self, relief=tk.FLAT, padx=10, pady=10, font=("Consolas", 11) + ) + self.text.config( + bg=self.theme.terminal[0], + fg=self.theme.terminal[1], + insertbackground=self.theme.terminal[1], + ) self.text.grid(row=0, column=0, sticky=tk.NSEW) self.text.bind("", self.enter) self.parser = OutputParser(self) self.terminal_scrollbar = Scrollbar(self, style="MonoScrollbar") - self.terminal_scrollbar.grid(row=0, column=1, sticky='NSW') + self.terminal_scrollbar.grid(row=0, column=1, sticky="NSW") self.text.config(yscrollcommand=self.terminal_scrollbar.set) self.terminal_scrollbar.config(command=self.text.yview, orient=tk.VERTICAL) @@ -68,48 +78,36 @@ def __init__(self, master, cwd=".", theme: Theme=None, standalone=True, *args, * self.text.tag_config("command", foreground="yellow") self.bind("", self.stop_service) - + def check_shell(self): - """Check if the shell is available in the system path.""" - - import shutil self.shell = shutil.which(self.shell) return self.shell - - def start_service(self, *_) -> None: - """Start the terminal service.""" + def start_service(self, *_) -> None: self.alive = True self.last_command = None - + self.p = PTY.spawn([self.shell]) Thread(target=self._write_loop, daemon=True).start() def stop_service(self, *_) -> None: - """Stop the terminal service.""" - self.alive = False def run_command(self, command: str) -> None: - """Run a command in the terminal. - TODO: Implement a queue for running multiple commands.""" + # TODO: Implement a queue for running multiple commands. self.text.insert("end", command, "command") self.enter() def clear(self) -> None: - """Clear the terminal.""" - self.text.clear() def enter(self, *_) -> None: - """Enter key event handler for running commands.""" - - command = self.text.get('input', 'end') + command = self.text.get("input", "end") self.last_command = command self.text.register_history(command) if command.strip(): - self.text.delete('input', 'end') + self.text.delete("input", "end") self.p.write(command + "\r\n") return "break" @@ -117,21 +115,166 @@ def enter(self, *_) -> None: def _write_loop(self) -> None: while self.alive: if buf := self.p.read(): - self._insert('\n'.join(self.parser.parse(buf))) - - def _insert(self, output: str, tag='') -> None: - self.text.insert(tk.END, output, tag) - #self.terminal.tag_add("prompt", "insert linestart", "insert") - self.text.see(tk.END) - self.text.mark_set('input', 'insert') - - def _newline(self): - self._insert('\n') - - # TODO: Implement a better way to handle key events. - def _ctrl_key(self, key: str) -> None: - if key == 'c': - self.run_command('\x03') - + self.text.flush_insert("".join(self.parser.parse(buf))) + + # C0 handlers + + def _bell(self): + self.bell() + + def _backspace(self): ... + def _tab(self): ... + def _newline(self): ... + def _formfeed(self): ... + def _carriage_return(self): ... + + # CSI sequence handlers + def _insert_characters(self, params: List[str]): ... + def _cursor_up(self, params: List[str]): ... + def _cursor_down(self, params: List[str]): ... + def _cursor_forward(self, params: List[str]): ... + def _cursor_backward(self, params: List[str]): ... + def _cursor_next_line(self, params: List[str]): ... + def _cursor_previous_line(self, params: List[str]): ... + def _cursor_horizontal_absolute(self, params: List[str]): ... + def _cursor_position(self, params: List[str]): ... + def _cursor_forward_tabulation(self, params: List[str]): ... + def _erase_in_display(self, params: List[str]): ... + def _erase_in_line(self, params: List[str]): ... + def _insert_lines(self, params: List[str]): ... + def _delete_lines(self, params: List[str]): ... + def _delete_characters(self, params: List[str]): ... + def _scroll_up(self, params: List[str]): ... + def _scroll_down(self, params: List[str]): ... + def _erase_characters(self, params: List[str]): ... + def _cursor_backward_tabulation(self, params: List[str]): ... + def _character_position_absolute(self, params: List[str]): ... + def _character_position_relative(self, params: List[str]): ... + def _repeat_preceding_character(self, params: List[str]): ... + def _send_device_attributes(self, params: List[str]): ... + def _line_position_absolute(self, params: List[str]): ... + def _line_position_relative(self, params: List[str]): ... + def _tab_clear(self, params: List[str]): ... + def _set_mode(self, params: List[str]): ... + def _reset_mode(self, params: List[str]): ... + def _device_status_report(self, params: List[str]): ... + def _set_keyboard_string(self, params: List[str]): ... + def _set_cursor_style(self, params: List[str]): ... + def _set_scrolling_region(self, params: List[str]): ... + def _save_cursor_position(self): ... + def _window_manipulation(self, params: List[str]): ... + def _restore_cursor_position(self): ... + def _request_terminal_parameters(self, params: List[str]): ... + def _select_locator_events(self, params: List[str]): ... + def _request_locator_position(self, params: List[str]): ... + + # OSC (Operating System Command) handlers + def _set_window_title(self, title: str): ... + def _set_x_property(self, prop: str, value: str): ... + def _set_color_mode(self, mode: str): ... + def _set_current_directory(self, directory: str): ... + def _send_notification(self, message: str): ... + def _set_cursor_color(self, color: str): ... + def _set_mouse_fore_color(self, color: str): ... + def _set_mouse_back_color(self, color: str): ... + def _set_tek_fore_color(self, color: str): ... + def _set_tek_back_color(self, color: str): ... + def _set_highlight_bg_color(self, color: str): ... + def _set_tek_cursor_color(self, color: str): ... + def _set_highlight_fg_color(self, color: str): ... + def _set_log_file(self, file_path: str): ... + def _set_font(self, font_string: str): ... + def _set_emoji_font(self, font_string: str): ... + def _query_color(self, color_index: int): ... + def _set_color(self, color_index: int, color_spec: str): ... + def _query_special_color(self, color_name: str): ... + def _set_special_color(self, color_name: str, color_spec: str): ... + def _set_hyperlink(self, params: dict, uri: str): ... + def _query_selection_data(self, clipboard: str): ... + def _set_selection_data(self, clipboard: str, data: str): ... + def _reset_color_palette(self): ... + def _reset_special_color(self, color_name: str): ... + def _reset_foreground_color(self): ... + def _reset_background_color(self): ... + def _reset_cursor_color(self): ... + def _reset_cursor_color(self): ... + def _reset_mouse_fore_color(self): ... + def _reset_mouse_back_color(self): ... + def _reset_tek_fore_color(self): ... + def _reset_tek_back_color(self): ... + def _reset_highlight_bg_color(self): ... + def _reset_tek_cursor_color(self): ... + def _reset_highlight_fg_color(self): ... + + # DCS (Device Control String) handlers + def _report_sgr_attributes(self, params: List[str]): ... + def _report_margins(self): ... + def _report_terminfo(self, params: List[str]): ... + def _report_cursor_info(self): ... + def _restore_presentation_state(self): ... + def _process_regis(self): ... + def _program_function_key(self): ... + def _program_key_action(self): ... + def _set_character_attributes(self): ... + def _define_user_keys(self): ... + + # Simple escape sequence handlers + def _index(self): ... + def _reverse_index(self): ... + def _next_line(self): ... + def _save_cursor(self): ... + def _restore_cursor(self): ... + def _set_tab_stop(self): ... + def _shift_to_g2(self): ... + def _shift_to_g3(self): ... + def _set_keypad_application_mode(self): ... + def _set_keypad_numeric_mode(self): ... + def _full_reset(self): ... + def _set_double_height_top(self): ... + def _set_double_height_bottom(self): ... + def _set_single_width(self): ... + def _set_double_width(self): ... + def _screen_alignment_test(self): ... + def _scroll_up(self): ... + def _scroll_down(self): ... + + # SGR (Select Graphic Rendition) handlers + def _reset_attributes(self): ... + def _set_bold(self): ... + def _set_faint(self): ... + def _set_italic(self): ... + def _set_underline(self): ... + def _set_slow_blink(self): ... + def _set_rapid_blink(self): ... + def _set_reverse_video(self): ... + def _set_conceal(self): ... + def _set_crossed_out(self): ... + def _set_primary_font(self): ... + def _set_alternative_font(self, font_number): ... + def _set_fraktur(self): ... + def _set_doubly_underlined(self): ... + def _reset_intensity(self): ... + def _reset_italic_fraktur(self): ... + def _reset_underline(self): ... + def _reset_blink(self): ... + def _reset_inverse(self): ... + def _reset_conceal(self): ... + def _reset_crossed_out(self): ... + def _set_foreground_color(self, color_code): ... + def _set_background_color(self, color_code): ... + def _set_foreground_color_extended(self, params): ... + def _set_background_color_extended(self, params): ... + def _reset_foreground_color(self): ... + def _reset_background_color(self): ... + def _set_bright_foreground_color(self, color_code): ... + def _set_bright_background_color(self, color_code): ... + def _set_foreground_color_rgb(self, r, g, b): ... + def _set_background_color_rgb(self, r, g, b): ... + def _set_foreground_color_256(self, color_code): ... + def _set_background_color_256(self, color_code): ... + + # C1 handlers + def _ctrl_key(self, key: str) -> None: ... + def __str__(self) -> str: return self.name diff --git a/mono/test.py b/mono/test.py new file mode 100644 index 0000000..24ab1df --- /dev/null +++ b/mono/test.py @@ -0,0 +1,9 @@ +import sys + +for i in range(11): + for j in range(10): + n = 10 * i + j + if n > 108: + break + sys.stdout.write("\033[%dm %3d\033[m" % (n, n)) + sys.stdout.write("\n") diff --git a/mono/text.py b/mono/text.py index e025a5b..bb22a17 100644 --- a/mono/text.py +++ b/mono/text.py @@ -4,32 +4,47 @@ class TerminalText(tk.Text): """Text widget used to display the terminal output and to get the user input. - Limits the editable area to text after the input mark and prevents deletion + Limits the editable area to text after the input mark and prevents deletion before the input mark. Also, it keeps a history of previously used commands. Args: master (tkinter.Tk, optional): The parent widget. - proxy_enabled (bool, optional): Whether the proxy is enabled. Defaults to True.""" - - def __init__(self, master=None, proxy_enabled: bool=True, **kw) -> None: + proxy_enabled (bool, optional): Whether the proxy is enabled. Defaults to True. + """ + + def __init__(self, master=None, proxy_enabled: bool = True, **kw) -> None: super().__init__(master, **kw) self.master = master - - self.mark_set('input', 'insert') - self.mark_gravity('input', 'left') + + self.mark_set("input", "insert") + self.mark_gravity("input", "left") self.proxy_enabled = proxy_enabled self.config(highlightthickness=0) self._history = [] self._history_level = 0 - self.bind('', self.history_up) - self.bind('', self.history_down) + self.bind("", self.history_up) + self.bind("", self.history_down) self._orig = self._w + "_orig" self.tk.call("rename", self._w, self._orig) self.tk.createcommand(self._w, self._proxy) + def _reset_input(self): + self.mark_set("input", "insert") + + def flush_insert(self, output: str, tag="") -> None: + self.insert(tk.END, output, tag) + # self.terminal.tag_add("prompt", "insert linestart", "insert") + self.see(tk.END) + self._reset_input() + + def flush_delete(self, start: str, end: str) -> None: + self.delete(start, end) + self.see(tk.END) + self._reset_input() + def history_up(self, *_) -> None: """moves up the history and displays it""" @@ -37,9 +52,9 @@ def history_up(self, *_) -> None: return "break" self._history_level = max(self._history_level - 1, 0) - self.mark_set('insert', 'input') - self.delete('input', 'end') - self.insert('input', self._history[self._history_level]) + self.mark_set("insert", "input") + self.delete("input", "end") + self.insert("input", self._history[self._history_level]) return "break" @@ -50,9 +65,9 @@ def history_down(self, *_) -> None: return "break" self._history_level = min(self._history_level + 1, len(self._history) - 1) - self.mark_set('insert', 'input') - self.delete('input', 'end') - self.insert('input', self._history[self._history_level]) + self.mark_set("insert", "input") + self.delete("input", "end") + self.insert("input", self._history[self._history_level]) return "break" @@ -60,7 +75,9 @@ def register_history(self, command: str) -> None: """registers a command in the history""" # don't register empty commands or duplicates - if command.strip() and (not self._history or command.strip() != self._history[-1]): + if command.strip() and ( + not self._history or command.strip() != self._history[-1] + ): self._history.append(command.strip()) self._history_level = len(self._history) @@ -69,10 +86,22 @@ def clear(self, *_) -> None: self.proxy_enabled = False - lastline = self.get('input linestart', 'input') - self.delete('1.0', 'end') - self.insert('end', lastline) + lastline = self.get("input linestart", "input") + self.delete("1.0", "end") + self.insert("end", lastline) + + self.proxy_enabled = True + + def _reset_input(self) -> None: + """resets the input mark""" + + self.mark_set("input", "insert") + def do(self, command, *args, **kwargs) -> None: + """performs the action""" + + self.proxy_enabled = False + command(*args, **kwargs) self.proxy_enabled = True def _proxy(self, *args) -> None: @@ -84,17 +113,17 @@ def _proxy(self, *args) -> None: try: largs = list(args) - if args[0] == 'insert': - if self.compare('insert', '<', 'input'): - self.mark_set('insert', 'end') + if args[0] == "insert": + if self.compare("insert", "<", "input"): + self.mark_set("insert", "end") elif args[0] == "delete": - if self.compare(largs[1], '<', 'input'): + if self.compare(largs[1], "<", "input"): if len(largs) == 2: return - largs[1] = 'input' - + largs[1] = "input" + result = self.tk.call((self._orig,) + tuple(largs)) return result except: - # most probably some tkinter-unhandled exception + # most probably some tkinter-unhandled exception pass