Complete terminal rewrite for full TUI compatibility

- Alternate screen buffer (smcup/rmcup) for mc, vim, htop, less
- 256-color and truecolor rendering (dynamic tag creation)
- Reverse video and strikethrough attributes
- write_process_input bridge (DA/DSR responses to SSH)
- DECCKM application cursor keys for arrow navigation
- Alt+key combinations (mc panels, readline word nav)
- Shift+Arrow / Ctrl+Arrow with xterm modifier encoding
- Shift+Tab (backtab) for reverse completion
- Bracketed paste mode for clean multi-line paste
- Mouse tracking (basic, button, any-event, SGR extended)
- Incremental UTF-8 decoder (handles split multi-byte)
- Cursor hidden state (DECTCEM) respected
- pyte dirty set for optimized rendering
- Numpad key support
- Professional copy/paste UX:
  - Ctrl+C always sends SIGINT
  - Ctrl+Shift+C/V for copy/paste
  - Right-click context menu with Copy/Paste/Select All
  - Double-click to select word
- Smooth mousewheel scroll (3 lines in alt screen)
- Thread-safe session management (all state on main thread)
- Resize debounce reduced to 100ms
- Send errors trigger disconnect notification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 14:57:55 -05:00
parent 38d2387cd5
commit 59163f0469
3 changed files with 505 additions and 210 deletions

View File

@@ -136,7 +136,9 @@ class ShellSession:
try: try:
self._channel.sendall(data) self._channel.sendall(data)
except OSError: except OSError:
pass self._running = False
if self.on_disconnect:
self.on_disconnect()
def resize(self, cols: int, rows: int): def resize(self, cols: int, rows: int):
self.cols = cols self.cols = cols

View File

