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

@@ -49,6 +49,7 @@ class ServerStore:
self._statuses_lock = threading.Lock() self._statuses_lock = threading.Lock()
self._file_lock = threading.Lock() self._file_lock = threading.Lock()
self._last_backup_time: float = 0 self._last_backup_time: float = 0
self._terminal_font_size: int = 11
self._servers_file: str = DEFAULT_SERVERS_FILE self._servers_file: str = DEFAULT_SERVERS_FILE
self._load_settings() self._load_settings()
self._load() self._load()
@@ -68,6 +69,7 @@ class ServerStore:
lang = settings.get("language", "en") lang = settings.get("language", "en")
i18n.set_language(lang) i18n.set_language(lang)
self._check_interval = settings.get("check_interval", 60) self._check_interval = settings.get("check_interval", 60)
self._terminal_font_size = settings.get("terminal_font_size", 11)
except json.JSONDecodeError: except json.JSONDecodeError:
log.warning("Corrupted settings.json, using defaults") log.warning("Corrupted settings.json, using defaults")
except Exception as e: except Exception as e:
@@ -80,6 +82,7 @@ class ServerStore:
"servers_path": self._servers_file, "servers_path": self._servers_file,
"language": i18n.get_language(), "language": i18n.get_language(),
"check_interval": self._check_interval, "check_interval": self._check_interval,
"terminal_font_size": self._terminal_font_size,
} }
try: try:
tmp = SETTINGS_FILE + ".tmp" tmp = SETTINGS_FILE + ".tmp"
@@ -301,3 +304,12 @@ class ServerStore:
def get_status(self, alias: str) -> str: def get_status(self, alias: str) -> str:
with self._statuses_lock: with self._statuses_lock:
return self._statuses.get(alias, "unknown") return self._statuses.get(alias, "unknown")
# ── Terminal font size ────────────────────────────────
def get_terminal_font_size(self) -> int:
return self._terminal_font_size
def set_terminal_font_size(self, size: int):
self._terminal_font_size = max(6, min(28, size))
self._save_settings()

View File

@@ -27,6 +27,8 @@ class TerminalTab(ctk.CTkFrame):
self, self,
send_callback=self._send_to_shell, send_callback=self._send_to_shell,
resize_callback=self._on_resize, resize_callback=self._on_resize,
font_size=store.get_terminal_font_size(),
on_font_size_changed=self._on_font_size_changed,
) )
self._terminal.pack(fill="both", expand=True, padx=5, pady=5) self._terminal.pack(fill="both", expand=True, padx=5, pady=5)
self._terminal.set_status(t("term_disconnected"), "#888888") self._terminal.set_status(t("term_disconnected"), "#888888")
@@ -145,3 +147,6 @@ class TerminalTab(ctk.CTkFrame):
session = self._session # local ref for thread safety session = self._session # local ref for thread safety
if session and session.connected: if session and session.connected:
session.resize(cols, rows) session.resize(cols, rows)
def _on_font_size_changed(self, size: int):
self.store.set_terminal_font_size(size)

View File

