v1.9.1: PNG Material Design icons — 28 icons, dark/light theme, HiDPI, graceful Unicode fallback

- 56 PNG icons (28 unique × 2 color variants) from Material Design Icons (round style, 96×96px)
- core/icons.py: ctk_icon(), make_icon_button(), reconfigure_icon_button() with CTkImage cache
- Updated 15 GUI files: app.py, sidebar.py, server_dialog.py, all tabs
- build.py: auto-include assets/icons/ in PyInstaller bundle, patch rollover at 99→minor+1
- tools/download_icons.py: icon download script
- Automatic dark↔light theme switching via CTkImage dual-image support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-03-03 07:27:49 -05:00
parent 9b0e4c76a3
commit 1e729fcf3a
76 changed files with 397 additions and 148 deletions

BIN
assets/icons/dark/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icons/dark/check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

BIN
assets/icons/dark/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

BIN
assets/icons/dark/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

BIN
assets/icons/dark/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

BIN
assets/icons/dark/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/icons/dark/lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/icons/dark/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icons/light/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

BIN
assets/icons/light/code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

BIN
assets/icons/light/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

BIN
assets/icons/light/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/icons/light/lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/icons/light/save.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -36,7 +36,13 @@ def auto_bump_version() -> str:
sys.exit(1) sys.exit(1)
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
new_patch = patch + 1
# Patch grows up to 99, then minor+1 and patch resets to 1
if patch >= 99:
minor += 1
new_patch = 1
else:
new_patch = patch + 1
new_version = f"{major}.{minor}.{new_patch}" new_version = f"{major}.{minor}.{new_patch}"
content = re.sub( content = re.sub(
@@ -111,6 +117,13 @@ def build():
"--add-data", f"core/encryption.py{os.pathsep}core", "--add-data", f"core/encryption.py{os.pathsep}core",
] ]
# PNG icons for GUI (Material Design)
icons_dir = os.path.join(PROJECT_DIR, "assets", "icons")
if os.path.isdir(icons_dir):
cmd_parts.extend(["--add-data", f"assets/icons{os.pathsep}assets/icons"])
else:
print("WARNING: assets/icons/ not found, building without PNG icons")
# Icon # Icon
icon_path = os.path.join(PROJECT_DIR, "assets", "icon.ico") icon_path = os.path.join(PROJECT_DIR, "assets", "icon.ico")
if os.path.exists(icon_path): if os.path.exists(icon_path):
@@ -194,6 +207,47 @@ def _get_gitea_auth() -> dict:
_GITEA_API = "https://git.sensey24.ru/api/v1/repos/aibot777/server-manager" _GITEA_API = "https://git.sensey24.ru/api/v1/repos/aibot777/server-manager"
def _generate_changelog() -> str:
"""Generate changelog from git commits since previous tag."""
try:
# Get all tags sorted by semver
raw = subprocess.check_output(
["git", "tag", "--list", "v*"],
text=True, stderr=subprocess.DEVNULL,
).strip()
tags = [t for t in raw.splitlines() if re.match(r'^v\d+\.\d+\.\d+$', t)]
tags.sort(key=lambda t: tuple(int(x) for x in t[1:].split(".")))
current_tag = f"v{__version__}"
# Find previous tag (exclude current if it exists)
prev_tags = [t for t in tags if t != current_tag]
if prev_tags:
prev_tag = prev_tags[-1]
log_range = f"{prev_tag}..HEAD"
else:
log_range = "HEAD~20..HEAD"
# Get commits
raw_log = subprocess.check_output(
["git", "log", log_range, "--oneline", "--no-merges"],
text=True, stderr=subprocess.DEVNULL,
).strip()
if not raw_log:
return f"Release {current_tag}"
# Format: strip commit hash, keep message
lines = []
for line in raw_log.splitlines():
parts = line.split(" ", 1)
if len(parts) == 2:
lines.append(f"- {parts[1]}")
return f"## What's New in {current_tag}\n\n" + "\n".join(lines)
except Exception as exc:
print(f"Changelog generation failed: {exc}")
return f"Release v{__version__}"
def publish_gitea_release(exe_path: str): def publish_gitea_release(exe_path: str):
"""Create a Gitea release and upload the exe as asset.""" """Create a Gitea release and upload the exe as asset."""
auth = _get_gitea_auth() auth = _get_gitea_auth()
@@ -203,13 +257,14 @@ def publish_gitea_release(exe_path: str):
tag = f"v{__version__}" tag = f"v{__version__}"
filename = os.path.basename(exe_path) filename = os.path.basename(exe_path)
changelog = _generate_changelog()
# Create release # Create release
try: try:
data = json.dumps({ data = json.dumps({
"tag_name": tag, "tag_name": tag,
"name": tag, "name": tag,
"body": f"Release {tag}", "body": changelog,
}).encode() }).encode()
req = urllib.request.Request( req = urllib.request.Request(
f"{_GITEA_API}/releases", f"{_GITEA_API}/releases",

View File

@@ -1,8 +1,42 @@
""" """
Icon registry — semantic Unicode symbols for all GUI elements. Icon registry — semantic Unicode symbols + PNG Material Design icons.
Centralized icon management for buttons, tabs, menus, and type badges. Centralized icon management for buttons, tabs, menus, and type badges.
PNG icons (assets/icons/dark/ + light/) auto-switch with CTk dark/light theme.
If PNG files or PIL are missing, all functions gracefully fall back to Unicode.
""" """
import os
import sys
from typing import Optional
# ── Asset path resolution ──────────────────────────────
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
_ASSETS_DIR = os.path.join(sys._MEIPASS, "assets", "icons")
else:
_ASSETS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "icons",
)
_HAS_PNG = os.path.isdir(_ASSETS_DIR)
_HAS_PIL: Optional[bool] = None # lazy-checked on first ctk_icon() call
# ── Semantic name → Material icon filename ─────────────
ICON_FILES = {
"back": "arrow_back", "up": "arrow_upward", "refresh": "refresh",
"add": "add", "edit": "edit", "delete": "delete", "confirm": "check",
"upload": "file_upload", "download": "file_download",
"execute": "play_arrow", "info": "info", "clear": "backspace",
"search": "search", "folder": "folder", "folder_open": "folder_open",
"save": "save", "key": "vpn_key", "lock": "lock", "eye": "visibility",
"copy": "content_copy", "gear": "settings", "globe": "language",
"terminal": "code", "query": "play_arrow", "dashboards": "dashboard",
"metrics": "trending_up", "powershell": "code", "launch": "computer",
"totp": "lock", "objects": "storage", "connect": "play_arrow",
"browser": "language", "close": "close",
}
# Semantic icon mapping # Semantic icon mapping
ICONS = { ICONS = {
# Navigation # Navigation
@@ -179,3 +213,83 @@ CTX_ICONS = {
"edit": "edit", "edit": "edit",
"delete": "delete", "delete": "delete",
} }
# ── CTkImage cache + loader ────────────────────────────
_icon_cache: dict[tuple, object] = {}
def ctk_icon(name: str, size: int = 20) -> Optional[object]:
"""PNG icon as CTkImage, or None (fallback to Unicode).
Lazy-imports PIL and customtkinter on first call so that
icons.py stays usable as a pure data module for CLI tools.
"""
global _HAS_PIL
if _HAS_PIL is None:
try:
from PIL import Image as _img # noqa: F401
import customtkinter as _ctk # noqa: F401
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
if not _HAS_PIL or not _HAS_PNG:
return None
cache_key = (name, size)
if cache_key in _icon_cache:
return _icon_cache[cache_key]
file_stem = ICON_FILES.get(name)
if not file_stem:
return None
from PIL import Image
import customtkinter as ctk
dark_path = os.path.join(_ASSETS_DIR, "dark", f"{file_stem}.png")
light_path = os.path.join(_ASSETS_DIR, "light", f"{file_stem}.png")
if not os.path.exists(dark_path) or not os.path.exists(light_path):
return None
try:
light_img = Image.open(light_path) # black icons for light bg
dark_img = Image.open(dark_path) # white icons for dark bg
result = ctk.CTkImage(
light_image=light_img, dark_image=dark_img,
size=(size, size),
)
_icon_cache[cache_key] = result
return result
except Exception:
return None
def make_icon_button(parent, icon_name: str, label: str,
icon_size: int = 16, **kwargs):
"""CTkButton with PNG icon or Unicode fallback."""
import customtkinter as _ctk
img = ctk_icon(icon_name, icon_size)
if img:
return _ctk.CTkButton(parent, text=label, image=img,
compound="left", **kwargs)
return _ctk.CTkButton(parent, text=icon_text(icon_name, label), **kwargs)
def make_icon_label(parent, icon_name: str, icon_size: int = 18, **kwargs):
"""CTkLabel with PNG icon or Unicode fallback."""
import customtkinter as _ctk
img = ctk_icon(icon_name, icon_size)
if img:
return _ctk.CTkLabel(parent, text="", image=img, **kwargs)
return _ctk.CTkLabel(parent, text=ICONS.get(icon_name, ""), **kwargs)
def reconfigure_icon_button(btn, icon_name: str, label: str,
icon_size: int = 16):
"""Update existing button text + image (for update_language)."""
img = ctk_icon(icon_name, icon_size)
if img:
btn.configure(text=label, image=img)
else:
btn.configure(text=icon_text(icon_name, label))

View File

@@ -11,7 +11,7 @@ from core.status_checker import StatusChecker
from core.updater import UpdateChecker from core.updater import UpdateChecker
from core import i18n from core import i18n
from core.i18n import t, LANGUAGES from core.i18n import t, LANGUAGES
from core.icons import icon, TAB_ICONS from core.icons import icon, TAB_ICONS, ctk_icon
from core.session_pool import SessionPool from core.session_pool import SessionPool
from gui.sidebar import Sidebar from gui.sidebar import Sidebar
from gui.server_dialog import ServerDialog from gui.server_dialog import ServerDialog
@@ -147,7 +147,11 @@ class App(ctk.CTk):
header_bar.pack_propagate(False) header_bar.pack_propagate(False)
# Language selector # Language selector
self._lang_icon = ctk.CTkLabel(header_bar, text="\U0001f310", font=ctk.CTkFont(size=14), width=20) _lang_img = ctk_icon("globe", 18)
self._lang_icon = ctk.CTkLabel(
header_bar, text="" if _lang_img else "\U0001f310",
image=_lang_img, font=ctk.CTkFont(size=14), width=20,
)
self._lang_icon.pack(side="right", padx=(5, 0)) self._lang_icon.pack(side="right", padx=(5, 0))
lang_values = list(LANGUAGES.values()) lang_values = list(LANGUAGES.values())
current_display = LANGUAGES.get(i18n.get_language(), "English") current_display = LANGUAGES.get(i18n.get_language(), "English")
@@ -159,16 +163,20 @@ class App(ctk.CTk):
self.lang_menu.pack(side="right", padx=(5, 0)) self.lang_menu.pack(side="right", padx=(5, 0))
# Check Updates button # Check Updates button
_sync_img = ctk_icon("refresh", 18)
self._update_check_btn = ctk.CTkButton( self._update_check_btn = ctk.CTkButton(
header_bar, text="\u21bb", width=30, height=30, header_bar, text="" if _sync_img else "\u21bb",
image=_sync_img, width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563", corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._check_updates_manual, command=self._check_updates_manual,
) )
self._update_check_btn.pack(side="right", padx=(5, 0)) self._update_check_btn.pack(side="right", padx=(5, 0))
# About button # About button
_info_img = ctk_icon("info", 18)
self.about_btn = ctk.CTkButton( self.about_btn = ctk.CTkButton(
header_bar, text="", width=30, height=30, header_bar, text="" if _info_img else "",
image=_info_img, width=30, height=30,
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563", corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
command=self._show_about command=self._show_about
) )

View File

@@ -6,7 +6,7 @@ Form adapts visible fields based on selected server type.
import customtkinter as ctk import customtkinter as ctk
from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.server_store import SERVER_TYPES, DEFAULT_PORTS
from core.i18n import t from core.i18n import t
from core.icons import icon_text, type_display, type_from_display from core.icons import icon_text, type_display, type_from_display, make_icon_button, reconfigure_icon_button
# Which conditional fields to show for each server type. # Which conditional fields to show for each server type.
@@ -160,7 +160,7 @@ class ServerDialog(ctk.CTkToplevel):
pass_inner.pack(fill="x", padx=20, pady=(2, 5)) pass_inner.pack(fill="x", padx=20, pady=(2, 5))
self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password")) self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password"))
self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_pass = ctk.CTkButton(pass_inner, text=icon_text("eye", t("show")), width=70, command=self._toggle_password) self.show_pass = make_icon_button(pass_inner, "eye", t("show"), width=70, command=self._toggle_password)
self.show_pass.pack(side="right") self.show_pass.pack(side="right")
self._pass_visible = False self._pass_visible = False
self._field_frames["password"] = f self._field_frames["password"] = f
@@ -286,8 +286,8 @@ class ServerDialog(ctk.CTkToplevel):
# ── Always visible: Buttons ── # ── Always visible: Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 20)) btn_frame.pack(fill="x", padx=20, pady=(15, 20))
ctk.CTkButton(btn_frame, text=icon_text("delete", t("cancel")), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5)) make_icon_button(btn_frame, "close", t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5))
ctk.CTkButton(btn_frame, text=icon_text("confirm", t("save")), command=self._save).pack(side="right", expand=True, padx=(5, 0)) make_icon_button(btn_frame, "confirm", t("save"), command=self._save).pack(side="right", expand=True, padx=(5, 0))
# Fill values if editing # Fill values if editing
if server: if server:
@@ -360,7 +360,7 @@ class ServerDialog(ctk.CTkToplevel):
def _toggle_password(self): def _toggle_password(self):
self._pass_visible = not self._pass_visible self._pass_visible = not self._pass_visible
self.password_entry.configure(show="" if self._pass_visible else "*") self.password_entry.configure(show="" if self._pass_visible else "*")
self.show_pass.configure(text=icon_text("eye", t("hide") if self._pass_visible else t("show"))) reconfigure_icon_button(self.show_pass, "eye", t("hide") if self._pass_visible else t("show"))
def _save(self): def _save(self):
alias = self.alias_entry.get().strip() alias = self.alias_entry.get().strip()

