v1.5.0: network interface binding, SSH fixes, terminal, release script
- 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>
This commit is contained in:
400
gui/widgets/terminal_widget.py
Normal file
400
gui/widgets/terminal_widget.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user