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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
releases/ServerManager-v1.5.5-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.5.5-win-x64.exe
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user