- Add network interface selection per server (VPN/multi-NIC support) - Fix "Install Everything" button hanging on error - Add interactive SSH terminal with PTY (pyte + xterm-256color) - Add release.py for automated versioning and changelog generation - Add CLAUDE.md with project instructions - Add screenshots and release binaries for v1.1–v1.4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
401 lines
13 KiB
Python
401 lines
13 KiB
Python
"""
|
|
Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors,
|
|
full keyboard mapping, diff-based rendering, and cursor display.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
import tkinter.font as tkfont
|
|
import pyte
|
|
|
|
|
|
# 16 standard ANSI 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"
|
|
|
|
# Key → VT100 escape sequence
|
|
_KEY_MAP = {
|
|
"Up": "\x1b[A",
|
|
"Down": "\x1b[B",
|
|
"Right": "\x1b[C",
|
|
"Left": "\x1b[D",
|
|
"Home": "\x1b[H",
|
|
"End": "\x1b[F",
|
|
"Insert": "\x1b[2~",
|
|
"Delete": "\x1b[3~",
|
|
"Prior": "\x1b[5~", # PageUp
|
|
"Next": "\x1b[6~", # PageDown
|
|
"F1": "\x1bOP",
|
|
"F2": "\x1bOQ",
|
|
"F3": "\x1bOR",
|
|
"F4": "\x1bOS",
|
|
"F5": "\x1b[15~",
|
|
"F6": "\x1b[17~",
|
|
"F7": "\x1b[18~",
|
|
"F8": "\x1b[19~",
|
|
"F9": "\x1b[20~",
|
|
"F10": "\x1b[21~",
|
|
"F11": "\x1b[23~",
|
|
"F12": "\x1b[24~",
|
|
}
|
|
|
|
|
|
class TerminalWidget(tk.Frame):
|
|
"""VT100 terminal emulator widget using pyte + tkinter.Text."""
|
|
|
|
def __init__(self, master, cols=80, rows=24, send_callback=None,
|
|
resize_callback=None, **kwargs):
|
|
super().__init__(master, bg=_DEFAULT_BG, **kwargs)
|
|
self.send_callback = send_callback
|
|
self.resize_callback = resize_callback
|
|
self._cols = cols
|
|
self._rows = rows
|
|
|
|
# pyte screen + stream
|
|
self._screen = pyte.Screen(cols, rows)
|
|
self._screen.set_mode(pyte.modes.LNM)
|
|
self._stream = pyte.Stream(self._screen)
|
|
|
|
# Previous buffer for diff rendering
|
|
self._prev_buffer: dict[int, list] = {}
|
|
|
|
# Font
|
|
self._font = tkfont.Font(family="Consolas", size=11)
|
|
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
|
|
|
|
# Text widget
|
|
self._text = tk.Text(
|
|
self,
|
|
bg=_DEFAULT_BG,
|
|
fg=_DEFAULT_FG,
|
|
font=self._font,
|
|
insertwidth=0,
|
|
highlightthickness=0,
|
|
borderwidth=0,
|
|
padx=4,
|
|
pady=4,
|
|
wrap="none",
|
|
cursor="xterm",
|
|
)
|
|
self._text.pack(fill="both", expand=True)
|
|
|
|
# Create color tags
|
|
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-a>", lambda e: "break")
|
|
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)
|
|
self._text.bind("<MouseWheel>", self._on_mousewheel)
|
|
|
|
# Resize handling
|
|
self._resize_after_id = None
|
|
self._text.bind("<Configure>", self._on_configure)
|
|
|
|
# Focus on click
|
|
self._text.bind("<Button-1>", lambda e: self._text.focus_set())
|
|
|
|
# Make text read-only (input goes through _on_key)
|
|
self._text.bind("<<Paste>>", lambda e: "break")
|
|
|
|
# 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=("Consolas", 9), anchor="w", padx=6,
|
|
)
|
|
self._status_label.pack(fill="x")
|
|
|
|
def _setup_tags(self):
|
|
"""Pre-create tags for all ANSI color combinations."""
|
|
for name, color in _ANSI_COLORS.items():
|
|
self._text.tag_configure(f"fg_{name}", foreground=color)
|
|
self._text.tag_configure(f"bg_{name}", background=color)
|
|
self._text.tag_configure("bold", font=self._bold_font)
|
|
self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic"))
|
|
self._text.tag_configure("underline", underline=True)
|
|
self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG)
|
|
self._text.tag_configure("default", foreground=_DEFAULT_FG)
|
|
|
|
def set_status(self, text: str, color: str = "#888888"):
|
|
self._status_label.configure(text=text, fg=color)
|
|
|
|
def feed(self, data: bytes):
|
|
"""Feed raw bytes from SSH into pyte and re-render."""
|
|
try:
|
|
text = data.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
text = data.decode("latin-1", errors="replace")
|
|
self._stream.feed(text)
|
|
self._render()
|
|
|
|
def reset(self):
|
|
"""Reset the terminal screen."""
|
|
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 (cols, rows) based on current widget size."""
|
|
return self._cols, self._rows
|
|
|
|
def _render(self):
|
|
"""Diff-based rendering: only update changed lines."""
|
|
self._text.configure(state="normal")
|
|
|
|
screen = self._screen
|
|
cursor = screen.cursor
|
|
|
|
for row in range(screen.lines):
|
|
line = screen.buffer[row]
|
|
# Build list of (char, attrs) for the row
|
|
current = []
|
|
for col in range(screen.columns):
|
|
char_data = line[col]
|
|
current.append((char_data.data, char_data.fg, char_data.bg,
|
|
char_data.bold, char_data.italics, char_data.underscore))
|
|
|
|
prev = self._prev_buffer.get(row)
|
|
is_cursor_row = (row == cursor.y)
|
|
prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row
|
|
|
|
if current == prev and not is_cursor_row and not prev_was_cursor_row:
|
|
continue
|
|
|
|
self._prev_buffer[row] = current
|
|
|
|
# Delete the line
|
|
line_start = f"{row + 1}.0"
|
|
line_end = f"{row + 1}.end"
|
|
self._text.delete(line_start, line_end)
|
|
|
|
# Ensure enough lines exist
|
|
while int(self._text.index("end-1c").split(".")[0]) <= row:
|
|
self._text.insert("end", "\n")
|
|
|
|
# Build line content with batched tags
|
|
col = 0
|
|
while col < len(current):
|
|
ch, fg, bg, bold, italic, underline = current[col]
|
|
|
|
# Batch consecutive chars with same attributes
|
|
batch = [ch]
|
|
while col + len(batch) < len(current):
|
|
nc = current[col + len(batch)]
|
|
if nc[1:] == (fg, bg, bold, italic, underline):
|
|
batch.append(nc[0])
|
|
else:
|
|
break
|
|
|
|
text = "".join(batch)
|
|
tags = self._make_tags(fg, bg, bold, italic, underline)
|
|
|
|
# Apply cursor tag
|
|
if is_cursor_row:
|
|
if col <= cursor.x < col + len(batch):
|
|
# Split at cursor position
|
|
pre_len = cursor.x - col
|
|
if pre_len > 0:
|
|
self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags)
|
|
cursor_tags = ("cursor",) + tags
|
|
self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags)
|
|
post = text[pre_len + 1:]
|
|
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_lines = int(self._text.index("end-1c").split(".")[0])
|
|
if total_lines > screen.lines:
|
|
self._text.delete(f"{screen.lines + 1}.0", "end")
|
|
|
|
self._prev_cursor_y = cursor.y
|
|
self._text.configure(state="disabled")
|
|
|
|
def _make_tags(self, fg, bg, bold, italic, underline) -> tuple:
|
|
tags = []
|
|
fg_color = self._resolve_color(fg, is_fg=True)
|
|
if fg_color:
|
|
tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None
|
|
if tag and tag.replace("fg_", "") in _ANSI_COLORS:
|
|
tags.append(tag)
|
|
bg_color = self._resolve_color(bg, is_fg=False)
|
|
if bg_color:
|
|
tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None
|
|
if tag and tag.replace("bg_", "") in _ANSI_COLORS:
|
|
tags.append(tag)
|
|
if bold:
|
|
tags.append("bold")
|
|
if italic:
|
|
tags.append("italic")
|
|
if underline:
|
|
tags.append("underline")
|
|
return tuple(tags)
|
|
|
|
def _resolve_color(self, color, is_fg=True):
|
|
if not color or color == "default":
|
|
return None
|
|
if color in _ANSI_COLORS:
|
|
return color
|
|
# pyte may return color names like "red", "green" etc directly
|
|
return None
|
|
|
|
def _on_key(self, event):
|
|
"""Handle keyboard input and send to SSH."""
|
|
# Ignore modifier-only keys
|
|
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
|
|
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
|
|
"Caps_Lock", "Num_Lock"):
|
|
return "break"
|
|
|
|
# Ctrl+key combinations
|
|
if event.state & 0x4: # Control
|
|
if event.keysym.lower() in ("c", "v", "d", "l", "z"):
|
|
return # handled by specific bindings
|
|
ch = event.keysym.lower()
|
|
if len(ch) == 1 and "a" <= ch <= "z":
|
|
self._send(bytes([ord(ch) - ord("a") + 1]))
|
|
return "break"
|
|
|
|
# Special keys
|
|
if event.keysym in _KEY_MAP:
|
|
self._send(_KEY_MAP[event.keysym].encode())
|
|
return "break"
|
|
|
|
# Tab
|
|
if event.keysym == "Tab":
|
|
self._send(b"\t")
|
|
return "break"
|
|
|
|
# Return / 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"
|
|
|
|
# Regular character
|
|
if event.char and ord(event.char) >= 32:
|
|
self._send(event.char.encode("utf-8"))
|
|
return "break"
|
|
|
|
return "break"
|
|
|
|
def _on_ctrl_c(self, event):
|
|
# Check if text is selected — if so, copy
|
|
try:
|
|
sel = self._text.get("sel.first", "sel.last")
|
|
if sel:
|
|
self.clipboard_clear()
|
|
self.clipboard_append(sel)
|
|
return "break"
|
|
except tk.TclError:
|
|
pass
|
|
# No selection — send SIGINT
|
|
self._send(b"\x03")
|
|
return "break"
|
|
|
|
def _on_ctrl_v(self, event):
|
|
try:
|
|
text = self.clipboard_get()
|
|
if text:
|
|
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"
|
|
|
|
def _on_mousewheel(self, event):
|
|
# Send scroll sequences for programs like less/man
|
|
if event.delta > 0:
|
|
self._send(b"\x1b[5~") # PageUp
|
|
else:
|
|
self._send(b"\x1b[6~") # PageDown
|
|
return "break"
|
|
|
|
def _send(self, data: bytes):
|
|
if self.send_callback:
|
|
self.send_callback(data)
|
|
|
|
def _on_configure(self, event):
|
|
"""Debounced resize handler."""
|
|
if self._resize_after_id:
|
|
self.after_cancel(self._resize_after_id)
|
|
self._resize_after_id = self.after(200, self._do_resize)
|
|
|
|
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
|
|
|
|
char_w = self._font.measure("M")
|
|
char_h = self._font.metrics("linespace")
|
|
if char_w <= 0 or char_h <= 0:
|
|
return
|
|
|
|
new_cols = max(20, (w - 8) // char_w)
|
|
new_rows = max(4, (h - 8) // char_h)
|
|
|
|
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)
|