@@ -6,6 +6,7 @@ DECCKM, bracketed paste, mouse tracking, and professional copy/paste UX.
import codecs import codecs
import copy import copy
import sys
import time import time
import tkinter as tk import tkinter as tk
import tkinter.font as tkfont import tkinter.font as tkfont
@@ -85,6 +86,28 @@ def _xterm_modifier(state: int) -> int:
return mod 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 ──────────────────────────────────────────────── # ── Alternate screen buffer ────────────────────────────────────────────────
class _Screen(pyte.Screen): class _Screen(pyte.Screen):
@@ -146,10 +169,12 @@ class TerminalWidget(tk.Frame):
"""Full-featured VT100/xterm terminal emulator widget.""" """Full-featured VT100/xterm terminal emulator widget."""
def __init__(self, master, cols=80, rows=24, send_callback=None, 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) super().__init__(master, bg=_DEFAULT_BG, **kwargs)
self.send_callback = send_callback self.send_callback = send_callback
self.resize_callback = resize_callback self.resize_callback = resize_callback
self.on_font_size_changed = on_font_size_changed
self._cols = cols self._cols = cols
self._rows = rows self._rows = rows
@@ -171,9 +196,13 @@ class TerminalWidget(tk.Frame):
# Dynamic color tag cache # Dynamic color tag cache
self._dynamic_tags: dict[str, bool] = {} self._dynamic_tags: dict[str, bool] = {}
# Fonts # Fonts (with fallback + zoom support)
self._font = tkfont.Font(family="Consolas", size=11) self._font_family = _pick_font_family()
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold") 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 # Text widget
self._text = tk.Text( self._text = tk.Text(
@@ -204,6 +233,14 @@ class TerminalWidget(tk.Frame):
self._text.bind("<MouseWheel>", self._on_mousewheel) self._text.bind("<MouseWheel>", self._on_mousewheel)
self._text.bind("<Double-Button-1>", self._on_double_click) 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 ── # ── Resize ──
self._resize_after_id = None self._resize_after_id = None
self._text.bind("<Configure>", self._on_configure) self._text.bind("<Configure>", self._on_configure)
@@ -212,13 +249,18 @@ class TerminalWidget(tk.Frame):
self._selecting = False self._selecting = False
self._last_ctrl_c: float = 0.0 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 ── # ── Status bar ──
self._status_frame = tk.Frame(self, bg="#2d2d44", height=22) self._status_frame = tk.Frame(self, bg="#2d2d44", height=22)
self._status_frame.pack(fill="x", side="bottom") self._status_frame.pack(fill="x", side="bottom")
self._status_frame.pack_propagate(False) self._status_frame.pack_propagate(False)
self._status_label = tk.Label( self._status_label = tk.Label(
self._status_frame, text="", fg="#888888", bg="#2d2d44", 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") 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(f"bg_{name}", background=color)
self._text.tag_configure("bold", font=self._bold_font) self._text.tag_configure("bold", font=self._bold_font)
self._text.tag_configure("italic", 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("underline", underline=True)
self._text.tag_configure("strikethrough", overstrike=True) self._text.tag_configure("strikethrough", overstrike=True)
self._text.tag_configure("cursor", foreground=_CURSOR_FG, 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"): def set_status(self, text: str, color: str = "#888888"):
self._status_label.configure(text=text, fg=color) 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): def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte, schedule debounced render.""" """Feed raw bytes from SSH into pyte, schedule debounced render."""
@@ -423,6 +514,13 @@ class TerminalWidget(tk.Frame):
# ── Ctrl+key ── # ── Ctrl+key ──
if ctrl: if ctrl:
ks = event.keysym.lower() 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 # Handled by dedicated bindings
if ks in ("c", "v", "d", "l", "z"): if ks in ("c", "v", "d", "l", "z"):
# Ctrl+Shift+C/V → copy/paste (handled separately) # Ctrl+Shift+C/V → copy/paste (handled separately)
@@ -537,6 +635,7 @@ class TerminalWidget(tk.Frame):
if sel: if sel:
self.clipboard_clear() self.clipboard_clear()
self.clipboard_append(sel) self.clipboard_append(sel)
self._flash_status("Copied!", "#44cc44")
return "break" return "break"
except tk.TclError: except tk.TclError:
pass pass
@@ -552,6 +651,7 @@ class TerminalWidget(tk.Frame):
if sel: if sel:
self.clipboard_clear() self.clipboard_clear()
self.clipboard_append(sel) self.clipboard_append(sel)
self._flash_status("Copied!", "#44cc44")
except tk.TclError: except tk.TclError:
pass pass
return "break" return "break"
@@ -678,6 +778,14 @@ class TerminalWidget(tk.Frame):
self._text.configure(state="disabled") self._text.configure(state="disabled")
def _on_mousewheel(self, event): 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(): if self._mouse_tracking_active():
col, row = self._pixel_to_cell(event.x, event.y) col, row = self._pixel_to_cell(event.x, event.y)
# Mouse wheel: button 64 (up) or 65 (down) # Mouse wheel: button 64 (up) or 65 (down)

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager.""" """Version info for ServerManager."""
__version__ = "1.5.4" __version__ = "1.5.5"
__app_name__ = "ServerManager" __app_name__ = "ServerManager"
__author__ = "aibot777" __author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers" __description__ = "Desktop GUI for managing remote servers"