""" Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors, full keyboard mapping, diff-based rendering, and cursor display. """ import tkinter as tk import tkinter.font as tkfont import pyte # 16 standard ANSI colors _ANSI_COLORS = { "black": "#000000", "red": "#cc0000", "green": "#4e9a06", "brown": "#c4a000", "blue": "#3465a4", "magenta": "#75507b", "cyan": "#06989a", "white": "#d3d7cf", "brightblack": "#555753", "brightred": "#ef2929", "brightgreen": "#8ae234", "brightbrown": "#fce94f", "brightblue": "#729fcf", "brightmagenta": "#ad7fa8", "brightcyan": "#34e2e2", "brightwhite": "#eeeeec", } _DEFAULT_FG = "#d3d7cf" _DEFAULT_BG = "#1a1a2e" _CURSOR_FG = "#1a1a2e" _CURSOR_BG = "#d3d7cf" # Key → VT100 escape sequence _KEY_MAP = { "Up": "\x1b[A", "Down": "\x1b[B", "Right": "\x1b[C", "Left": "\x1b[D", "Home": "\x1b[H", "End": "\x1b[F", "Insert": "\x1b[2~", "Delete": "\x1b[3~", "Prior": "\x1b[5~", # PageUp "Next": "\x1b[6~", # PageDown "F1": "\x1bOP", "F2": "\x1bOQ", "F3": "\x1bOR", "F4": "\x1bOS", "F5": "\x1b[15~", "F6": "\x1b[17~", "F7": "\x1b[18~", "F8": "\x1b[19~", "F9": "\x1b[20~", "F10": "\x1b[21~", "F11": "\x1b[23~", "F12": "\x1b[24~", } class TerminalWidget(tk.Frame): """VT100 terminal emulator widget using pyte + tkinter.Text.""" def __init__(self, master, cols=80, rows=24, send_callback=None, resize_callback=None, **kwargs): super().__init__(master, bg=_DEFAULT_BG, **kwargs) self.send_callback = send_callback self.resize_callback = resize_callback self._cols = cols self._rows = rows # pyte screen + stream self._screen = pyte.Screen(cols, rows) self._screen.set_mode(pyte.modes.LNM) self._stream = pyte.Stream(self._screen) # Previous buffer for diff rendering self._prev_buffer: dict[int, list] = {} # Font self._font = tkfont.Font(family="Consolas", size=11) self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold") # Text widget self._text = tk.Text( self, bg=_DEFAULT_BG, fg=_DEFAULT_FG, font=self._font, insertwidth=0, highlightthickness=0, borderwidth=0, padx=4, pady=4, wrap="none", cursor="xterm", ) self._text.pack(fill="both", expand=True) # Create color tags self._setup_tags() # Keyboard bindings self._text.bind("", self._on_key) self._text.bind("", self._on_ctrl_c) self._text.bind("", self._on_ctrl_v) self._text.bind("", lambda e: "break") self._text.bind("", self._on_ctrl_d) self._text.bind("", self._on_ctrl_l) self._text.bind("", self._on_ctrl_z) self._text.bind("", self._on_mousewheel) # Resize handling self._resize_after_id = None self._text.bind("", self._on_configure) # Focus on click self._text.bind("", lambda e: self._text.focus_set()) # Make text read-only (input goes through _on_key) self._text.bind("<>", lambda e: "break") # Status bar self._status_frame = tk.Frame(self, bg="#2d2d44", height=22) self._status_frame.pack(fill="x", side="bottom") self._status_frame.pack_propagate(False) self._status_label = tk.Label( self._status_frame, text="", fg="#888888", bg="#2d2d44", font=("Consolas", 9), anchor="w", padx=6, ) self._status_label.pack(fill="x") def _setup_tags(self): """Pre-create tags for all ANSI color combinations.""" for name, color in _ANSI_COLORS.items(): self._text.tag_configure(f"fg_{name}", foreground=color) self._text.tag_configure(f"bg_{name}", background=color) self._text.tag_configure("bold", font=self._bold_font) self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic")) self._text.tag_configure("underline", underline=True) self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG) self._text.tag_configure("default", foreground=_DEFAULT_FG) def set_status(self, text: str, color: str = "#888888"): self._status_label.configure(text=text, fg=color) def feed(self, data: bytes): """Feed raw bytes from SSH into pyte and re-render.""" try: text = data.decode("utf-8", errors="replace") except Exception: text = data.decode("latin-1", errors="replace") self._stream.feed(text) self._render() def reset(self): """Reset the terminal screen.""" self._screen.reset() self._prev_buffer.clear() self._render() def focus_terminal(self): self._text.focus_set() def get_size(self) -> tuple[int, int]: """Return (cols, rows) based on current widget size.""" return self._cols, self._rows def _render(self): """Diff-based rendering: only update changed lines.""" self._text.configure(state="normal") screen = self._screen cursor = screen.cursor for row in range(screen.lines): line = screen.buffer[row] # Build list of (char, attrs) for the row current = [] for col in range(screen.columns): char_data = line[col] current.append((char_data.data, char_data.fg, char_data.bg, char_data.bold, char_data.italics, char_data.underscore)) prev = self._prev_buffer.get(row) is_cursor_row = (row == cursor.y) prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row if current == prev and not is_cursor_row and not prev_was_cursor_row: continue self._prev_buffer[row] = current # Delete the line line_start = f"{row + 1}.0" line_end = f"{row + 1}.end" self._text.delete(line_start, line_end) # Ensure enough lines exist while int(self._text.index("end-1c").split(".")[0]) <= row: self._text.insert("end", "\n") # Build line content with batched tags col = 0 while col < len(current): ch, fg, bg, bold, italic, underline = current[col] # Batch consecutive chars with same attributes batch = [ch] while col + len(batch) < len(current): nc = current[col + len(batch)] if nc[1:] == (fg, bg, bold, italic, underline): batch.append(nc[0]) else: break text = "".join(batch) tags = self._make_tags(fg, bg, bold, italic, underline) # Apply cursor tag if is_cursor_row: if col <= cursor.x < col + len(batch): # Split at cursor position pre_len = cursor.x - col if pre_len > 0: self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags) cursor_tags = ("cursor",) + tags self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags) post = text[pre_len + 1:] if post: self._text.insert(f"{row + 1}.{cursor.x + 1}", post, tags) col += len(batch) continue self._text.insert(f"{row + 1}.{col}", text, tags) col += len(batch) # Trim excess lines total_lines = int(self._text.index("end-1c").split(".")[0]) if total_lines > screen.lines: self._text.delete(f"{screen.lines + 1}.0", "end") self._prev_cursor_y = cursor.y self._text.configure(state="disabled") def _make_tags(self, fg, bg, bold, italic, underline) -> tuple: tags = [] fg_color = self._resolve_color(fg, is_fg=True) if fg_color: tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None if tag and tag.replace("fg_", "") in _ANSI_COLORS: tags.append(tag) bg_color = self._resolve_color(bg, is_fg=False) if bg_color: tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None if tag and tag.replace("bg_", "") in _ANSI_COLORS: tags.append(tag) if bold: tags.append("bold") if italic: tags.append("italic") if underline: tags.append("underline") return tuple(tags) def _resolve_color(self, color, is_fg=True): if not color or color == "default": return None if color in _ANSI_COLORS: return color # pyte may return color names like "red", "green" etc directly return None def _on_key(self, event): """Handle keyboard input and send to SSH.""" # Ignore modifier-only keys if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", "Meta_L", "Meta_R", "Caps_Lock", "Num_Lock"): return "break" # Ctrl+key combinations if event.state & 0x4: # Control if event.keysym.lower() in ("c", "v", "d", "l", "z"): return # handled by specific bindings ch = event.keysym.lower() if len(ch) == 1 and "a" <= ch <= "z": self._send(bytes([ord(ch) - ord("a") + 1])) return "break" # Special keys if event.keysym in _KEY_MAP: self._send(_KEY_MAP[event.keysym].encode()) return "break" # Tab if event.keysym == "Tab": self._send(b"\t") return "break" # Return / Enter if event.keysym in ("Return", "KP_Enter"): self._send(b"\r") return "break" # Backspace if event.keysym == "BackSpace": self._send(b"\x7f") return "break" # Escape if event.keysym == "Escape": self._send(b"\x1b") return "break" # Regular character if event.char and ord(event.char) >= 32: self._send(event.char.encode("utf-8")) return "break" return "break" def _on_ctrl_c(self, event): # Check if text is selected — if so, copy try: sel = self._text.get("sel.first", "sel.last") if sel: self.clipboard_clear() self.clipboard_append(sel) return "break" except tk.TclError: pass # No selection — send SIGINT self._send(b"\x03") return "break" def _on_ctrl_v(self, event): try: text = self.clipboard_get() if text: self._send(text.encode("utf-8")) except tk.TclError: pass return "break" def _on_ctrl_d(self, event): self._send(b"\x04") return "break" def _on_ctrl_l(self, event): self._send(b"\x0c") return "break" def _on_ctrl_z(self, event): self._send(b"\x1a") return "break" def _on_mousewheel(self, event): # Send scroll sequences for programs like less/man if event.delta > 0: self._send(b"\x1b[5~") # PageUp else: self._send(b"\x1b[6~") # PageDown return "break" def _send(self, data: bytes): if self.send_callback: self.send_callback(data) def _on_configure(self, event): """Debounced resize handler.""" if self._resize_after_id: self.after_cancel(self._resize_after_id) self._resize_after_id = self.after(200, self._do_resize) def _do_resize(self): self._resize_after_id = None w = self._text.winfo_width() h = self._text.winfo_height() if w < 20 or h < 20: return char_w = self._font.measure("M") char_h = self._font.metrics("linespace") if char_w <= 0 or char_h <= 0: return new_cols = max(20, (w - 8) // char_w) new_rows = max(4, (h - 8) // char_h) if new_cols != self._cols or new_rows != self._rows: self._cols = new_cols self._rows = new_rows self._screen.resize(new_rows, new_cols) self._prev_buffer.clear() self._render() if self.resize_callback: self.resize_callback(new_cols, new_rows)