@@ -2,14 +2,13 @@
Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget. Terminal tab — persistent interactive SSH shell via ShellSession + TerminalWidget.
""" """
import queue
import threading import threading
import time import time
import customtkinter as ctk import customtkinter as ctk
from core.ssh_client import ShellSession from core.ssh_client import ShellSession
from core.i18n import t from core.i18n import t
import queue
class TerminalTab(ctk.CTkFrame): class TerminalTab(ctk.CTkFrame):
def __init__(self, master, store): 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.pack(fill="both", expand=True, padx=5, pady=5)
self._terminal.set_status(t("term_disconnected"), "#888888") 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._data_queue: queue.Queue[bytes] = queue.Queue()
self._flush_after_id = None
def set_server(self, alias: str | None): def set_server(self, alias: str | None):
if alias == self._current_alias: if alias == self._current_alias:
@@ -70,12 +68,16 @@ class TerminalTab(ctk.CTkFrame):
session.on_data = self._on_data_received session.on_data = self._on_data_received
session.on_disconnect = self._on_disconnected session.on_disconnect = self._on_disconnected
session.connect() session.connect()
self._session = session
self._reconnect_count = 0 # Set session on main thread to avoid races
self.after(0, lambda: self._terminal.set_status( def _set_session():
t("term_connected").format(alias=alias), "#44cc44" self._session = session
)) self._reconnect_count = 0
self.after(0, self._terminal.focus_terminal) self._terminal.set_status(
t("term_connected").format(alias=alias), "#44cc44"
)
self._terminal.focus_terminal()
self.after(0, _set_session)
except Exception as e: except Exception as e:
self.after(0, lambda: self._terminal.set_status( self.after(0, lambda: self._terminal.set_status(
t("term_connect_failed").format(error=str(e)), "#ff4444" t("term_connect_failed").format(error=str(e)), "#ff4444"
@@ -85,9 +87,10 @@ class TerminalTab(ctk.CTkFrame):
def _disconnect(self): def _disconnect(self):
self._intentional_disconnect = True self._intentional_disconnect = True
if self._session: session = self._session
self._session.disconnect() self._session = None
self._session = None if session:
session.disconnect()
def _on_data_received(self, data: bytes): def _on_data_received(self, data: bytes):
"""Called from SSH thread — put data in thread-safe queue.""" """Called from SSH thread — put data in thread-safe queue."""
@@ -106,37 +109,39 @@ class TerminalTab(ctk.CTkFrame):
self._terminal.feed(b"".join(chunks)) self._terminal.feed(b"".join(chunks))
def _on_disconnected(self): def _on_disconnected(self):
if self._intentional_disconnect: """Called from SSH read thread."""
self.after(0, lambda: self._terminal.set_status( def _handle():
t("term_disconnected"), "#888888" if self._intentional_disconnect:
)) self._terminal.set_status(t("term_disconnected"), "#888888")
return return
self._session = None self._session = None
if self._reconnect_count < self._max_reconnect: if self._reconnect_count < self._max_reconnect:
self._reconnect_count += 1 self._reconnect_count += 1
n = self._reconnect_count n = self._reconnect_count
mx = self._max_reconnect mx = self._max_reconnect
self.after(0, lambda: self._terminal.set_status( self._terminal.set_status(
t("term_reconnecting").format(n=n, max=mx), "#ccaa00" t("term_reconnecting").format(n=n, max=mx), "#ccaa00"
)) )
def _retry(): def _retry():
time.sleep(1) time.sleep(1)
if not self._intentional_disconnect and self._current_alias: if not self._intentional_disconnect and self._current_alias:
self.after(0, self._connect) self.after(0, self._connect)
threading.Thread(target=_retry, daemon=True).start() threading.Thread(target=_retry, daemon=True).start()
else: else:
self.after(0, lambda: self._terminal.set_status( self._terminal.set_status(t("term_reconnect_fail"), "#ff4444")
t("term_reconnect_fail"), "#ff4444"
)) self.after(0, _handle)
def _send_to_shell(self, data: bytes): def _send_to_shell(self, data: bytes):
if self._session and self._session.connected: session = self._session # local ref for thread safety
self._session.send(data) if session and session.connected:
session.send(data)
def _on_resize(self, cols: int, rows: int): def _on_resize(self, cols: int, rows: int):
if self._session and self._session.connected: session = self._session # local ref for thread safety
self._session.resize(cols, rows) if session and session.connected:
session.resize(cols, rows)

View File

@@ -1,14 +1,18 @@
""" """
Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors, Terminal widget — pyte VT100 emulator + tkinter.Text with full xterm
full keyboard mapping, diff-based rendering, and cursor display. 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 as tk
import tkinter.font as tkfont import tkinter.font as tkfont
import pyte import pyte
# 16 standard ANSI colors # ── Colors ─────────────────────────────────────────────────────────────────
_ANSI_COLORS = { _ANSI_COLORS = {
"black": "#000000", "black": "#000000",
"red": "#cc0000", "red": "#cc0000",
@@ -30,51 +34,115 @@ _ANSI_COLORS = {
_DEFAULT_FG = "#d3d7cf" _DEFAULT_FG = "#d3d7cf"
_DEFAULT_BG = "#1a1a2e" _DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e" _CURSOR_FG = "#1a1a2e"
_CURSOR_BG = "#d3d7cf" _CURSOR_BG = "#d3d7cf"
# DECCKM (private mode ?1) — pyte stores private modes shifted by 5 bits # ── Private mode constants (pyte stores private modes shifted <<5) ─────────
_DECCKM = 32
_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 = { _KEY_MAP = {
"Up": "\x1b[A", "Up": "\x1b[A", "Down": "\x1b[B",
"Down": "\x1b[B", "Right": "\x1b[C", "Left": "\x1b[D",
"Right": "\x1b[C", "Home": "\x1b[H", "End": "\x1b[F",
"Left": "\x1b[D", "Insert":"\x1b[2~", "Delete":"\x1b[3~",
"Home": "\x1b[H", "Prior": "\x1b[5~", "Next": "\x1b[6~",
"End": "\x1b[F", "F1": "\x1bOP", "F2": "\x1bOQ", "F3": "\x1bOR", "F4": "\x1bOS",
"Insert": "\x1b[2~", "F5": "\x1b[15~","F6": "\x1b[17~","F7": "\x1b[18~","F8": "\x1b[19~",
"Delete": "\x1b[3~", "F9": "\x1b[20~","F10": "\x1b[21~","F11": "\x1b[23~","F12": "\x1b[24~",
"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~",
} }
# Application cursor mode (DECCKM) — mc, htop, vim enable this _APP_CURSOR_MAP = {
_KEY_MAP_APP_CURSOR = { "Up": "\x1bOA", "Down": "\x1bOB", "Right": "\x1bOC", "Left": "\x1bOD",
"Up": "\x1bOA", "Home": "\x1bOH", "End": "\x1bOF",
"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): 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, def __init__(self, master, cols=80, rows=24, send_callback=None,
resize_callback=None, **kwargs): resize_callback=None, **kwargs):
@@ -85,60 +153,64 @@ class TerminalWidget(tk.Frame):
self._rows = rows self._rows = rows
# pyte screen + stream # 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) self._stream = pyte.Stream(self._screen)
# Previous buffer for diff rendering # Incremental UTF-8 decoder (handles split multi-byte sequences)
self._prev_buffer: dict[int, list] = {} 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_pending = False
self._render_after_id = None 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._font = tkfont.Font(family="Consolas", size=11)
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold") self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
# Text widget # Text widget
self._text = tk.Text( self._text = tk.Text(
self, self, bg=_DEFAULT_BG, fg=_DEFAULT_FG, font=self._font,
bg=_DEFAULT_BG, insertwidth=0, highlightthickness=0, borderwidth=0,
fg=_DEFAULT_FG, padx=4, pady=4, wrap="none", cursor="xterm",
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._text.pack(fill="both", expand=True)
# Create color tags
self._setup_tags() self._setup_tags()
# Keyboard bindings # ── Keyboard bindings ──
self._text.bind("<Key>", self._on_key) self._text.bind("<Key>", self._on_key)
self._text.bind("<Control-c>", self._on_ctrl_c) self._text.bind("<Control-c>", self._on_ctrl_c)
self._text.bind("<Control-v>", self._on_ctrl_v) self._text.bind("<Control-v>", self._on_ctrl_v)
self._text.bind("<Control-a>", lambda e: "break")
self._text.bind("<Control-d>", self._on_ctrl_d) self._text.bind("<Control-d>", self._on_ctrl_d)
self._text.bind("<Control-l>", self._on_ctrl_l) self._text.bind("<Control-l>", self._on_ctrl_l)
self._text.bind("<Control-z>", self._on_ctrl_z) self._text.bind("<Control-z>", self._on_ctrl_z)
self._text.bind("<MouseWheel>", self._on_mousewheel) # Professional copy/paste: Ctrl+Shift+C/V always copy/paste
self._text.bind("<Control-Shift-C>", self._on_copy)
self._text.bind("<Control-Shift-V>", self._on_ctrl_v)
self._text.bind("<<Paste>>", lambda e: "break")
# Resize handling # ── Mouse bindings ──
self._text.bind("<Button-1>", self._on_mouse_press)
self._text.bind("<ButtonRelease-1>", self._on_mouse_release)
self._text.bind("<B1-Motion>", self._on_mouse_motion)
self._text.bind("<Button-3>", self._on_right_click)
self._text.bind("<MouseWheel>", self._on_mousewheel)
self._text.bind("<Double-Button-1>", self._on_double_click)
# ── Resize ──
self._resize_after_id = None self._resize_after_id = None
self._text.bind("<Configure>", self._on_configure) self._text.bind("<Configure>", self._on_configure)
# Focus on click # ── Selection tracking for copy ──
self._text.bind("<Button-1>", lambda e: self._text.focus_set()) self._selecting = False
# Make text read-only (input goes through _on_key) # ── Status bar ──
self._text.bind("<<Paste>>", lambda e: "break")
# Status bar
self._status_frame = tk.Frame(self, bg="#2d2d44", height=22) self._status_frame = tk.Frame(self, bg="#2d2d44", height=22)
self._status_frame.pack(fill="x", side="bottom") self._status_frame.pack(fill="x", side="bottom")
self._status_frame.pack_propagate(False) self._status_frame.pack_propagate(False)
@@ -148,39 +220,44 @@ class TerminalWidget(tk.Frame):
) )
self._status_label.pack(fill="x") self._status_label.pack(fill="x")
# ── Tag setup ──────────────────────────────────────────────────────────
def _setup_tags(self): def _setup_tags(self):
"""Pre-create tags for all ANSI color combinations."""
for name, color in _ANSI_COLORS.items(): for name, color in _ANSI_COLORS.items():
self._text.tag_configure(f"fg_{name}", foreground=color) self._text.tag_configure(f"fg_{name}", foreground=color)
self._text.tag_configure(f"bg_{name}", background=color) self._text.tag_configure(f"bg_{name}", background=color)
self._text.tag_configure("bold", font=self._bold_font) 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("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("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"): def set_status(self, text: str, color: str = "#888888"):
self._status_label.configure(text=text, fg=color) self._status_label.configure(text=text, fg=color)
def feed(self, data: bytes): def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte, schedule debounced render.""" """Feed raw bytes from SSH into pyte, schedule debounced render."""
try: text = self._utf8_decoder.decode(data)
text = data.decode("utf-8", errors="replace") if not text:
except Exception: return
text = data.decode("latin-1", errors="replace")
self._stream.feed(text) self._stream.feed(text)
if not self._render_pending: if not self._render_pending:
self._render_pending = True self._render_pending = True
self._render_after_id = self.after(16, self._debounced_render) self._render_after_id = self.after(16, self._debounced_render)
def _debounced_render(self): def _debounced_render(self):
"""Execute pending render."""
self._render_pending = False self._render_pending = False
self._render_after_id = None self._render_after_id = None
self._render() self._render()
def reset(self): def reset(self):
"""Reset the terminal screen."""
self._screen.reset() self._screen.reset()
self._prev_buffer.clear() self._prev_buffer.clear()
self._render() self._render()
@@ -189,72 +266,78 @@ class TerminalWidget(tk.Frame):
self._text.focus_set() self._text.focus_set()
def get_size(self) -> tuple[int, int]: def get_size(self) -> tuple[int, int]:
"""Return (cols, rows) based on current widget size."""
return self._cols, self._rows return self._cols, self._rows
def _render(self): # ── Rendering ──────────────────────────────────────────────────────────
"""Diff-based rendering: only update changed lines."""
self._text.configure(state="normal")
def _render(self):
self._text.configure(state="normal")
screen = self._screen screen = self._screen
cursor = screen.cursor 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): for row in range(screen.lines):
line = screen.buffer[row] line = screen.buffer[row]
# Build list of (char, attrs) for the row # Build cell data: (char, fg, bg, bold, italic, underline, reverse, strike)
current = [] current = []
for col in range(screen.columns): for c in range(screen.columns):
char_data = line[col] cd = line[c]
current.append((char_data.data, char_data.fg, char_data.bg, current.append((cd.data, cd.fg, cd.bg, cd.bold, cd.italics,
char_data.bold, char_data.italics, char_data.underscore)) cd.underscore, cd.reverse, cd.strikethrough))
prev = self._prev_buffer.get(row) prev = self._prev_buffer.get(row)
is_cursor_row = (row == cursor.y) 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 continue
self._prev_buffer[row] = current self._prev_buffer[row] = current
# Delete the line
line_start = f"{row + 1}.0" line_start = f"{row + 1}.0"
line_end = f"{row + 1}.end" self._text.delete(line_start, f"{row + 1}.end")
self._text.delete(line_start, line_end)
# Ensure enough lines exist # Ensure line exists
while int(self._text.index("end-1c").split(".")[0]) <= row: while int(self._text.index("end-1c").split(".")[0]) <= row:
self._text.insert("end", "\n") self._text.insert("end", "\n")
# Build line content with batched tags # Batch consecutive chars with same attrs
col = 0 col = 0
while col < len(current): while col < len(current):
ch, fg, bg, bold, italic, underline = current[col] ch, fg, bg, bold, italic, ul, rev, strike = current[col]
# Batch consecutive chars with same attributes
batch = [ch] batch = [ch]
while col + len(batch) < len(current): while col + len(batch) < len(current):
nc = current[col + len(batch)] 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]) batch.append(nc[0])
else: else:
break break
text = "".join(batch) 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 # Cursor overlay
if is_cursor_row: if is_cursor_row and not cursor_hidden:
if col <= cursor.x < col + len(batch): if col <= cursor.x < col + len(batch):
# Split at cursor position pre = cursor.x - col
pre_len = cursor.x - col if pre > 0:
if pre_len > 0: self._text.insert(f"{row+1}.{col}",
self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags) text[:pre], tags)
cursor_tags = ("cursor",) + tags self._text.insert(f"{row+1}.{cursor.x}",
self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags) text[pre:pre+1], ("cursor",)+tags)
post = text[pre_len + 1:] post = text[pre+1:]
if post: 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) col += len(batch)
continue continue
@@ -262,86 +345,168 @@ class TerminalWidget(tk.Frame):
col += len(batch) col += len(batch)
# Trim excess lines # Trim excess lines
total_lines = int(self._text.index("end-1c").split(".")[0]) total = int(self._text.index("end-1c").split(".")[0])
if total_lines > screen.lines: if total > screen.lines:
self._text.delete(f"{screen.lines + 1}.0", "end") self._text.delete(f"{screen.lines + 1}.0", "end")
self._prev_cursor_y = cursor.y self._prev_cursor_y = cursor.y
self._prev_cursor_hidden = cursor_hidden
self._text.configure(state="disabled") 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 = [] tags = []
fg_color = self._resolve_color(fg, is_fg=True) t = self._color_tag(fg, is_fg=True)
if fg_color: if t:
tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None tags.append(t)
if tag and tag.replace("fg_", "") in _ANSI_COLORS: t = self._color_tag(bg, is_fg=False)
tags.append(tag) if t:
bg_color = self._resolve_color(bg, is_fg=False) tags.append(t)
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: if bold:
tags.append("bold") tags.append("bold")
if italic: if italic:
tags.append("italic") tags.append("italic")
if underline: if underline:
tags.append("underline") tags.append("underline")
if strikethrough:
tags.append("strikethrough")
return tuple(tags) 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": if not color or color == "default":
return None return None
prefix = "fg" if is_fg else "bg"
# Named ANSI color
if color in _ANSI_COLORS: if color in _ANSI_COLORS:
return color return f"{prefix}_{color}"
# pyte may return color names like "red", "green" etc directly # 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 return None
# ── Keyboard handling ──────────────────────────────────────────────────
def _on_key(self, event): def _on_key(self, event):
"""Handle keyboard input and send to SSH."""
# Ignore modifier-only keys # Ignore modifier-only keys
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R", if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Meta_L", "Meta_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" return "break"
# Ctrl+key combinations state = event.state
if event.state & 0x4: # Control ctrl = bool(state & 0x4)
if event.keysym.lower() in ("c", "v", "d", "l", "z"): shift = bool(state & 0x1)
return # handled by specific bindings alt = bool(state & 0x20000)
ch = event.keysym.lower()
if len(ch) == 1 and "a" <= ch <= "z": # ── Ctrl+key ──
self._send(bytes([ord(ch) - ord("a") + 1])) 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" 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: if event.keysym in _KEY_MAP:
decckm = _DECCKM in self._screen.mode decckm = _DECCKM in self._screen.mode
if decckm and event.keysym in _KEY_MAP_APP_CURSOR: modifier = _xterm_modifier(state)
self._send(_KEY_MAP_APP_CURSOR[event.keysym].encode())
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: 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" return "break"
# Tab # Tab
if event.keysym == "Tab": if event.keysym == "Tab":
self._send(b"\t") self._send(b"\t"); return "break"
return "break"
# Return / Enter # Enter
if event.keysym in ("Return", "KP_Enter"): if event.keysym in ("Return", "KP_Enter"):
self._send(b"\r") self._send(b"\r"); return "break"
return "break"
# Backspace # Backspace
if event.keysym == "BackSpace": if event.keysym == "BackSpace":
self._send(b"\x7f") self._send(b"\x7f"); return "break"
return "break"
# Escape # Escape
if event.keysym == "Escape": if event.keysym == "Escape":
self._send(b"\x1b") self._send(b"\x1b"); return "break"
return "break"
# Numpad
if event.keysym in _KP_MAP:
self._send(_KP_MAP[event.keysym].encode()); return "break"
# Regular character # Regular character
if event.char and ord(event.char) >= 32: if event.char and ord(event.char) >= 32:
@@ -350,58 +515,184 @@ class TerminalWidget(tk.Frame):
return "break" return "break"
# ── Ctrl+C: always SIGINT, use Ctrl+Shift+C to copy ──
def _on_ctrl_c(self, event): 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: try:
sel = self._text.get("sel.first", "sel.last") sel = self._text.get("sel.first", "sel.last")
if sel: if sel:
self.clipboard_clear() self.clipboard_clear()
self.clipboard_append(sel) self.clipboard_append(sel)
return "break"
except tk.TclError: except tk.TclError:
pass pass
# No selection — send SIGINT
self._send(b"\x03")
return "break" return "break"
def _on_ctrl_v(self, event): def _on_ctrl_v(self, event):
"""Paste with bracketed paste mode support."""
try: try:
text = self.clipboard_get() text = self.clipboard_get()
if text: 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: except tk.TclError:
pass pass
return "break" return "break"
def _on_ctrl_d(self, event): def _on_ctrl_d(self, event):
self._send(b"\x04") self._send(b"\x04"); return "break"
return "break"
def _on_ctrl_l(self, event): def _on_ctrl_l(self, event):
self._send(b"\x0c") self._send(b"\x0c"); return "break"
return "break"
def _on_ctrl_z(self, event): 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" 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): def _on_mousewheel(self, event):
# Send scroll sequences for programs like less/man if self._mouse_tracking_active():
if event.delta > 0: col, row = self._pixel_to_cell(event.x, event.y)
self._send(b"\x1b[5~") # PageUp # 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: 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" return "break"
# ── Send ───────────────────────────────────────────────────────────────
def _send(self, data: bytes): def _send(self, data: bytes):
if self.send_callback: if self.send_callback:
self.send_callback(data) self.send_callback(data)
# ── Resize ─────────────────────────────────────────────────────────────
def _on_configure(self, event): def _on_configure(self, event):
"""Debounced resize handler."""
if self._resize_after_id: if self._resize_after_id:
self.after_cancel(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): def _do_resize(self):
self._resize_after_id = None self._resize_after_id = None
@@ -409,15 +700,12 @@ class TerminalWidget(tk.Frame):
h = self._text.winfo_height() h = self._text.winfo_height()
if w < 20 or h < 20: if w < 20 or h < 20:
return return
cw = self._font.measure("M")
char_w = self._font.measure("M") ch = self._font.metrics("linespace")
char_h = self._font.metrics("linespace") if cw <= 0 or ch <= 0:
if char_w <= 0 or char_h <= 0:
return return
new_cols = max(20, (w - 8) // cw)
new_cols = max(20, (w - 8) // char_w) new_rows = max(4, (h - 8) // ch)
new_rows = max(4, (h - 8) // char_h)
if new_cols != self._cols or new_rows != self._rows: if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols self._cols = new_cols
self._rows = new_rows self._rows = new_rows