View File

@@ -8,6 +8,7 @@ import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import ( from core.icons import (
icon_text, TYPE_COLORS, TYPE_LABELS, CTX_ICONS, icon, icon_text, TYPE_COLORS, TYPE_LABELS, CTX_ICONS, icon,
make_icon_button, reconfigure_icon_button,
) )
from gui.widgets.status_badge import StatusBadge from gui.widgets.status_badge import StatusBadge
@@ -80,11 +81,11 @@ class Sidebar(ctk.CTkFrame):
# Buttons # Buttons
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=10, pady=10) btn_frame.pack(fill="x", padx=10, pady=10)
self.add_btn = ctk.CTkButton(btn_frame, text=icon_text("add", t("add")), width=70, height=30, command=self._on_add) self.add_btn = make_icon_button(btn_frame, "add", t("add"), width=70, height=30, command=self._on_add)
self.add_btn.pack(side="left", padx=(0, 3)) self.add_btn.pack(side="left", padx=(0, 3))
self.edit_btn = ctk.CTkButton(btn_frame, text=icon_text("edit", t("edit")), width=70, height=30, fg_color="#6b7280", command=self._on_edit) self.edit_btn = make_icon_button(btn_frame, "edit", t("edit"), width=70, height=30, fg_color="#6b7280", command=self._on_edit)
self.edit_btn.pack(side="left", padx=3) self.edit_btn.pack(side="left", padx=3)
self.del_btn = ctk.CTkButton(btn_frame, text=icon_text("delete", t("delete")), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete) self.del_btn = make_icon_button(btn_frame, "delete", t("delete"), width=70, height=30, fg_color="#ef4444", hover_color="#dc2626", command=self._on_delete)
self.del_btn.pack(side="right", padx=(3, 0)) self.del_btn.pack(side="right", padx=(3, 0))
# Callbacks — set by app.py # Callbacks — set by app.py
@@ -103,9 +104,9 @@ class Sidebar(ctk.CTkFrame):
def update_language(self): def update_language(self):
self.title_label.configure(text=t("servers")) self.title_label.configure(text=t("servers"))
self.search_entry.configure(placeholder_text=t("search")) self.search_entry.configure(placeholder_text=t("search"))
self.add_btn.configure(text=icon_text("add", t("add"))) reconfigure_icon_button(self.add_btn, "add", t("add"))
self.edit_btn.configure(text=icon_text("edit", t("edit"))) reconfigure_icon_button(self.edit_btn, "edit", t("edit"))
self.del_btn.configure(text=icon_text("delete", t("delete"))) reconfigure_icon_button(self.del_btn, "delete", t("delete"))
self._update_sessions_label() self._update_sessions_label()
# ── Refresh / Render ────────────────────────────── # ── Refresh / Render ──────────────────────────────

