""" Terminal widget — pyte VT100 emulator + tkinter.Text with full xterm compatibility: 256-color, truecolor, reverse video, alternate screen buffer, DECCKM, bracketed paste, mouse tracking, and professional copy/paste UX. """ import codecs import copy import sys import time import tkinter as tk import tkinter.font as tkfont import pyte # ── 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" # ── Private mode constants (pyte stores private modes shifted <<5) ───────── _DECCKM = 1 << 5 # Application cursor keys _BRACKETED_PASTE = 2004 << 5 # Bracketed paste mode _MOUSE_BASIC = 1000 << 5 # Basic mouse tracking _MOUSE_BTN_TRACK = 1002 << 5 # Button-event mouse tracking _MOUSE_ANY = 1003 << 5 # Any-event mouse tracking _MOUSE_SGR = 1006 << 5 # SGR extended mouse encoding # ── Key maps ─────────────────────────────────────────────────────────────── _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~", "Next": "\x1b[6~", "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~", } _APP_CURSOR_MAP = { "Up": "\x1bOA", "Down": "\x1bOB", "Right": "\x1bOC", "Left": "\x1bOD", "Home": "\x1bOH", "End": "\x1bOF", } _ARROW_LETTER = {"Up": "A", "Down": "B", "Right": "C", "Left": "D"} _HOMEEND_LETTER = {"Home": "H", "End": "F"} _KP_MAP = { "KP_0": "0", "KP_1": "1", "KP_2": "2", "KP_3": "3", "KP_4": "4", "KP_5": "5", "KP_6": "6", "KP_7": "7", "KP_8": "8", "KP_9": "9", "KP_Add": "+", "KP_Subtract": "-", "KP_Multiply": "*", "KP_Divide": "/", "KP_Decimal": ".", "KP_Separator": ",", } def _xterm_modifier(state: int) -> int: """Convert tkinter event.state bitmask to xterm modifier parameter.""" mod = 1 if state & 0x1: mod += 1 # Shift if state & 0x20000: mod += 2 # Alt (Windows) if state & 0x4: mod += 4 # Control return mod # ── Font fallback ───────────────────────────────────────────────────────── _FONT_PREFERENCES = { "win32": ["Cascadia Mono", "Consolas", "Courier New"], "darwin": ["Menlo", "Monaco", "Courier"], "linux": ["DejaVu Sans Mono", "Liberation Mono", "Monospace"], } def _pick_font_family() -> str: """Pick the best available monospace font for the current platform.""" candidates = _FONT_PREFERENCES.get(sys.platform, _FONT_PREFERENCES["linux"]) try: available = set(tkfont.families()) for name in candidates: if name in available: return name except Exception: pass # Ultimate fallback return "Consolas" if sys.platform == "win32" else "Courier" # ── Alternate screen buffer ──────────────────────────────────────────────── class _Screen(pyte.Screen): """pyte.Screen extended with alternate buffer, write_process_input bridge.""" _ALT_MODES = frozenset([47 << 5, 1047 << 5, 1049 << 5]) def __init__(self, columns: int, lines: int): super().__init__(columns, lines) self._write_cb = None # set by TerminalWidget → _send self._main_buffer = None self._main_cursor = None self._in_alt = False # Bridge DA / DSR responses back to SSH def write_process_input(self, data: str): if self._write_cb: self._write_cb(data.encode("utf-8")) def set_mode(self, *modes, **kwargs): super().set_mode(*modes, **kwargs) if kwargs.get("private"): if {m << 5 for m in modes} & self._ALT_MODES and not self._in_alt: self._enter_alt() def reset_mode(self, *modes, **kwargs): super().reset_mode(*modes, **kwargs) if kwargs.get("private"): if {m << 5 for m in modes} & self._ALT_MODES and self._in_alt: self._exit_alt() def _enter_alt(self): self._in_alt = True self._main_buffer = copy.deepcopy(dict(self.buffer)) self._main_cursor = (self.cursor.x, self.cursor.y, self.cursor.attrs, self.cursor.hidden) self.buffer.clear() self.cursor_position() self.dirty.update(range(self.lines)) def _exit_alt(self): self._in_alt = False if self._main_buffer is not None: self.buffer.clear() self.buffer.update(self._main_buffer) self._main_buffer = None if self._main_cursor is not None: x, y, attrs, hidden = self._main_cursor self.cursor.x, self.cursor.y = x, y self.cursor.attrs = attrs self.cursor.hidden = hidden self._main_cursor = None self.dirty.update(range(self.lines)) # ── Terminal Widget ──────────────────────────────────────────────────────── class TerminalWidget(tk.Frame): """Full-featured VT100/xterm terminal emulator widget.""" def __init__(self, master, cols=80, rows=24, send_callback=None, resize_callback=None, font_size=None, on_font_size_changed=None, **kwargs): super().__init__(master, bg=_DEFAULT_BG, **kwargs) self.send_callback = send_callback self.resize_callback = resize_callback self.on_font_size_changed = on_font_size_changed self._cols = cols self._rows = rows # pyte screen + stream self._screen = _Screen(cols, rows) self._screen._write_cb = self._send self._stream = pyte.Stream(self._screen) # Incremental UTF-8 decoder (handles split multi-byte sequences) self._utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace") # Rendering state self._prev_buffer: dict[int, list] = {} self._prev_cursor_y: int = 0 self._prev_cursor_hidden: bool = False self._render_pending = False self._render_after_id = None # Dynamic color tag cache self._dynamic_tags: dict[str, bool] = {} # Fonts (with fallback + zoom support) self._font_family = _pick_font_family() self._default_font_size = 11 self._font_size = max(6, min(28, font_size or self._default_font_size)) self._font = tkfont.Font(family=self._font_family, size=self._font_size) self._bold_font = tkfont.Font(family=self._font_family, size=self._font_size, 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) 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("", self._on_ctrl_d) self._text.bind("", self._on_ctrl_l) self._text.bind("", self._on_ctrl_z) # Professional copy/paste: Ctrl+Shift+C/V always copy/paste self._text.bind("", self._on_copy) self._text.bind("", self._on_ctrl_v) self._text.bind("<>", lambda e: "break") # Handle Ctrl+key by keycode — works with ANY keyboard layout self._text.bind("", self._on_ctrl_key) # ── Mouse bindings ── self._text.bind("", self._on_mouse_press) self._text.bind("", self._on_mouse_release) self._text.bind("", self._on_mouse_motion) self._text.bind("", self._on_right_click) self._text.bind("", self._on_mousewheel) self._text.bind("", self._on_double_click) # ── Zoom bindings (Ctrl+Plus/Minus/0, numpad) ── self._text.bind("", self._on_zoom_in) self._text.bind("", self._on_zoom_in) self._text.bind("", self._on_zoom_out) self._text.bind("", self._on_zoom_reset) self._text.bind("", self._on_zoom_in) self._text.bind("", self._on_zoom_out) # ── Resize ── self._resize_after_id = None self._text.bind("", self._on_configure) # ── Selection tracking for copy ── self._selecting = False self._last_ctrl_c: float = 0.0 # ── Flash status state ── self._flash_after_id = None self._saved_status_text = "" self._saved_status_color = "#888888" # ── 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=(self._font_family, 9), anchor="w", padx=6, ) self._status_label.pack(fill="x") # ── Tag setup ────────────────────────────────────────────────────────── def _setup_tags(self): 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=self._font_family, size=self._font_size, slant="italic")) self._text.tag_configure("underline", underline=True) self._text.tag_configure("strikethrough", overstrike=True) self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG) self._text.tag_configure("default", foreground=_DEFAULT_FG) self._text.tag_configure("selection_highlight", background="#44447a", foreground="#ffffff") # ── Public API ───────────────────────────────────────────────────────── def set_status(self, text: str, color: str = "#888888"): self._status_label.configure(text=text, fg=color) self._saved_status_text = text self._saved_status_color = color def _flash_status(self, text: str, color: str, duration: int = 1000): """Briefly show a flash message in the status bar, then restore.""" if self._flash_after_id: self.after_cancel(self._flash_after_id) self._status_label.configure(text=text, fg=color) self._flash_after_id = self.after(duration, self._restore_status) def _restore_status(self): self._flash_after_id = None self._status_label.configure(text=self._saved_status_text, fg=self._saved_status_color) # ── Zoom ────────────────────────────────────────────────────────────── def _set_font_size(self, size: int): size = max(6, min(28, size)) if size == self._font_size: return self._font_size = size self._font.configure(size=size) self._bold_font.configure(size=size) # Rebuild italic tag with new size self._text.tag_configure("italic", font=tkfont.Font(family=self._font_family, size=size, slant="italic")) # Force full re-render self._prev_buffer.clear() self._render() # Recalculate cols/rows for new font metrics self._do_resize() # Notify for persistence if self.on_font_size_changed: self.on_font_size_changed(size) def _on_zoom_in(self, event=None): self._set_font_size(self._font_size + 1) return "break" def _on_zoom_out(self, event=None): self._set_font_size(self._font_size - 1) return "break" def _on_zoom_reset(self, event=None): self._set_font_size(self._default_font_size) return "break" def feed(self, data: bytes): """Feed raw bytes from SSH into pyte, schedule debounced render.""" text = self._utf8_decoder.decode(data) if not text: return self._stream.feed(text) if not self._render_pending: self._render_pending = True self._render_after_id = self.after(16, self._debounced_render) def _debounced_render(self): self._render_pending = False self._render_after_id = None self._render() def reset(self): 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 self._cols, self._rows def get_current_buffer(self) -> bytes: """Export full pyte screen state as serialized data for session pool.""" import pickle from pyte.screens import Char screen_state = { 'buffer': {}, 'cursor_x': self._screen.cursor.x, 'cursor_y': self._screen.cursor.y, 'cursor_attrs': self._screen.cursor.attrs, 'cursor_hidden': self._screen.cursor.hidden, 'mode': self._screen.mode.copy(), 'columns': self._screen.columns, 'lines': self._screen.lines, } for y in range(self._screen.lines): line_dict = {} for x in range(self._screen.columns): c = self._screen.buffer[y][x] # Char is a namedtuple — store all fields line_dict[x] = { 'data': c.data, 'fg': c.fg, 'bg': c.bg, 'bold': c.bold, 'italics': c.italics, 'underscore': c.underscore, 'reverse': c.reverse, 'strikethrough': c.strikethrough, 'blink': c.blink, } screen_state['buffer'][y] = line_dict return pickle.dumps(screen_state) def restore_buffer(self, buffer_data: bytes): """Restore full pyte screen state from serialized data.""" import pickle from pyte.screens import Char try: state = pickle.loads(buffer_data) if not isinstance(state, dict) or 'buffer' not in state: return cols, rows = self._screen.columns, self._screen.lines self._screen.reset() self._screen.resize(rows, cols) # Char is a namedtuple — must replace entire objects for y, line_dict in state['buffer'].items(): if y >= self._screen.lines: continue for x, cd in line_dict.items(): if x >= self._screen.columns: continue self._screen.buffer[y][x] = Char( data=cd['data'], fg=cd['fg'], bg=cd['bg'], bold=cd['bold'], italics=cd['italics'], underscore=cd['underscore'], reverse=cd['reverse'], strikethrough=cd['strikethrough'], blink=cd.get('blink', False), ) self._screen.cursor.x = state.get('cursor_x', 0) self._screen.cursor.y = state.get('cursor_y', 0) if 'cursor_attrs' in state: self._screen.cursor.attrs = state['cursor_attrs'] if 'cursor_hidden' in state: self._screen.cursor.hidden = state['cursor_hidden'] if 'mode' in state: self._screen.mode.clear() self._screen.mode.update(state['mode']) # Force full redraw self._screen.dirty.update(range(self._screen.lines)) self._prev_buffer.clear() self._render() except Exception: self.reset() # ── Rendering ────────────────────────────────────────────────────────── def _render(self): self._text.configure(state="normal") screen = self._screen cursor = screen.cursor dirty = screen.dirty.copy() screen.dirty.clear() cursor_hidden = cursor.hidden cursor_hidden_changed = cursor_hidden != self._prev_cursor_hidden for row in range(screen.lines): line = screen.buffer[row] # Build cell data: (char, fg, bg, bold, italic, underline, reverse, strike) current = [] for c in range(screen.columns): cd = line[c] current.append((cd.data, cd.fg, cd.bg, cd.bold, cd.italics, cd.underscore, cd.reverse, cd.strikethrough)) prev = self._prev_buffer.get(row) is_cursor_row = (row == cursor.y) was_cursor_row = (row == self._prev_cursor_y) # Skip unchanged lines (unless cursor moved in/out) if (row not in dirty and current == prev and not is_cursor_row and not was_cursor_row and not cursor_hidden_changed): continue self._prev_buffer[row] = current line_start = f"{row + 1}.0" self._text.delete(line_start, f"{row + 1}.end") # Ensure line exists while int(self._text.index("end-1c").split(".")[0]) <= row: self._text.insert("end", "\n") # Batch consecutive chars with same attrs col = 0 while col < len(current): ch, fg, bg, bold, italic, ul, rev, strike = current[col] batch = [ch] while col + len(batch) < len(current): nc = current[col + len(batch)] if nc[1:] == (fg, bg, bold, italic, ul, rev, strike): batch.append(nc[0]) else: break text = "".join(batch) tags = self._make_tags(fg, bg, bold, italic, ul, rev, strike) # Cursor overlay if is_cursor_row and not cursor_hidden: if col <= cursor.x < col + len(batch): pre = cursor.x - col if pre > 0: self._text.insert(f"{row+1}.{col}", text[:pre], tags) self._text.insert(f"{row+1}.{cursor.x}", text[pre:pre+1], ("cursor",)+tags) post = text[pre+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 = int(self._text.index("end-1c").split(".")[0]) if total > screen.lines: self._text.delete(f"{screen.lines + 1}.0", "end") self._prev_cursor_y = cursor.y self._prev_cursor_hidden = cursor_hidden # Keep text widget in "normal" state — all input is handled # by key bindings returning "break", so no user editing is possible. # "disabled" state breaks mouse selection on Windows. def _make_tags(self, fg, bg, bold, italic, underline, reverse, strikethrough) -> tuple: # Reverse video: swap fg/bg if reverse: fg, bg = bg, fg if not fg or fg == "default": fg = None if not bg or bg == "default": bg = None tags = [] t = self._color_tag(fg, is_fg=True) if t: tags.append(t) t = self._color_tag(bg, is_fg=False) if t: tags.append(t) if bold: tags.append("bold") if italic: tags.append("italic") if underline: tags.append("underline") if strikethrough: tags.append("strikethrough") return tuple(tags) def _color_tag(self, color, is_fg: bool) -> str | None: """Resolve any pyte color → tkinter tag, creating dynamic tags as needed.""" if not color or color == "default": return None prefix = "fg" if is_fg else "bg" # Named ANSI color if color in _ANSI_COLORS: return f"{prefix}_{color}" # 256-color / truecolor: pyte gives hex string like "ff0000" if isinstance(color, str) and len(color) == 6: tag = f"{prefix}_#{color}" if tag not in self._dynamic_tags: if len(self._dynamic_tags) > 4096: self._dynamic_tags.clear() hx = f"#{color}" if is_fg: self._text.tag_configure(tag, foreground=hx) else: self._text.tag_configure(tag, background=hx) self._dynamic_tags[tag] = True return tag return None # ── Keyboard handling ────────────────────────────────────────────────── def _on_key(self, event): # 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", "Scroll_Lock", "Win_L", "Win_R", "Super_L", "Super_R"): return "break" state = event.state ctrl = bool(state & 0x4) shift = bool(state & 0x1) alt = bool(state & 0x20000) # ── Ctrl+key ── if ctrl: ks = event.keysym.lower() # Zoom keys — intercept, don't send to SSH if event.keysym in ("plus", "equal", "KP_Add"): self._on_zoom_in(); return "break" if event.keysym in ("minus", "KP_Subtract"): self._on_zoom_out(); return "break" if event.keysym == "0": self._on_zoom_reset(); return "break" # Handled by dedicated bindings if ks in ("c", "v", "d", "l", "z"): # Ctrl+Shift+C/V → copy/paste (handled separately) if shift and ks in ("c", "v"): return return # Ctrl+special chars if ks == "bracketleft": self._send(b"\x1b"); return "break" if ks == "backslash": self._send(b"\x1c"); return "break" if ks == "bracketright": self._send(b"\x1d"); return "break" if ks == "space": self._send(b"\x00"); return "break" # Ctrl+a..z → \x01..\x1a if len(ks) == 1 and "a" <= ks <= "z": self._send(bytes([ord(ks) - ord("a") + 1])) return "break" # ── Alt+key ── if alt and not ctrl: ks = event.keysym if ks in _KEY_MAP: decckm = _DECCKM in self._screen.mode if decckm and ks in _APP_CURSOR_MAP: self._send(b"\x1b" + _APP_CURSOR_MAP[ks].encode()) else: self._send(b"\x1b" + _KEY_MAP[ks].encode()) return "break" if event.char and ord(event.char) >= 32: self._send(b"\x1b" + event.char.encode("utf-8")) return "break" if len(ks) == 1: self._send(b"\x1b" + ks.encode("utf-8")) return "break" # ── Special keys (with modifier support) ── if event.keysym in _KEY_MAP: decckm = _DECCKM in self._screen.mode modifier = _xterm_modifier(state) if modifier > 1: ks = event.keysym if ks in _ARROW_LETTER: self._send(f"\x1b[1;{modifier}{_ARROW_LETTER[ks]}".encode()) elif ks in _HOMEEND_LETTER: self._send(f"\x1b[1;{modifier}{_HOMEEND_LETTER[ks]}".encode()) else: seq = _KEY_MAP[ks] if seq.endswith("~"): code = seq[2:-1] self._send(f"\x1b[{code};{modifier}~".encode()) else: self._send(seq.encode()) else: if decckm and event.keysym in _APP_CURSOR_MAP: self._send(_APP_CURSOR_MAP[event.keysym].encode()) else: self._send(_KEY_MAP[event.keysym].encode()) return "break" # ── Shift+Tab (backtab) ── if event.keysym == "ISO_Left_Tab" or (event.keysym == "Tab" and shift): self._send(b"\x1b[Z") return "break" # Tab if event.keysym == "Tab": self._send(b"\t"); return "break" # 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" # Numpad if event.keysym in _KP_MAP: self._send(_KP_MAP[event.keysym].encode()); return "break" # Regular character if event.char and ord(event.char) >= 32: self._send(event.char.encode("utf-8")) return "break" return "break" # ── Ctrl+Key by keycode — works with any keyboard layout ── _CTRL_KEYCODE_MAP = { 67: '_on_ctrl_c', # C 86: '_on_ctrl_v', # V 68: '_on_ctrl_d', # D 76: '_on_ctrl_l', # L 90: '_on_ctrl_z', # Z } def _on_ctrl_key(self, event): """Route Ctrl+key by physical keycode (layout-independent).""" handler_name = self._CTRL_KEYCODE_MAP.get(event.keycode) if handler_name: # Check if Shift is also held if event.state & 0x1 and event.keycode == 67: return self._on_copy(event) if event.state & 0x1 and event.keycode == 86: return self._on_ctrl_v(event) return getattr(self, handler_name)(event) return None # let other bindings handle it # ── Ctrl+C: copy or SIGINT (double-press within 1.5s) ── def _on_ctrl_c(self, event): if event.state & 0x1: # Shift held → Ctrl+Shift+C → copy return self._on_copy(event) # If there is a selection → always copy (no SIGINT risk) try: sel = self._text.get("sel.first", "sel.last") if sel: self.clipboard_clear() self.clipboard_append(sel) self._flash_status("Copied!", "#44cc44") self._last_ctrl_c = 0.0 return "break" except tk.TclError: pass # No selection → double Ctrl+C within 1.5s sends SIGINT now = time.monotonic() if (now - self._last_ctrl_c) < 1.5: self._send(b"\x03") self._last_ctrl_c = 0.0 self._flash_status("SIGINT sent", "#ff8800") return "break" # First press → hint, wait for second self._last_ctrl_c = now self._flash_status("Press Ctrl+C again to send SIGINT", "#ccaa00") return "break" def _on_copy(self, event): """Copy selected text to clipboard.""" try: sel = self._text.get("sel.first", "sel.last") if sel: self.clipboard_clear() self.clipboard_append(sel) self._flash_status("Copied!", "#44cc44") except tk.TclError: pass return "break" def _on_ctrl_v(self, event): """Paste with bracketed paste mode support.""" try: text = self.clipboard_get() if text: if _BRACKETED_PASTE in self._screen.mode: self._send(b"\x1b[200~") self._send(text.encode("utf-8")) self._send(b"\x1b[201~") else: 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" # ── Mouse handling ───────────────────────────────────────────────────── def _mouse_tracking_active(self) -> bool: m = self._screen.mode return (_MOUSE_BASIC in m or _MOUSE_BTN_TRACK in m or _MOUSE_ANY in m) def _pixel_to_cell(self, px: int, py: int) -> tuple[int, int]: cw = self._font.measure("M") ch = self._font.metrics("linespace") col = max(0, min((px - 4) // max(cw, 1), self._cols - 1)) row = max(0, min((py - 4) // max(ch, 1), self._rows - 1)) return col, row def _send_mouse(self, button: int, col: int, row: int, release=False): if _MOUSE_SGR in self._screen.mode: ch = "m" if release else "M" self._send(f"\x1b[<{button};{col+1};{row+1}{ch}".encode()) else: if not release: self._send(bytes([0x1b, 0x5b, 0x4d, 32 + button, 32 + col + 1, 32 + row + 1])) def _on_mouse_press(self, event): self._text.focus_set() if self._mouse_tracking_active(): col, row = self._pixel_to_cell(event.x, event.y) self._send_mouse(0, col, row) return "break" # Normal mode: start text selection self._selecting = True return # let tkinter handle selection def _on_mouse_release(self, event): if self._mouse_tracking_active(): col, row = self._pixel_to_cell(event.x, event.y) self._send_mouse(0, col, row, release=True) return "break" self._selecting = False def _on_mouse_motion(self, event): if self._mouse_tracking_active(): if _MOUSE_BTN_TRACK in self._screen.mode or _MOUSE_ANY in self._screen.mode: col, row = self._pixel_to_cell(event.x, event.y) self._send_mouse(32, col, row) # 32 = motion flag return "break" def _on_double_click(self, event): """Double-click to select word (like professional terminals).""" if self._mouse_tracking_active(): return "break" # Let tkinter handle word selection return def _on_right_click(self, event): """Right-click context menu: Copy / Paste.""" if self._mouse_tracking_active(): col, row = self._pixel_to_cell(event.x, event.y) self._send_mouse(2, col, row) return "break" menu = tk.Menu(self, tearoff=0, bg="#2d2d44", fg="#d3d7cf", activebackground="#44447a", activeforeground="#ffffff", font=("Consolas", 10)) has_selection = False try: self._text.get("sel.first", "sel.last") has_selection = True except tk.TclError: pass menu.add_command( label="Copy (Ctrl+Shift+C)", command=lambda: self._on_copy(None), state="normal" if has_selection else "disabled", ) menu.add_command( label="Paste (Ctrl+Shift+V)", command=lambda: self._on_ctrl_v(None), ) menu.add_separator() menu.add_command( label="Select All", command=self._select_all, ) try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() return "break" def _select_all(self): """Select all text in terminal.""" self._text.configure(state="normal") self._text.tag_add("sel", "1.0", "end-1c") # Keep text widget in "normal" state — all input is handled # by key bindings returning "break", so no user editing is possible. # "disabled" state breaks mouse selection on Windows. def _on_mousewheel(self, event): # Ctrl+wheel → zoom if event.state & 0x4: if event.delta > 0: self._on_zoom_in() else: self._on_zoom_out() return "break" if self._mouse_tracking_active(): col, row = self._pixel_to_cell(event.x, event.y) # Mouse wheel: button 64 (up) or 65 (down) btn = 64 if event.delta > 0 else 65 self._send_mouse(btn, col, row) return "break" in_alt = getattr(self._screen, "_in_alt", False) if in_alt: # In alternate screen: send arrow keys for smooth scrolling lines = 3 decckm = _DECCKM in self._screen.mode if event.delta > 0: key = b"\x1bOA" if decckm else b"\x1b[A" else: key = b"\x1bOB" if decckm else b"\x1b[B" for _ in range(lines): self._send(key) else: # Normal mode: page up/down if event.delta > 0: self._send(b"\x1b[5~") else: self._send(b"\x1b[6~") return "break" # ── Send ─────────────────────────────────────────────────────────────── def _send(self, data: bytes): if self.send_callback: self.send_callback(data) # ── Resize ───────────────────────────────────────────────────────────── def _on_configure(self, event): if self._resize_after_id: self.after_cancel(self._resize_after_id) self._resize_after_id = self.after(100, 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 cw = self._font.measure("M") ch = self._font.metrics("linespace") if cw <= 0 or ch <= 0: return new_cols = max(20, (w - 8) // cw) new_rows = max(4, (h - 8) // ch) 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)