diff --git a/core/server_store.py b/core/server_store.py index 9615e1a..b1fec19 100644 --- a/core/server_store.py +++ b/core/server_store.py @@ -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() diff --git a/gui/tabs/terminal_tab.py b/gui/tabs/terminal_tab.py index 2a89cd0..7a36c98 100644 --- a/gui/tabs/terminal_tab.py +++ b/gui/tabs/terminal_tab.py @@ -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) diff --git a/gui/widgets/terminal_widget.py b/gui/widgets/terminal_widget.py index 7f18a11..a897610 100644 --- a/gui/widgets/terminal_widget.py +++ b/gui/widgets/terminal_widget.py @@ -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("", self._on_mousewheel) self._text.bind("", self._on_double_click) + # ── Zoom bindings (Ctrl+Plus/Minus/0, numpad) ── + self._text.bind("", self._on_zoom_in) + self._text.bind("", self._on_zoom_in) + self._text.bind("", self._on_zoom_out) + self._text.bind("", self._on_zoom_reset) + self._text.bind("", self._on_zoom_in) + self._text.bind("", self._on_zoom_out) + # ── Resize ── self._resize_after_id = None self._text.bind("", 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) diff --git a/releases/ServerManager-v1.5.5-win-x64.exe b/releases/ServerManager-v1.5.5-win-x64.exe new file mode 100644 index 0000000..1ce4e7b Binary files /dev/null and b/releases/ServerManager-v1.5.5-win-x64.exe differ diff --git a/version.py b/version.py index 2b021ec..f6ddec8 100644 --- a/version.py +++ b/version.py @@ -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"