View File

@@ -13,7 +13,7 @@ from tkinter import messagebox, filedialog
import customtkinter as ctk import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, ctk_icon, make_icon_button
from core.ssh_client import SFTPSession from core.ssh_client import SFTPSession
from gui.widgets.file_list import FileListWidget from gui.widgets.file_list import FileListWidget
@@ -90,28 +90,34 @@ class FilesTab(ctk.CTkFrame):
ctk.CTkLabel(left_header, text=t("local_files"), ctk.CTkLabel(left_header, text=t("local_files"),
font=ctk.CTkFont(size=13, weight="bold")).pack(side="left") font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
_back_img = ctk_icon("back", 16)
self._local_back_btn = ctk.CTkButton( self._local_back_btn = ctk.CTkButton(
left_header, text="\u2190", width=30, height=28, left_header, text="" if _back_img else "\u2190",
image=_back_img, width=30, height=28,
command=self._local_go_back, command=self._local_go_back,
) )
self._local_back_btn.pack(side="left", padx=(8, 2)) self._local_back_btn.pack(side="left", padx=(8, 2))
_up_img = ctk_icon("up", 16)
self._local_up_btn = ctk.CTkButton( self._local_up_btn = ctk.CTkButton(
left_header, text="\u2191", width=30, height=28, left_header, text="" if _up_img else "\u2191",
image=_up_img, width=30, height=28,
command=self._local_go_up, command=self._local_go_up,
) )
self._local_up_btn.pack(side="left", padx=2) self._local_up_btn.pack(side="left", padx=2)
# Local refresh button # Local refresh button
_ref_img = ctk_icon("refresh", 16)
self._local_refresh_btn = ctk.CTkButton( self._local_refresh_btn = ctk.CTkButton(
left_header, text="\u21BB", width=30, height=28, left_header, text="" if _ref_img else "\u21BB",
image=_ref_img, width=30, height=28,
command=self._refresh_local, command=self._refresh_local,
) )
self._local_refresh_btn.pack(side="left", padx=2) self._local_refresh_btn.pack(side="left", padx=2)
# Browse button # Browse button
self._browse_btn = ctk.CTkButton( self._browse_btn = make_icon_button(
left_header, text=icon_text("folder_open", t("browse")), width=75, height=28, left_header, "folder_open", t("browse"), width=75, height=28,
command=self._browse_local, command=self._browse_local,
) )
self._browse_btn.pack(side="left", padx=2) self._browse_btn.pack(side="left", padx=2)
@@ -158,19 +164,22 @@ class FilesTab(ctk.CTkFrame):
font=ctk.CTkFont(size=13, weight="bold")).pack(side="left") font=ctk.CTkFont(size=13, weight="bold")).pack(side="left")
self._remote_back_btn = ctk.CTkButton( self._remote_back_btn = ctk.CTkButton(
right_header, text="\u2190", width=30, height=28, right_header, text="" if _back_img else "\u2190",
image=_back_img, width=30, height=28,
command=self._remote_go_back, command=self._remote_go_back,
) )
self._remote_back_btn.pack(side="left", padx=(8, 2)) self._remote_back_btn.pack(side="left", padx=(8, 2))
self._remote_up_btn = ctk.CTkButton( self._remote_up_btn = ctk.CTkButton(
right_header, text="\u2191", width=30, height=28, right_header, text="" if _up_img else "\u2191",
image=_up_img, width=30, height=28,
command=self._remote_go_up, command=self._remote_go_up,
) )
self._remote_up_btn.pack(side="left", padx=2) self._remote_up_btn.pack(side="left", padx=2)
self._remote_refresh_btn = ctk.CTkButton( self._remote_refresh_btn = ctk.CTkButton(
right_header, text="\u21BB", width=30, height=28, right_header, text="" if _ref_img else "\u21BB",
image=_ref_img, width=30, height=28,
command=self._refresh_remote, command=self._refresh_remote,
) )
self._remote_refresh_btn.pack(side="left", padx=2) self._remote_refresh_btn.pack(side="left", padx=2)
@@ -204,14 +213,14 @@ class FilesTab(ctk.CTkFrame):
toolbar = ctk.CTkFrame(self, fg_color="transparent") toolbar = ctk.CTkFrame(self, fg_color="transparent")
toolbar.pack(fill="x", padx=10, pady=4) toolbar.pack(fill="x", padx=10, pady=4)
self._upload_btn = ctk.CTkButton( self._upload_btn = make_icon_button(
toolbar, text=icon_text("upload", t("upload")), width=110, height=30, toolbar, "upload", t("upload"), width=110, height=30,
command=self._upload_selected, command=self._upload_selected,
) )
self._upload_btn.pack(side="left", padx=(0, 4)) self._upload_btn.pack(side="left", padx=(0, 4))
self._download_btn = ctk.CTkButton( self._download_btn = make_icon_button(
toolbar, text=icon_text("download", t("download")), width=110, height=30, toolbar, "download", t("download"), width=110, height=30,
command=self._download_selected, command=self._download_selected,
) )
self._download_btn.pack(side="left", padx=4) self._download_btn.pack(side="left", padx=4)
@@ -219,21 +228,21 @@ class FilesTab(ctk.CTkFrame):
sep = ctk.CTkFrame(toolbar, width=2, height=24, fg_color="gray40") sep = ctk.CTkFrame(toolbar, width=2, height=24, fg_color="gray40")
sep.pack(side="left", padx=8) sep.pack(side="left", padx=8)
self._mkdir_btn = ctk.CTkButton( self._mkdir_btn = make_icon_button(
toolbar, text=icon_text("folder", t("new_folder")), width=110, height=30, toolbar, "folder", t("new_folder"), width=110, height=30,
command=self._mkdir_remote, command=self._mkdir_remote,
) )
self._mkdir_btn.pack(side="left", padx=4) self._mkdir_btn.pack(side="left", padx=4)
self._delete_btn = ctk.CTkButton( self._delete_btn = make_icon_button(
toolbar, text=icon_text("delete", t("delete_files")), width=90, height=30, toolbar, "delete", t("delete_files"), width=90, height=30,
fg_color="#dc2626", hover_color="#b91c1c", fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete_remote, command=self._delete_remote,
) )
self._delete_btn.pack(side="left", padx=4) self._delete_btn.pack(side="left", padx=4)
self._rename_btn = ctk.CTkButton( self._rename_btn = make_icon_button(
toolbar, text=icon_text("edit", t("rename_file")), width=110, height=30, toolbar, "edit", t("rename_file"), width=110, height=30,
command=self._rename_remote, command=self._rename_remote,
) )
self._rename_btn.pack(side="left", padx=4) self._rename_btn.pack(side="left", padx=4)

