v1.5.5: terminal zoom, font fallback, copy feedback

- Ctrl+wheel/Ctrl±/Ctrl+0 font zoom (6-28), persisted in settings.json
- Font fallback: Cascadia Mono → Consolas → Courier New (per platform)
- Visual "Copied!" flash in status bar on copy
- Closes audit items #27 (copy feedback) and #29 (font fallback)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-02-23 15:17:51 -05:00
parent 5b46e0426e
commit b0b7d263fb
5 changed files with 132 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ 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
@@ -85,6 +86,28 @@ def _xterm_modifier(state: int) -> int:
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):
@@ -146,10 +169,12 @@ 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, **kwargs):
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
@@ -171,9 +196,13 @@ class TerminalWidget(tk.Frame):
# Dynamic color tag cache
self._dynamic_tags: dict[str, bool] = {}
# Fonts
self._font = tkfont.Font(family="Consolas", size=11)
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
# 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(
@@ -204,6 +233,14 @@ class TerminalWidget(tk.Frame):
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)
@@ -212,13 +249,18 @@ class TerminalWidget(tk.Frame):
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=("Consolas", 9), anchor="w", padx=6,
font=(self._font_family, 9), anchor="w", padx=6,
)
self._status_label.pack(fill="x")
@@ -230,7 +272,8 @@ class TerminalWidget(tk.Frame):
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"))
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,
@@ -243,6 +286,54 @@ class TerminalWidget(tk.Frame):
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."""
@@ -423,6 +514,13 @@ class TerminalWidget(tk.Frame):
# ── 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)
@@ -537,6 +635,7 @@ class TerminalWidget(tk.Frame):
if sel:
self.clipboard_clear()
self.clipboard_append(sel)
self._flash_status("Copied!", "#44cc44")
return "break"
except tk.TclError:
pass
@@ -552,6 +651,7 @@ class TerminalWidget(tk.Frame):
if sel:
self.clipboard_clear()
self.clipboard_append(sel)
self._flash_status("Copied!", "#44cc44")
except tk.TclError:
pass
return "break"
@@ -678,6 +778,14 @@ class TerminalWidget(tk.Frame):
self._text.configure(state="disabled")
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)