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._file_lock = threading.Lock()
self._last_backup_time: float = 0
self._terminal_font_size: int = 11
self._servers_file: str = DEFAULT_SERVERS_FILE
self._load_settings()
self._load()
@@ -68,6 +69,7 @@ class ServerStore:
lang = settings.get("language", "en")
i18n.set_language(lang)
self._check_interval = settings.get("check_interval", 60)
self._terminal_font_size = settings.get("terminal_font_size", 11)
except json.JSONDecodeError:
log.warning("Corrupted settings.json, using defaults")
except Exception as e:
@@ -80,6 +82,7 @@ class ServerStore:
"servers_path": self._servers_file,
"language": i18n.get_language(),
"check_interval": self._check_interval,
"terminal_font_size": self._terminal_font_size,
}
try:
tmp = SETTINGS_FILE + ".tmp"
@@ -301,3 +304,12 @@ class ServerStore:
def get_status(self, alias: str) -> str:
with self._statuses_lock:
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,
send_callback=self._send_to_shell,
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.set_status(t("term_disconnected"), "#888888")
@@ -145,3 +147,6 @@ class TerminalTab(ctk.CTkFrame):
session = self._session # local ref for thread safety
if session and session.connected:
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 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)

Binary file not shown.

View File

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