View File

@@ -9,7 +9,7 @@ from tkinter import ttk
import customtkinter as ctk import customtkinter as ctk
from core.grafana_client import GrafanaClient from core.grafana_client import GrafanaClient
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style from gui.tabs.query_tab import apply_dark_scrollbar_style
@@ -33,8 +33,8 @@ class GrafanaTab(ctk.CTkFrame):
font=ctk.CTkFont(size=18, weight="bold")) font=ctk.CTkFont(size=18, weight="bold"))
title.pack(side="left") title.pack(side="left")
self._refresh_btn = ctk.CTkButton(header_frame, text=icon_text("refresh", t("grafana_refresh")), width=110, self._refresh_btn = make_icon_button(header_frame, "refresh", t("grafana_refresh"), width=110,
command=self._refresh) command=self._refresh)
self._refresh_btn.pack(side="right") self._refresh_btn.pack(side="right")
# ── Dashboards section ── # ── Dashboards section ──
@@ -131,8 +131,10 @@ class GrafanaTab(ctk.CTkFrame):
except Exception as e: except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444")) self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally: finally:
self.after(0, lambda: self._refresh_btn.configure( self.after(0, lambda: (
state="normal", text=icon_text("refresh", t("grafana_refresh")))) self._refresh_btn.configure(state="normal"),
reconfigure_icon_button(self._refresh_btn, "refresh", t("grafana_refresh")),
))
threading.Thread(target=_do, daemon=True).start() threading.Thread(target=_do, daemon=True).start()

View File

@@ -4,7 +4,7 @@ Info tab — display server details, edit button.
import customtkinter as ctk import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
class InfoTab(ctk.CTkFrame): class InfoTab(ctk.CTkFrame):
@@ -56,7 +56,7 @@ class InfoTab(ctk.CTkFrame):
self._fields[key] = val self._fields[key] = val
# Edit button # Edit button
self.edit_btn = ctk.CTkButton(self, text=icon_text("edit", t("edit_server_btn")), command=self._on_edit) self.edit_btn = make_icon_button(self, "edit", t("edit_server_btn"), command=self._on_edit)
self.edit_btn.pack(pady=15) self.edit_btn.pack(pady=15)
def set_server(self, alias: str | None): def set_server(self, alias: str | None):

View File

@@ -7,7 +7,7 @@ import threading
import customtkinter as ctk import customtkinter as ctk
from core.ssh_client import SSHClientWrapper from core.ssh_client import SSHClientWrapper
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
class KeysTab(ctk.CTkFrame): class KeysTab(ctk.CTkFrame):
@@ -30,13 +30,13 @@ class KeysTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5) btn_frame.pack(fill="x", padx=15, pady=5)
self.gen_btn = ctk.CTkButton(btn_frame, text=icon_text("key", t("generate_key")), command=self._generate) self.gen_btn = make_icon_button(btn_frame, "key", t("generate_key"), command=self._generate)
self.gen_btn.pack(side="left", padx=(0, 10)) self.gen_btn.pack(side="left", padx=(0, 10))
self.install_btn = ctk.CTkButton(btn_frame, text=icon_text("upload", t("install_on_server")), fg_color="#22c55e", hover_color="#16a34a", command=self._install) self.install_btn = make_icon_button(btn_frame, "upload", t("install_on_server"), fg_color="#22c55e", hover_color="#16a34a", command=self._install)
self.install_btn.pack(side="left") self.install_btn.pack(side="left")
self.copy_btn = ctk.CTkButton(btn_frame, text=icon_text("copy", t("copy_public_key")), fg_color="#6b7280", command=self._copy_key) self.copy_btn = make_icon_button(btn_frame, "copy", t("copy_public_key"), fg_color="#6b7280", command=self._copy_key)
self.copy_btn.pack(side="right") self.copy_btn.pack(side="right")
# Status log # Status log

View File

