diff --git a/core/ssh_client.py b/core/ssh_client.py index 780e11f..6cd8bd6 100644 --- a/core/ssh_client.py +++ b/core/ssh_client.py @@ -136,7 +136,9 @@ class ShellSession: try: self._channel.sendall(data) except OSError: - pass + self._running = False + if self.on_disconnect: + self.on_disconnect() def resize(self, cols: int, rows: int): self.cols = cols diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 17445a2..2a89cd0 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -2,14 +2,13 @@ Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget. """ +import queue import threading import time import customtkinter as ctk from core.ssh_client import ShellSession from core.i18n import t -import queue - class TerminalTab(ctk.CTkFrame): def __init__(self, master, store): @@ -32,9 +31,8 @@ class TerminalTab(ctk.CTkFrame): self._terminal.pack(fill="both", expand=True, padx=5, pady=5) self._terminal.set_status(t("term_disconnected"), "#888888") - # Thread-safe data batching + # Thread-safe data queue self._data_queue: queue.Queue[bytes] = queue.Queue() - self._flush_after_id = None def set_server(self, alias: str | None): if alias == self._current_alias: @@ -70,12 +68,16 @@ class TerminalTab(ctk.CTkFrame): session.on_data = self._on_data_received session.on_disconnect = self._on_disconnected session.connect() - self._session = session - self._reconnect_count = 0 - self.after(0, lambda: self._terminal.set_status( - t("term_connected").format(alias=alias), "#44cc44" - )) - self.after(0, self._terminal.focus_terminal) + + # Set session on main thread to avoid races + def _set_session(): + self._session = session + self._reconnect_count = 0 + self._terminal.set_status( + t("term_connected").format(alias=alias), "#44cc44" + ) + self._terminal.focus_terminal() + self.after(0, _set_session) except Exception as e: self.after(0, lambda: self._terminal.set_status( t("term_connect_failed").format(error=str(e)), "#ff4444" @@ -85,9 +87,10 @@ class TerminalTab(ctk.CTkFrame): def _disconnect(self): self._intentional_disconnect = True - if self._session: - self._session.disconnect() - self._session = None + session = self._session + self._session = None + if session: + session.disconnect() def _on_data_received(self, data: bytes): """Called from SSH thread — put data in thread-safe queue.""" @@ -106,37 +109,39 @@ class TerminalTab(ctk.CTkFrame): self._terminal.feed(b"".join(chunks)) def _on_disconnected(self): - if self._intentional_disconnect: - self.after(0, lambda: self._terminal.set_status( - t("term_disconnected"), "#888888" - )) - return + """Called from SSH read thread.""" + def _handle(): + if self._intentional_disconnect: + self._terminal.set_status(t("term_disconnected"), "#888888") + return - self._session = None + self._session = None - if self._reconnect_count < self._max_reconnect: - self._reconnect_count += 1 - n = self._reconnect_count - mx = self._max_reconnect - self.after(0, lambda: self._terminal.set_status( - t("term_reconnecting").format(n=n, max=mx), "#ccaa00" - )) + if self._reconnect_count < self._max_reconnect: + self._reconnect_count += 1 + n = self._reconnect_count + mx = self._max_reconnect + self._terminal.set_status( + t("term_reconnecting").format(n=n, max=mx), "#ccaa00" + ) - def _retry(): - time.sleep(1) - if not self._intentional_disconnect and self._current_alias: - self.after(0, self._connect) + def _retry(): + time.sleep(1) + if not self._intentional_disconnect and self._current_alias: + self.after(0, self._connect) - threading.Thread(target=_retry, daemon=True).start() - else: - self.after(0, lambda: self._terminal.set_status( - t("term_reconnect_fail"), "#ff4444" - )) + threading.Thread(target=_retry, daemon=True).start() + else: + self._terminal.set_status(t("term_reconnect_fail"), "#ff4444") + + self.after(0, _handle) def _send_to_shell(self, data: bytes): - if self._session and self._session.connected: - self._session.send(data) + session = self._session # local ref for thread safety + if session and session.connected: + session.send(data) def _on_resize(self, cols: int, rows: int): - if self._session and self._session.connected: - self._session.resize(cols, rows) + session = self._session # local ref for thread safety + if session and session.connected: + session.resize(cols, rows) diff --git a/gui/widgets/terminal_widget.py b/gui/widgets/terminal_widget.py index 3032d55..ae0bf96 100644 --- a/gui/widgets/terminal_widget.py +++ b/gui/widgets/terminal_widget.py @@ -1,14 +1,18 @@ """ -Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors, -full keyboard mapping, diff-based rendering, and cursor display. +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 tkinter as tk import tkinter.font as tkfont import pyte -# 16 standard ANSI colors +# ── Colors ───────────────────────────────────────────────────────────────── + _ANSI_COLORS = { "black": "#000000", "red": "#cc0000", @@ -30,51 +34,115 @@ _ANSI_COLORS = { _DEFAULT_FG = "#d3d7cf" _DEFAULT_BG = "#1a1a2e" -_CURSOR_FG = "#1a1a2e" -_CURSOR_BG = "#d3d7cf" +_CURSOR_FG = "#1a1a2e" +_CURSOR_BG = "#d3d7cf" -# DECCKM (private mode ?1) — pyte stores private modes shifted by 5 bits -_DECCKM = 32 +# ── 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 → VT100 escape sequence (normal cursor mode) _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~", + "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~", } -# Application cursor mode (DECCKM) — mc, htop, vim enable this -_KEY_MAP_APP_CURSOR = { - "Up": "\x1bOA", - "Down": "\x1bOB", - "Right": "\x1bOC", - "Left": "\x1bOD", - "Home": "\x1bOH", - "End": "\x1bOF", +_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 + + +# ── 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): - """VT100 terminal emulator widget using pyte + tkinter.Text.""" + """Full-featured VT100/xterm terminal emulator widget.""" def __init__(self, master, cols=80, rows=24, send_callback=None, resize_callback=None, **kwargs): @@ -85,60 +153,64 @@ class TerminalWidget(tk.Frame): self._rows = rows # pyte screen + stream - self._screen = pyte.Screen(cols, rows) + self._screen = _Screen(cols, rows) + self._screen._write_cb = self._send self._stream = pyte.Stream(self._screen) - # Previous buffer for diff rendering - self._prev_buffer: dict[int, list] = {} + # Incremental UTF-8 decoder (handles split multi-byte sequences) + self._utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace") - # Render debouncing (~60fps) + # 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 - # Font + # Dynamic color tag cache + self._dynamic_tags: dict[str, bool] = {} + + # Fonts 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, 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 + # ── 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) + # 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") - # Resize handling + # ── 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) + + # ── Resize ── self._resize_after_id = None self._text.bind("", self._on_configure) - # Focus on click - self._text.bind("", lambda e: self._text.focus_set()) + # ── Selection tracking for copy ── + self._selecting = False - # Make text read-only (input goes through _on_key) - self._text.bind("<>", lambda e: "break") - - # Status bar + # ── 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) @@ -148,39 +220,44 @@ class TerminalWidget(tk.Frame): ) self._status_label.pack(fill="x") + # ── Tag setup ────────────────────────────────────────────────────────── + 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("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("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) def feed(self, data: bytes): """Feed raw bytes from SSH into pyte, schedule debounced render.""" - try: - text = data.decode("utf-8", errors="replace") - except Exception: - text = data.decode("latin-1", errors="replace") + 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): - """Execute pending render.""" self._render_pending = False self._render_after_id = None self._render() def reset(self): - """Reset the terminal screen.""" self._screen.reset() self._prev_buffer.clear() self._render() @@ -189,72 +266,78 @@ class TerminalWidget(tk.Frame): 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") + # ── 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 list of (char, attrs) for the row + # Build cell data: (char, fg, bg, bold, italic, underline, reverse, strike) 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)) + 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) - prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row + was_cursor_row = (row == self._prev_cursor_y) - if current == prev and not is_cursor_row and not prev_was_cursor_row: + # 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 - - # Delete the line line_start = f"{row + 1}.0" - line_end = f"{row + 1}.end" - self._text.delete(line_start, line_end) + self._text.delete(line_start, f"{row + 1}.end") - # Ensure enough lines exist + # Ensure line exists while int(self._text.index("end-1c").split(".")[0]) <= row: self._text.insert("end", "\n") - # Build line content with batched tags + # Batch consecutive chars with same attrs col = 0 while col < len(current): - ch, fg, bg, bold, italic, underline = current[col] - - # Batch consecutive chars with same attributes + 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, underline): + 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, underline) + tags = self._make_tags(fg, bg, bold, italic, ul, rev, strike) - # Apply cursor tag - if is_cursor_row: + # Cursor overlay + if is_cursor_row and not cursor_hidden: 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:] + 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) + self._text.insert(f"{row+1}.{cursor.x+1}", + post, tags) col += len(batch) continue @@ -262,86 +345,168 @@ class TerminalWidget(tk.Frame): col += len(batch) # Trim excess lines - total_lines = int(self._text.index("end-1c").split(".")[0]) - if total_lines > screen.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 self._text.configure(state="disabled") - def _make_tags(self, fg, bg, bold, italic, underline) -> tuple: + 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 = [] - 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) + 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 _resolve_color(self, color, is_fg=True): + 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 color - # pyte may return color names like "red", "green" etc directly + 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): - """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"): + "Caps_Lock", "Num_Lock", "Scroll_Lock", + "Win_L", "Win_R", "Super_L", "Super_R"): 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])) + state = event.state + ctrl = bool(state & 0x4) + shift = bool(state & 0x1) + alt = bool(state & 0x20000) + + # ── Ctrl+key ── + if ctrl: + ks = event.keysym.lower() + # 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" - # Special keys — check DECCKM (application cursor mode) for arrow keys + # ── 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 - if decckm and event.keysym in _KEY_MAP_APP_CURSOR: - self._send(_KEY_MAP_APP_CURSOR[event.keysym].encode()) + 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: - self._send(_KEY_MAP[event.keysym].encode()) + 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" + self._send(b"\t"); return "break" - # Return / Enter + # Enter if event.keysym in ("Return", "KP_Enter"): - self._send(b"\r") - return "break" + self._send(b"\r"); return "break" # Backspace if event.keysym == "BackSpace": - self._send(b"\x7f") - return "break" + self._send(b"\x7f"); return "break" # Escape if event.keysym == "Escape": - self._send(b"\x1b") - return "break" + 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: @@ -350,58 +515,184 @@ class TerminalWidget(tk.Frame): return "break" + # ── Ctrl+C: always SIGINT, use Ctrl+Shift+C to copy ── def _on_ctrl_c(self, event): - # Check if text is selected — if so, copy + if event.state & 0x1: # Shift held → Ctrl+Shift+C → copy + return self._on_copy(event) + self._send(b"\x03") + 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) - return "break" except tk.TclError: pass - # No selection — send SIGINT - self._send(b"\x03") return "break" def _on_ctrl_v(self, event): + """Paste with bracketed paste mode support.""" try: text = self.clipboard_get() if text: - self._send(text.encode("utf-8")) + 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" + self._send(b"\x04"); return "break" def _on_ctrl_l(self, event): - self._send(b"\x0c") - return "break" + self._send(b"\x0c"); return "break" def _on_ctrl_z(self, event): - self._send(b"\x1a") + 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") + self._text.configure(state="disabled") + def _on_mousewheel(self, event): - # Send scroll sequences for programs like less/man - if event.delta > 0: - self._send(b"\x1b[5~") # PageUp + 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: - self._send(b"\x1b[6~") # PageDown + # 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): - """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) + self._resize_after_id = self.after(100, self._do_resize) def _do_resize(self): self._resize_after_id = None @@ -409,15 +700,12 @@ class TerminalWidget(tk.Frame): 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: + cw = self._font.measure("M") + ch = self._font.metrics("linespace") + if cw <= 0 or ch <= 0: return - - new_cols = max(20, (w - 8) // char_w) - new_rows = max(4, (h - 8) // char_h) - + 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