- Add Cyrillic key bindings for Ctrl+C/V/D/L/Z and Ctrl+Shift+C/V - Russian layout: с=C, м=V, в=D, д=L, я=Z - Fixes: Ctrl+C (SIGINT/copy), Ctrl+V (paste) not working with РУС layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
939 lines
36 KiB
Python
939 lines
36 KiB
Python
"""
|
||
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("<Key>", self._on_key)
|
||
self._text.bind("<Control-c>", self._on_ctrl_c)
|
||
self._text.bind("<Control-v>", self._on_ctrl_v)
|
||
self._text.bind("<Control-d>", self._on_ctrl_d)
|
||
self._text.bind("<Control-l>", self._on_ctrl_l)
|
||
self._text.bind("<Control-z>", self._on_ctrl_z)
|
||
# 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")
|
||
# Russian keyboard layout: Cyrillic equivalents (Windows)
|
||
self._text.bind("<Control-\u0441>", self._on_ctrl_c) # с
|
||
self._text.bind("<Control-\u043c>", self._on_ctrl_v) # м (Ctrl+V → м)
|
||
self._text.bind("<Control-\u0432>", self._on_ctrl_d) # в (Ctrl+D → в)
|
||
self._text.bind("<Control-\u0434>", self._on_ctrl_l) # д (Ctrl+L → д)
|
||
self._text.bind("<Control-\u044f>", self._on_ctrl_z) # я (Ctrl+Z → я)
|
||
self._text.bind("<Control-Shift-\u0421>", self._on_copy) # С (Shift)
|
||
self._text.bind("<Control-Shift-\u041c>", self._on_ctrl_v) # М (Shift)
|
||
|
||
# ── 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)
|
||
|
||
# ── Zoom bindings (Ctrl+Plus/Minus/0, numpad) ──
|
||
self._text.bind("<Control-plus>", self._on_zoom_in)
|
||
self._text.bind("<Control-equal>", self._on_zoom_in)
|
||
self._text.bind("<Control-minus>", self._on_zoom_out)
|
||
self._text.bind("<Control-0>", self._on_zoom_reset)
|
||
self._text.bind("<Control-KP_Add>", self._on_zoom_in)
|
||
self._text.bind("<Control-KP_Subtract>", self._on_zoom_out)
|
||
|
||
# ── Resize ──
|
||
self._resize_after_id = None
|
||
self._text.bind("<Configure>", 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+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)
|