@@ -10,7 +10,7 @@ import threading
import customtkinter as ctk import customtkinter as ctk
from core.remote_desktop import RemoteDesktopLauncher from core.remote_desktop import RemoteDesktopLauncher
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from core.logger import log from core.logger import log
@@ -33,15 +33,15 @@ class LaunchTab(ctk.CTkFrame):
# ── Toolbar (shown when RDP connected) ── # ── Toolbar (shown when RDP connected) ──
self._toolbar = ctk.CTkFrame(self, height=36, fg_color="transparent") self._toolbar = ctk.CTkFrame(self, height=36, fg_color="transparent")
self._disconnect_btn = ctk.CTkButton( self._disconnect_btn = make_icon_button(
self._toolbar, text=icon_text("delete", t("rdp_disconnect")), self._toolbar, "delete", t("rdp_disconnect"),
width=120, height=30, fg_color="#ef4444", hover_color="#dc2626", width=120, height=30, fg_color="#ef4444", hover_color="#dc2626",
command=self._disconnect, command=self._disconnect,
) )
self._disconnect_btn.pack(side="left", padx=(8, 4)) self._disconnect_btn.pack(side="left", padx=(8, 4))
self._fullscreen_btn = ctk.CTkButton( self._fullscreen_btn = make_icon_button(
self._toolbar, text=icon_text("launch", t("rdp_fullscreen")), self._toolbar, "launch", t("rdp_fullscreen"),
width=130, height=30, fg_color="#6b7280", hover_color="#4b5563", width=130, height=30, fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_fullscreen, command=self._toggle_fullscreen,
) )
@@ -135,8 +135,9 @@ class LaunchTab(ctk.CTkFrame):
).pack(fill="x", padx=15, pady=(3, 12)) ).pack(fill="x", padx=15, pady=(3, 12))
# Connect button # Connect button
self._connect_btn = ctk.CTkButton( self._connect_btn = make_icon_button(
self._settings_panel, text=icon_text("execute", t("launch_connect")), self._settings_panel, "execute", t("launch_connect"),
icon_size=20,
font=ctk.CTkFont(size=18, weight="bold"), font=ctk.CTkFont(size=18, weight="bold"),
width=220, height=50, width=220, height=50,
command=self._on_connect, command=self._on_connect,
@@ -374,9 +375,7 @@ class LaunchTab(ctk.CTkFrame):
if self._is_fullscreen: if self._is_fullscreen:
# Exit fullscreen — reattach # Exit fullscreen — reattach
self._is_fullscreen = False self._is_fullscreen = False
self._fullscreen_btn.configure( reconfigure_icon_button(self._fullscreen_btn, "launch", t("rdp_fullscreen"))
text=icon_text("launch", t("rdp_fullscreen")),
)
self._rdp_frame.update_idletasks() self._rdp_frame.update_idletasks()
parent_hwnd = self._rdp_frame.winfo_id() parent_hwnd = self._rdp_frame.winfo_id()
w = self._rdp_frame.winfo_width() w = self._rdp_frame.winfo_width()
@@ -385,9 +384,7 @@ class LaunchTab(ctk.CTkFrame):
else: else:
# Go fullscreen — detach, then maximize after event loop settles # Go fullscreen — detach, then maximize after event loop settles
self._is_fullscreen = True self._is_fullscreen = True
self._fullscreen_btn.configure( reconfigure_icon_button(self._fullscreen_btn, "back", t("rdp_exit_fullscreen"))
text=icon_text("back", t("rdp_exit_fullscreen")),
)
self._embedded_rdp.detach() self._embedded_rdp.detach()
self.after(300, self._maximize_detached) self.after(300, self._maximize_detached)

View File

@@ -8,7 +8,7 @@ import threading
import customtkinter as ctk import customtkinter as ctk
from core.winrm_client import WinRMClient from core.winrm_client import WinRMClient
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
class PowershellTab(ctk.CTkFrame): class PowershellTab(ctk.CTkFrame):
@@ -68,8 +68,8 @@ class PowershellTab(ctk.CTkFrame):
self._entry.bind("<Up>", lambda e: self._history_navigate(-1)) self._entry.bind("<Up>", lambda e: self._history_navigate(-1))
self._entry.bind("<Down>", lambda e: self._history_navigate(1)) self._entry.bind("<Down>", lambda e: self._history_navigate(1))
self._exec_btn = ctk.CTkButton( self._exec_btn = make_icon_button(
input_row, text=icon_text("execute", t("ps_execute")), width=100, input_row, "execute", t("ps_execute"), width=100,
command=self._execute, command=self._execute,
) )
self._exec_btn.pack(side="right") self._exec_btn.pack(side="right")

View File

@@ -8,7 +8,7 @@ from tkinter import ttk
import customtkinter as ctk import customtkinter as ctk
from core.prometheus_client import PrometheusClient from core.prometheus_client import PrometheusClient
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button, reconfigure_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style from gui.tabs.query_tab import apply_dark_scrollbar_style
@@ -37,8 +37,8 @@ class PrometheusTab(ctk.CTkFrame):
self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) self._query_entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
self._query_entry.bind("<Return>", lambda e: self._execute_query()) self._query_entry.bind("<Return>", lambda e: self._execute_query())
self._exec_btn = ctk.CTkButton(query_frame, text=icon_text("execute", t("prom_execute")), width=100, self._exec_btn = make_icon_button(query_frame, "execute", t("prom_execute"), width=100,
command=self._execute_query) command=self._execute_query)
self._exec_btn.pack(side="left") self._exec_btn.pack(side="left")
# ── Query results ── # ── Query results ──
@@ -59,8 +59,8 @@ class PrometheusTab(ctk.CTkFrame):
font=ctk.CTkFont(size=14, weight="bold"), anchor="w") font=ctk.CTkFont(size=14, weight="bold"), anchor="w")
targets_label.pack(side="left") targets_label.pack(side="left")
self._refresh_btn = ctk.CTkButton(targets_header, text=icon_text("refresh", t("prom_refresh")), width=100, self._refresh_btn = make_icon_button(targets_header, "refresh", t("prom_refresh"), width=100,
command=self._refresh_all) command=self._refresh_all)
self._refresh_btn.pack(side="right") self._refresh_btn.pack(side="right")
targets_frame = ctk.CTkFrame(self, fg_color="transparent") targets_frame = ctk.CTkFrame(self, fg_color="transparent")
@@ -201,8 +201,10 @@ class PrometheusTab(ctk.CTkFrame):
except Exception as e: except Exception as e:
self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444")) self.after(0, lambda: self._set_status(f"(error) {e}", "#ef4444"))
finally: finally:
self.after(0, lambda: self._refresh_btn.configure( self.after(0, lambda: (
state="normal", text=icon_text("refresh", t("prom_refresh")))) self._refresh_btn.configure(state="normal"),
reconfigure_icon_button(self._refresh_btn, "refresh", t("prom_refresh")),
))
threading.Thread(target=_do, daemon=True).start() threading.Thread(target=_do, daemon=True).start()

View File

