- 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>
296 lines
8.7 KiB
Python
296 lines
8.7 KiB
Python
"""
|
||
Icon registry — semantic Unicode symbols + PNG Material Design icons.
|
||
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
|
||
ICONS = {
|
||
# Navigation
|
||
"back": "\u2190", # ←
|
||
"up": "\u2191", # ↑
|
||
"refresh": "\u21bb", # ↻
|
||
|
||
# CRUD
|
||
"add": "\uff0b", # +
|
||
"edit": "\u270e", # ✎
|
||
"delete": "\u2715", # ✕
|
||
"confirm": "\u2713", # ✓
|
||
|
||
# Transfer
|
||
"upload": "\u2b06", # ⬆
|
||
"download": "\u2b07", # ⬇
|
||
|
||
# Actions
|
||
"execute": "\u25b6", # ▶
|
||
"info": "\u2139", # ℹ
|
||
"clear": "\u232b", # ⌫
|
||
"search": "\U0001f50d", # 🔍
|
||
"hash": "#",
|
||
|
||
# Files
|
||
"folder": "\U0001f4c1", # 📁
|
||
"folder_open": "\U0001f4c2", # 📂
|
||
"save": "\U0001f4be", # 💾
|
||
|
||
# Keys & security
|
||
"key": "\U0001f511", # 🔑
|
||
"lock": "\U0001f510", # 🔐
|
||
"eye": "\U0001f441", # 👁
|
||
|
||
# Clipboard
|
||
"copy": "\U0001f4cb", # 📋
|
||
|
||
# Settings
|
||
"gear": "\u2699", # ⚙
|
||
"globe": "\U0001f310", # 🌐
|
||
|
||
# Status
|
||
"online": "\u25cf", # ●
|
||
"checking": "\u25d0", # ◐
|
||
"offline": "\u2014", # —
|
||
|
||
# Tabs
|
||
"terminal": "\u2328", # ⌨
|
||
"query": "\u25b6", # ▶
|
||
"dashboards": "\U0001f4ca", # 📊
|
||
"metrics": "\U0001f4c8", # 📈
|
||
"powershell": "\u2328", # ⌨
|
||
"launch": "\U0001f5a5", # 🖥
|
||
"totp": "\U0001f510", # 🔐
|
||
"objects": "\U0001faa3", # 🪣
|
||
|
||
# Context menu
|
||
"connect": "\u25b6", # ▶
|
||
"browser": "\U0001f310", # 🌐
|
||
"status_check": "\u25cf",# ●
|
||
}
|
||
|
||
|
||
def icon(name: str) -> str:
|
||
"""Get icon symbol by semantic name."""
|
||
return ICONS.get(name, "")
|
||
|
||
|
||
def icon_text(name: str, label: str) -> str:
|
||
"""Format 'icon label' string for buttons/menus."""
|
||
sym = ICONS.get(name, "")
|
||
if sym:
|
||
return f"{sym} {label}"
|
||
return label
|
||
|
||
|
||
# Server type colors
|
||
TYPE_COLORS = {
|
||
"ssh": "#22c55e",
|
||
"telnet": "#a855f7",
|
||
"rdp": "#3b82f6",
|
||
"vnc": "#6366f1",
|
||
"winrm": "#0ea5e9",
|
||
"mariadb": "#f59e0b",
|
||
"mssql": "#ef4444",
|
||
"postgresql": "#3b82f6",
|
||
"redis": "#dc2626",
|
||
"grafana": "#f97316",
|
||
"prometheus": "#e11d48",
|
||
"s3": "#16a34a",
|
||
}
|
||
|
||
# Unicode symbols for each server type (reliable, no PIL needed)
|
||
TYPE_SYMBOLS = {
|
||
"ssh": "\U0001f5a5", # 🖥
|
||
"telnet": "\U0001f4df", # 📟
|
||
"rdp": "\U0001f5b5", # 🖵
|
||
"vnc": "\U0001f5b5", # 🖵
|
||
"winrm": "\u229e", # ⊞
|
||
"mariadb": "\U0001f4be", # 💾
|
||
"mssql": "\U0001f4be", # 💾
|
||
"postgresql": "\U0001f418",# 🐘
|
||
"redis": "\u25c6", # ◆
|
||
"grafana": "\U0001f4ca", # 📊
|
||
"prometheus": "\U0001f525", # 🔥
|
||
"s3": "\U0001faa3", # 🪣
|
||
}
|
||
|
||
# Short text labels for sidebar badge
|
||
TYPE_LABELS = {
|
||
"ssh": "SSH",
|
||
"telnet": "TEL",
|
||
"rdp": "RDP",
|
||
"vnc": "VNC",
|
||
"winrm": "PS",
|
||
"mariadb": "MDB",
|
||
"mssql": "SQL",
|
||
"postgresql": "PG",
|
||
"redis": "RDS",
|
||
"grafana": "GRF",
|
||
"prometheus": "PRM",
|
||
"s3": "S3",
|
||
}
|
||
|
||
|
||
def type_display(server_type: str) -> str:
|
||
"""Return 'symbol type' for dropdown display, e.g. '🖥 ssh'."""
|
||
sym = TYPE_SYMBOLS.get(server_type, "")
|
||
if sym:
|
||
return f"{sym} {server_type}"
|
||
return server_type
|
||
|
||
|
||
def type_from_display(display: str) -> str:
|
||
"""Extract raw type from display string, e.g. '🖥 ssh' -> 'ssh'."""
|
||
# Strip the leading symbol + space
|
||
for stype in TYPE_SYMBOLS:
|
||
suffix = f" {stype}"
|
||
if display.endswith(suffix) and len(display) == len(suffix) + len(TYPE_SYMBOLS.get(stype, "")):
|
||
return stype
|
||
# Fallback: return as-is (might already be raw type)
|
||
return display.strip()
|
||
|
||
|
||
# Tab icon mapping (tab_key -> icon_name)
|
||
TAB_ICONS = {
|
||
"terminal": "terminal",
|
||
"files": "folder",
|
||
"info": "info",
|
||
"keys": "key",
|
||
"totp": "totp",
|
||
"setup": "gear",
|
||
"query": "query",
|
||
"console": "terminal",
|
||
"dashboards": "dashboards",
|
||
"metrics": "metrics",
|
||
"powershell": "powershell",
|
||
"launch": "launch",
|
||
"objects": "objects",
|
||
}
|
||
|
||
# Context menu icon mapping (i18n_key -> icon_name)
|
||
CTX_ICONS = {
|
||
"ctx_open_terminal": "terminal",
|
||
"ctx_browse_files": "folder",
|
||
"ctx_install_key": "key",
|
||
"ctx_open_powershell": "powershell",
|
||
"ctx_open_query": "query",
|
||
"ctx_open_console": "terminal",
|
||
"ctx_connect": "connect",
|
||
"ctx_open_browser": "browser",
|
||
"ctx_check_status": "status_check",
|
||
"ctx_copy_alias": "copy",
|
||
"edit": "edit",
|
||
"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))
|