Files
server-manager/gui/widgets/terminal_widget.py
chrome-storm-c442 0d3c433bac v1.8.7: fix Ctrl+C/V with Russian keyboard layout
- 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>
2026-02-24 04:17:24 -05:00

939 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)