@@ -14,7 +14,7 @@ from tkinter import ttk, filedialog
import customtkinter as ctk import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
from core.sql_client import SQLClient from core.sql_client import SQLClient
_TREE_THEME_APPLIED = False _TREE_THEME_APPLIED = False
@@ -227,9 +227,8 @@ class QueryTab(ctk.CTkFrame):
btn_row = ctk.CTkFrame(right_ctk, fg_color="transparent") btn_row = ctk.CTkFrame(right_ctk, fg_color="transparent")
btn_row.pack(fill="x", padx=8, pady=4) btn_row.pack(fill="x", padx=8, pady=4)
self._exec_btn = ctk.CTkButton( self._exec_btn = make_icon_button(
btn_row, btn_row, "execute", t("query_execute"),
text=icon_text("execute", t("query_execute")),
command=self._execute_query, command=self._execute_query,
width=130, width=130,
fg_color="#2563eb", fg_color="#2563eb",
@@ -237,9 +236,8 @@ class QueryTab(ctk.CTkFrame):
) )
self._exec_btn.pack(side="left", padx=(0, 6)) self._exec_btn.pack(side="left", padx=(0, 6))
self._clear_btn = ctk.CTkButton( self._clear_btn = make_icon_button(
btn_row, btn_row, "clear", t("query_clear"),
text=icon_text("clear", t("query_clear")),
command=self._clear_all, command=self._clear_all,
width=80, width=80,
fg_color="#6b7280", fg_color="#6b7280",
@@ -247,9 +245,8 @@ class QueryTab(ctk.CTkFrame):
) )
self._clear_btn.pack(side="left", padx=(0, 6)) self._clear_btn.pack(side="left", padx=(0, 6))
self._export_btn = ctk.CTkButton( self._export_btn = make_icon_button(
btn_row, btn_row, "save", t("query_export_csv"),
text=icon_text("save", t("query_export_csv")),
command=self._export_csv, command=self._export_csv,
width=110, width=110,
fg_color="#059669", fg_color="#059669",

View File

@@ -6,7 +6,7 @@ import threading
import customtkinter as ctk import customtkinter as ctk
from core.redis_client import RedisClient from core.redis_client import RedisClient
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
class RedisTab(ctk.CTkFrame): class RedisTab(ctk.CTkFrame):
@@ -67,28 +67,28 @@ class RedisTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=15, pady=5) btn_frame.pack(fill="x", padx=15, pady=5)
self._exec_btn = ctk.CTkButton(btn_frame, text=icon_text("execute", t("redis_execute")), width=100, self._exec_btn = make_icon_button(btn_frame, "execute", t("redis_execute"), width=100,
command=self._execute_command) command=self._execute_command)
self._exec_btn.pack(side="left", padx=(0, 5)) self._exec_btn.pack(side="left", padx=(0, 5))
self._info_btn = ctk.CTkButton(btn_frame, text=icon_text("info", "INFO"), width=80, self._info_btn = make_icon_button(btn_frame, "info", "INFO", width=80,
fg_color="#6b7280", hover_color="#4b5563", fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("INFO")) command=lambda: self._run_quick("INFO"))
self._info_btn.pack(side="left", padx=(0, 5)) self._info_btn.pack(side="left", padx=(0, 5))
self._dbsize_btn = ctk.CTkButton(btn_frame, text=icon_text("hash", "DBSIZE"), width=90, self._dbsize_btn = make_icon_button(btn_frame, "info", "DBSIZE", width=90,
fg_color="#6b7280", hover_color="#4b5563", fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("DBSIZE")) command=lambda: self._run_quick("DBSIZE"))
self._dbsize_btn.pack(side="left", padx=(0, 5)) self._dbsize_btn.pack(side="left", padx=(0, 5))
self._scan_btn = ctk.CTkButton(btn_frame, text=icon_text("search", "SCAN"), width=80, self._scan_btn = make_icon_button(btn_frame, "search", "SCAN", width=80,
fg_color="#6b7280", hover_color="#4b5563", fg_color="#6b7280", hover_color="#4b5563",
command=lambda: self._run_quick("SCAN 0 COUNT 100")) command=lambda: self._run_quick("SCAN 0 COUNT 100"))
self._scan_btn.pack(side="left", padx=(0, 5)) self._scan_btn.pack(side="left", padx=(0, 5))
self._clear_btn = ctk.CTkButton(btn_frame, text=icon_text("clear", t("redis_clear")), width=80, self._clear_btn = make_icon_button(btn_frame, "clear", t("redis_clear"), width=80,
fg_color="#374151", hover_color="#1f2937", fg_color="#374151", hover_color="#1f2937",
command=self._clear_output) command=self._clear_output)
self._clear_btn.pack(side="right") self._clear_btn.pack(side="right")
# ── Output console ── # ── Output console ──

View File

@@ -12,7 +12,7 @@ from tkinter import ttk, filedialog
import customtkinter as ctk import customtkinter as ctk
from core.s3_client import S3Client from core.s3_client import S3Client
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
from gui.tabs.query_tab import apply_dark_scrollbar_style from gui.tabs.query_tab import apply_dark_scrollbar_style
@@ -103,32 +103,32 @@ class S3Tab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(header, fg_color="transparent") btn_frame = ctk.CTkFrame(header, fg_color="transparent")
btn_frame.pack(side="right") btn_frame.pack(side="right")
self._back_btn = ctk.CTkButton( self._back_btn = make_icon_button(
btn_frame, text=icon_text("back", t("s3_back")), width=80, btn_frame, "back", t("s3_back"), width=80,
command=self._go_back, state="disabled", command=self._go_back, state="disabled",
) )
self._back_btn.pack(side="left", padx=(0, 5)) self._back_btn.pack(side="left", padx=(0, 5))
self._refresh_btn = ctk.CTkButton( self._refresh_btn = make_icon_button(
btn_frame, text=icon_text("refresh", t("s3_refresh")), width=100, btn_frame, "refresh", t("s3_refresh"), width=100,
command=self._refresh, command=self._refresh,
) )
self._refresh_btn.pack(side="left", padx=(0, 5)) self._refresh_btn.pack(side="left", padx=(0, 5))
self._upload_btn = ctk.CTkButton( self._upload_btn = make_icon_button(
btn_frame, text=icon_text("upload", t("s3_upload")), width=100, btn_frame, "upload", t("s3_upload"), width=100,
command=self._upload, command=self._upload,
) )
self._upload_btn.pack(side="left", padx=(0, 5)) self._upload_btn.pack(side="left", padx=(0, 5))
self._download_btn = ctk.CTkButton( self._download_btn = make_icon_button(
btn_frame, text=icon_text("download", t("s3_download")), width=110, btn_frame, "download", t("s3_download"), width=110,
command=self._download, command=self._download,
) )
self._download_btn.pack(side="left", padx=(0, 5)) self._download_btn.pack(side="left", padx=(0, 5))
self._delete_btn = ctk.CTkButton( self._delete_btn = make_icon_button(
btn_frame, text=icon_text("delete", t("s3_delete")), width=100, btn_frame, "delete", t("s3_delete"), width=100,
fg_color="#dc2626", hover_color="#b91c1c", fg_color="#dc2626", hover_color="#b91c1c",
command=self._delete, command=self._delete,
) )

View File

@@ -10,7 +10,7 @@ from tkinter import filedialog, messagebox
import customtkinter as ctk import customtkinter as ctk
from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key from core.claude_setup import check_status, install_all, install_ssh_script, install_skill, generate_ssh_key
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button
from core.logger import log from core.logger import log
@@ -70,8 +70,8 @@ class SetupTab(ctk.CTkFrame):
btn_frame = ctk.CTkFrame(self._scroll, fg_color="transparent") btn_frame = ctk.CTkFrame(self._scroll, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=15) btn_frame.pack(fill="x", padx=20, pady=15)
self.install_all_btn = ctk.CTkButton( self.install_all_btn = make_icon_button(
btn_frame, text=icon_text("confirm", t("install_everything")), btn_frame, "confirm", t("install_everything"),
font=ctk.CTkFont(size=14, weight="bold"), font=ctk.CTkFont(size=14, weight="bold"),
height=40, fg_color="#22c55e", hover_color="#16a34a", height=40, fg_color="#22c55e", hover_color="#16a34a",
command=self._install_all command=self._install_all
@@ -82,16 +82,16 @@ class SetupTab(ctk.CTkFrame):
ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent") ind_frame = ctk.CTkFrame(btn_frame, fg_color="transparent")
ind_frame.pack(fill="x") ind_frame.pack(fill="x")
self.ssh_py_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_py")), width=110, fg_color="#6b7280", self.ssh_py_btn = make_icon_button(ind_frame, "confirm", t("install_ssh_py"), width=110, fg_color="#6b7280",
command=self._install_script) command=self._install_script)
self.ssh_py_btn.pack(side="left", padx=(0, 5)) self.ssh_py_btn.pack(side="left", padx=(0, 5))
self.skill_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_skill")), width=110, fg_color="#6b7280", self.skill_btn = make_icon_button(ind_frame, "confirm", t("install_skill"), width=110, fg_color="#6b7280",
command=self._install_skill) command=self._install_skill)
self.skill_btn.pack(side="left", padx=5) self.skill_btn.pack(side="left", padx=5)
self.ssh_key_btn = ctk.CTkButton(ind_frame, text=icon_text("confirm", t("install_ssh_key")), width=110, fg_color="#6b7280", self.ssh_key_btn = make_icon_button(ind_frame, "confirm", t("install_ssh_key"), width=110, fg_color="#6b7280",
command=self._gen_key) command=self._gen_key)
self.ssh_key_btn.pack(side="left", padx=5) self.ssh_key_btn.pack(side="left", padx=5)
self.refresh_btn = ctk.CTkButton(ind_frame, text=icon_text("refresh", t("refresh")), width=90, fg_color="#3b82f6", self.refresh_btn = make_icon_button(ind_frame, "refresh", t("refresh"), width=90, fg_color="#3b82f6",
command=self._refresh_status) command=self._refresh_status)
self.refresh_btn.pack(side="right") self.refresh_btn.pack(side="right")
@@ -185,8 +185,8 @@ class SetupTab(ctk.CTkFrame):
font=ctk.CTkFont(family="Consolas", size=11) font=ctk.CTkFont(family="Consolas", size=11)
) )
self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10)) self._path_label.pack(side="left", fill="x", expand=True, padx=(5, 10))
self.change_path_btn = ctk.CTkButton( self.change_path_btn = make_icon_button(
path_row, text=icon_text("folder", t("change_path")), width=120, fg_color="#6b7280", path_row, "folder", t("change_path"), width=120, fg_color="#6b7280",
command=self._change_config_path command=self._change_config_path
) )
self.change_path_btn.pack(side="right") self.change_path_btn.pack(side="right")
@@ -195,8 +195,8 @@ class SetupTab(ctk.CTkFrame):
backup_row = ctk.CTkFrame(config_frame, fg_color="transparent") backup_row = ctk.CTkFrame(config_frame, fg_color="transparent")
backup_row.pack(fill="x", padx=15, pady=(5, 10)) backup_row.pack(fill="x", padx=15, pady=(5, 10))
self.backup_btn = ctk.CTkButton( self.backup_btn = make_icon_button(
backup_row, text=icon_text("save", t("backup_now")), width=120, fg_color="#3b82f6", backup_row, "save", t("backup_now"), width=120, fg_color="#3b82f6",
command=self._backup_now command=self._backup_now
) )
self.backup_btn.pack(side="left", padx=(0, 10)) self.backup_btn.pack(side="left", padx=(0, 10))
@@ -210,8 +210,8 @@ class SetupTab(ctk.CTkFrame):
) )
self._backup_menu.pack(side="left", padx=(0, 10)) self._backup_menu.pack(side="left", padx=(0, 10))
self.restore_btn = ctk.CTkButton( self.restore_btn = make_icon_button(
backup_row, text=icon_text("refresh", t("restore")), width=100, fg_color="#ef4444", hover_color="#dc2626", backup_row, "refresh", t("restore"), width=100, fg_color="#ef4444", hover_color="#dc2626",
command=self._restore_backup command=self._restore_backup
) )
self.restore_btn.pack(side="left") self.restore_btn.pack(side="left")
@@ -220,26 +220,26 @@ class SetupTab(ctk.CTkFrame):
ie_row = ctk.CTkFrame(config_frame, fg_color="transparent") ie_row = ctk.CTkFrame(config_frame, fg_color="transparent")
ie_row.pack(fill="x", padx=15, pady=(0, 10)) ie_row.pack(fill="x", padx=15, pady=(0, 10))
self.export_config_btn = ctk.CTkButton( self.export_config_btn = make_icon_button(
ie_row, text=icon_text("upload", t("export_config")), width=130, fg_color="#6b7280", ie_row, "upload", t("export_config"), width=130, fg_color="#6b7280",
command=self._export_config command=self._export_config
) )
self.export_config_btn.pack(side="left", padx=(0, 5)) self.export_config_btn.pack(side="left", padx=(0, 5))
self.import_config_btn = ctk.CTkButton( self.import_config_btn = make_icon_button(
ie_row, text=icon_text("download", t("import_config")), width=130, fg_color="#6b7280", ie_row, "download", t("import_config"), width=130, fg_color="#6b7280",
command=self._import_config command=self._import_config
) )
self.import_config_btn.pack(side="left", padx=5) self.import_config_btn.pack(side="left", padx=5)
self.export_backup_btn = ctk.CTkButton( self.export_backup_btn = make_icon_button(
ie_row, text=icon_text("upload", t("export_backup")), width=130, fg_color="#6b7280", ie_row, "upload", t("export_backup"), width=130, fg_color="#6b7280",
command=self._export_backup command=self._export_backup
) )
self.export_backup_btn.pack(side="left", padx=5) self.export_backup_btn.pack(side="left", padx=5)
self.import_backup_btn = ctk.CTkButton( self.import_backup_btn = make_icon_button(
ie_row, text=icon_text("download", t("import_backup")), width=130, fg_color="#6b7280", ie_row, "download", t("import_backup"), width=130, fg_color="#6b7280",
command=self._import_backup command=self._import_backup
) )
self.import_backup_btn.pack(side="left", padx=5) self.import_backup_btn.pack(side="left", padx=5)

View File

@@ -6,7 +6,7 @@ Live countdown, one-click copy, per-server secrets.
import threading import threading
import customtkinter as ctk import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import icon_text from core.icons import icon_text, make_icon_button, reconfigure_icon_button
class TOTPTab(ctk.CTkFrame): class TOTPTab(ctk.CTkFrame):
@@ -81,8 +81,8 @@ class TOTPTab(ctk.CTkFrame):
widget.bind("<Button-1>", lambda e: self._copy_code()) widget.bind("<Button-1>", lambda e: self._copy_code())
# Copy button # Copy button
self.copy_btn = ctk.CTkButton( self.copy_btn = make_icon_button(
self, text=icon_text("copy", t("totp_copy")), width=200, height=40, self, "copy", t("totp_copy"), width=200, height=40,
font=ctk.CTkFont(size=14), font=ctk.CTkFont(size=14),
fg_color="#22c55e", hover_color="#16a34a", fg_color="#22c55e", hover_color="#16a34a",
command=self._copy_code command=self._copy_code
@@ -108,30 +108,30 @@ class TOTPTab(ctk.CTkFrame):
) )
self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) self.secret_entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.show_secret_btn = ctk.CTkButton( self.show_secret_btn = make_icon_button(
entry_row, text=icon_text("eye", t("show")), width=80, entry_row, "eye", t("show"), width=80,
fg_color="#6b7280", hover_color="#4b5563", fg_color="#6b7280", hover_color="#4b5563",
command=self._toggle_secret command=self._toggle_secret
) )
self.show_secret_btn.pack(side="left", padx=(0, 5)) self.show_secret_btn.pack(side="left", padx=(0, 5))
self._secret_visible = False self._secret_visible = False
self.save_secret_btn = ctk.CTkButton( self.save_secret_btn = make_icon_button(
entry_row, text=icon_text("confirm", t("totp_save_secret")), width=110, entry_row, "confirm", t("totp_save_secret"), width=110,
command=self._save_secret command=self._save_secret
) )
self.save_secret_btn.pack(side="left", padx=(0, 5)) self.save_secret_btn.pack(side="left", padx=(0, 5))
self.remove_secret_btn = ctk.CTkButton( self.remove_secret_btn = make_icon_button(
entry_row, text=icon_text("delete", t("totp_remove_secret")), width=110, entry_row, "delete", t("totp_remove_secret"), width=110,
fg_color="#ef4444", hover_color="#dc2626", fg_color="#ef4444", hover_color="#dc2626",
command=self._remove_secret command=self._remove_secret
) )
self.remove_secret_btn.pack(side="left") self.remove_secret_btn.pack(side="left")
# Generate random secret button # Generate random secret button
self.gen_secret_btn = ctk.CTkButton( self.gen_secret_btn = make_icon_button(
secret_frame, text=icon_text("key", t("totp_generate_secret")), width=200, secret_frame, "key", t("totp_generate_secret"), width=200,
fg_color="#6b7280", hover_color="#4b5563", fg_color="#6b7280", hover_color="#4b5563",
command=self._generate_secret command=self._generate_secret
) )
@@ -267,8 +267,8 @@ class TOTPTab(ctk.CTkFrame):
def _toggle_secret(self): def _toggle_secret(self):
self._secret_visible = not self._secret_visible self._secret_visible = not self._secret_visible
self.secret_entry.configure(show="" if self._secret_visible else "*") self.secret_entry.configure(show="" if self._secret_visible else "*")
self.show_secret_btn.configure( reconfigure_icon_button(
text=icon_text("eye", t("hide") if self._secret_visible else t("show")) self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
) )
def _save_secret(self): def _save_secret(self):
@@ -331,12 +331,12 @@ class TOTPTab(ctk.CTkFrame):
def update_language(self): def update_language(self):
self.title_label.configure(text=t("totp_title")) self.title_label.configure(text=t("totp_title"))
self.desc_label.configure(text=t("totp_desc")) self.desc_label.configure(text=t("totp_desc"))
self.copy_btn.configure(text=icon_text("copy", t("totp_copy"))) reconfigure_icon_button(self.copy_btn, "copy", t("totp_copy"))
self.save_secret_btn.configure(text=icon_text("confirm", t("totp_save_secret"))) reconfigure_icon_button(self.save_secret_btn, "confirm", t("totp_save_secret"))
self.remove_secret_btn.configure(text=icon_text("delete", t("totp_remove_secret"))) reconfigure_icon_button(self.remove_secret_btn, "delete", t("totp_remove_secret"))
self.gen_secret_btn.configure(text=icon_text("key", t("totp_generate_secret"))) reconfigure_icon_button(self.gen_secret_btn, "key", t("totp_generate_secret"))
self.show_secret_btn.configure( reconfigure_icon_button(
text=icon_text("eye", t("hide") if self._secret_visible else t("show")) self.show_secret_btn, "eye", t("hide") if self._secret_visible else t("show")
) )
if not self._current_alias: if not self._current_alias:
self.server_label.configure(text=t("no_server_selected")) self.server_label.configure(text=t("no_server_selected"))

Binary file not shown.

64
tools/download_icons.py Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Download Material Design Icons PNG from GitHub.
Source: github.com/material-icons/material-icons-png (Apache 2.0)
Style: round-4x (96x96 px) — enough for 4x HiDPI
"""
import os
import urllib.request
import time
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE_URL = "https://raw.githubusercontent.com/material-icons/material-icons-png/master/png"
ICONS = [
"add", "arrow_back", "arrow_upward", "backspace", "check", "close",
"code", "computer", "content_copy", "dashboard", "delete", "edit",
"file_upload", "folder", "folder_open", "info",
"language", "lock", "play_arrow", "refresh", "save", "search",
"settings", "storage", "trending_up", "visibility", "vpn_key",
]
# Icons with different names on GitHub vs local filename
ICON_RENAMES = {
"get_app": "file_download", # Material "get_app" = download icon
}
# GitHub color dir → local theme dir
VARIANTS = {"white": "dark", "black": "light"}
def download():
done = 0
errors = 0
all_icons = [(name, name) for name in ICONS]
all_icons += [(gh_name, local_name) for gh_name, local_name in ICON_RENAMES.items()]
total = len(all_icons) * len(VARIANTS)
for gh_color, local_dir in VARIANTS.items():
out_dir = os.path.join(PROJECT_DIR, "assets", "icons", local_dir)
os.makedirs(out_dir, exist_ok=True)
for gh_name, local_name in all_icons:
done += 1
dst = os.path.join(out_dir, f"{local_name}.png")
if os.path.exists(dst):
print(f" [{done}/{total}] SKIP {local_dir}/{local_name}.png")
continue
url = f"{BASE_URL}/{gh_color}/{gh_name}/round-4x.png"
print(f" [{done}/{total}] GET {local_dir}/{local_name}.png ...", end=" ")
try:
urllib.request.urlretrieve(url, dst)
size_kb = os.path.getsize(dst) / 1024
print(f"OK ({size_kb:.1f} KB)")
except Exception as e:
print(f"FAIL: {e}")
errors += 1
time.sleep(0.1)
print(f"\nDone: {total - errors}/{total} icons downloaded")
if errors:
print(f" {errors} errors — re-run to retry")
if __name__ == "__main__":
download()

View File

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