Build Windows / build (push) Has been cancelled
- Buttons/Nav/Filter-Chips mit Hover-Farbwechsel und aktiver Akzent-Kante - Server-Karten mit Status rechts, Verbinden-Hinweis und Akzent-Rahmen bei Hover - Shell-Karten mit '»'-Badge - Verfeinerte Farbpaletten (kontrastreicher) + eigener Hover-Akzent pro Theme - Sidebar-Stats mit Version, farbigem Terminal-Status und Server-Zähler - Empty-States mit Symbol, Scrollbar schlanker (6px, Akzent-Scrollbalken) - Version 1.2.0
1164 lines
46 KiB
Python
1164 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import shlex
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import tkinter as tk
|
||
from pathlib import Path
|
||
from tkinter import messagebox
|
||
|
||
import yaml
|
||
|
||
|
||
APP_TITLE = "PulseGate Desktop"
|
||
APP_VERSION = "1.2.0"
|
||
APP_URL = "https://git.pepe44.dev/Pepe44DEV/PulseGate-GUI"
|
||
IS_WINDOWS = sys.platform == "win32"
|
||
CONNECT_TIMEOUT = 2.5
|
||
STATUS_OK = "#00e07a"
|
||
|
||
THEMES = {
|
||
"neon-green": {
|
||
"bg": "#050d09",
|
||
"sidebar": "#040b08",
|
||
"surface": "#0c1a13",
|
||
"surface_2": "#12241b",
|
||
"surface_3": "#193127",
|
||
"field": "#08130e",
|
||
"line": "#1d4a37",
|
||
"text": "#d9ffe9",
|
||
"muted": "#86a998",
|
||
"accent": "#00ff99",
|
||
"accent_hi": "#5cffb9",
|
||
"accent_2": "#3ecfff",
|
||
"danger": "#ff7070",
|
||
},
|
||
"ocean-blue": {
|
||
"bg": "#061019",
|
||
"sidebar": "#040b13",
|
||
"surface": "#0d1d29",
|
||
"surface_2": "#13293a",
|
||
"surface_3": "#1a364c",
|
||
"field": "#081420",
|
||
"line": "#1c4a6e",
|
||
"text": "#d8edff",
|
||
"muted": "#86a7c2",
|
||
"accent": "#3ddcff",
|
||
"accent_hi": "#8fecff",
|
||
"accent_2": "#6f9bff",
|
||
"danger": "#ff7a7a",
|
||
},
|
||
"sunset-orange": {
|
||
"bg": "#130d06",
|
||
"sidebar": "#0e0904",
|
||
"surface": "#241911",
|
||
"surface_2": "#312419",
|
||
"surface_3": "#3f2f20",
|
||
"field": "#181009",
|
||
"line": "#6c4a1d",
|
||
"text": "#ffead6",
|
||
"muted": "#c3ab8b",
|
||
"accent": "#ff9d3d",
|
||
"accent_hi": "#ffbd7c",
|
||
"accent_2": "#ff7a5c",
|
||
"danger": "#ff7a7a",
|
||
},
|
||
"cyber-purple": {
|
||
"bg": "#0f0712",
|
||
"sidebar": "#0a050c",
|
||
"surface": "#1e0f24",
|
||
"surface_2": "#2b1534",
|
||
"surface_3": "#3a1d46",
|
||
"field": "#150a19",
|
||
"line": "#5c2172",
|
||
"text": "#f2d9ff",
|
||
"muted": "#b590c2",
|
||
"accent": "#c95aff",
|
||
"accent_hi": "#e0a0ff",
|
||
"accent_2": "#6affcc",
|
||
"danger": "#ff7a7a",
|
||
},
|
||
}
|
||
|
||
COLORS = dict(THEMES["neon-green"])
|
||
|
||
UI_FONT = ("Segoe UI", 11) if IS_WINDOWS else ("Inter", 11)
|
||
UI_FONT_SMALL = ("Segoe UI", 9) if IS_WINDOWS else ("Inter", 9)
|
||
UI_FONT_MONO = ("Consolas", 10) if IS_WINDOWS else ("JetBrains Mono", 10)
|
||
FONT = UI_FONT
|
||
FONT_SMALL = UI_FONT_SMALL
|
||
FONT_TITLE = (UI_FONT[0], 24, "bold")
|
||
FONT_CARD = (UI_FONT[0], 13, "bold")
|
||
|
||
|
||
def config_path() -> Path:
|
||
if len(sys.argv) > 1:
|
||
return Path(sys.argv[1]).expanduser()
|
||
|
||
if IS_WINDOWS:
|
||
appdata = os.environ.get("APPDATA")
|
||
if appdata:
|
||
return Path(appdata) / "pulsegate" / "config.yaml"
|
||
return Path.home() / "AppData" / "Roaming" / "pulsegate" / "config.yaml"
|
||
|
||
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||
if config_home:
|
||
return Path(config_home) / "pulsegate" / "config.yaml"
|
||
|
||
return Path.home() / ".config" / "pulsegate" / "config.yaml"
|
||
|
||
|
||
def default_config() -> dict:
|
||
return {
|
||
"settings": {
|
||
"theme": "neon-green",
|
||
"terminal": {
|
||
"term": "xterm-256color",
|
||
"enable_kitty_fix": True,
|
||
},
|
||
},
|
||
"servers": [
|
||
{
|
||
"name": "Example Server",
|
||
"host": "10.0.0.10",
|
||
"user": "root",
|
||
"port": 22,
|
||
"group": "Homelab",
|
||
"auth": "key",
|
||
"key": "~/.ssh/id_ed25519",
|
||
"password_id": "",
|
||
"kitty_fix": True,
|
||
}
|
||
],
|
||
"quick_commands": [
|
||
{"name": "Disk Usage", "command": "df -h"},
|
||
{"name": "RAM Usage", "command": "free -h"},
|
||
{"name": "Container Status", "command": "docker ps"},
|
||
],
|
||
}
|
||
|
||
|
||
def normalize_config(config: dict) -> dict:
|
||
config.setdefault("settings", {})
|
||
settings = config["settings"]
|
||
settings.setdefault("theme", "neon-green")
|
||
settings.setdefault("quick_commands_enabled", True)
|
||
settings.setdefault("terminal", {})
|
||
settings["terminal"].setdefault("term", "xterm-256color")
|
||
settings["terminal"].setdefault("enable_kitty_fix", True)
|
||
config.setdefault("servers", [])
|
||
config.setdefault("quick_commands", [])
|
||
|
||
for server in config["servers"]:
|
||
server.setdefault("name", "")
|
||
server.setdefault("host", "")
|
||
server.setdefault("user", "root")
|
||
server.setdefault("port", 22)
|
||
server.setdefault("group", "Homelab")
|
||
server.setdefault("auth", "key")
|
||
server.setdefault("key", "")
|
||
server.setdefault("password_id", "")
|
||
server.setdefault("kitty_fix", True)
|
||
|
||
return config
|
||
|
||
|
||
def load_config(path: Path) -> dict:
|
||
if not path.exists():
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
save_config(path, default_config())
|
||
|
||
with path.open("r", encoding="utf-8") as handle:
|
||
data = yaml.safe_load(handle) or {}
|
||
|
||
return normalize_config(data)
|
||
|
||
|
||
def save_config(path: Path, config: dict) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8") as handle:
|
||
yaml.safe_dump(normalize_config(config), handle, sort_keys=False, allow_unicode=True)
|
||
try:
|
||
path.chmod(0o600)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def expand_home(value: str) -> str:
|
||
if not (value.startswith("~/") or value.startswith("~\\")):
|
||
return value
|
||
if IS_WINDOWS:
|
||
profile = os.environ.get("USERPROFILE")
|
||
if profile:
|
||
return profile + "\\" + value[2:].replace("/", "\\")
|
||
return str(Path(value).expanduser())
|
||
|
||
|
||
def ssh_args(server: dict) -> list[str]:
|
||
args = ["ssh", "-p", str(server.get("port") or 22)]
|
||
if server.get("auth") == "key" and server.get("key"):
|
||
args.extend(["-i", expand_home(str(server["key"]))])
|
||
args.append(f"{server.get('user', 'root')}@{server.get('host', '')}")
|
||
return args
|
||
|
||
|
||
def display_command(server: dict) -> str:
|
||
args = ssh_args(server)
|
||
if IS_WINDOWS:
|
||
return subprocess.list2cmdline(args)
|
||
return " ".join(shlex.quote(arg) for arg in args)
|
||
|
||
|
||
def remote_ssh_command(server: dict, command: str) -> str:
|
||
"""Führt einen Befehl auf dem Server via SSH aus (im Terminal)."""
|
||
if IS_WINDOWS:
|
||
base = subprocess.list2cmdline(ssh_args(server))
|
||
return base + " " + subprocess.list2cmdline([command])
|
||
base = " ".join(shlex.quote(arg) for arg in ssh_args(server))
|
||
return base + " " + shlex.quote(command)
|
||
|
||
|
||
def detect_terminal() -> str | None:
|
||
if IS_WINDOWS:
|
||
for name in ("wt", "cmd", "powershell"):
|
||
path = shutil.which(name)
|
||
if path:
|
||
return path
|
||
return None
|
||
|
||
for name in ("kitty", "alacritty", "konsole", "gnome-terminal", "xfce4-terminal", "xterm"):
|
||
path = shutil.which(name)
|
||
if path:
|
||
return path
|
||
return None
|
||
|
||
|
||
def open_in_terminal(terminal: str, title: str, command: str, hold: bool = True, done_message: str = "Fertig.") -> list[str]:
|
||
if IS_WINDOWS:
|
||
stem = Path(terminal).stem.lower()
|
||
if stem in ("powershell", "pwsh"):
|
||
suffix = f"; Write-Host ''; Write-Host '{done_message}'; Read-Host 'Enter zum Schliessen...'" if hold else ""
|
||
return [terminal, "-NoExit", "-Command", f"& {command}{suffix}"]
|
||
suffix = f" & echo. & echo {done_message} & pause" if hold else ""
|
||
inner = command + suffix
|
||
if stem == "wt":
|
||
return [terminal, "new-tab", "--title", title, "cmd.exe", "/k", inner]
|
||
return [terminal, "/k", inner]
|
||
|
||
suffix = f"; printf '\\n{done_message} Enter zum Schließen...'; read _" if hold else ""
|
||
inner = command + suffix
|
||
name = Path(terminal).name
|
||
if name == "kitty":
|
||
return [terminal, "--title", title, "sh", "-lc", inner]
|
||
if name == "alacritty":
|
||
return [terminal, "--title", title, "-e", "sh", "-lc", inner]
|
||
if name == "konsole":
|
||
return [terminal, "--new-tab", "-p", f"tabtitle={title}", "-e", "sh", "-lc", inner]
|
||
if name == "gnome-terminal":
|
||
return [terminal, "--title", title, "--", "sh", "-lc", inner]
|
||
if name == "xfce4-terminal":
|
||
return [terminal, "--title", title, "--command", f"sh -lc {shlex.quote(inner)}"]
|
||
if name == "xterm":
|
||
return [terminal, "-T", title, "-e", "sh", "-lc", inner]
|
||
|
||
return [terminal, "-e", "sh", "-lc", inner]
|
||
|
||
|
||
def terminal_command(terminal: str, server: dict) -> list[str]:
|
||
title = f"PulseGate - {server.get('name') or server.get('host')}"
|
||
if IS_WINDOWS:
|
||
command = subprocess.list2cmdline(ssh_args(server))
|
||
return open_in_terminal(terminal, title, command, done_message="SSH beendet.")
|
||
command = " ".join(shlex.quote(arg) for arg in ssh_args(server))
|
||
return open_in_terminal(terminal, title, command, done_message="SSH beendet.")
|
||
|
||
|
||
class PulseGateDesktop(tk.Tk):
|
||
def __init__(self, path: Path) -> None:
|
||
super().__init__()
|
||
self.path = path
|
||
self.config_data = load_config(path)
|
||
self.selected_index = 0
|
||
self.current_view = "servers"
|
||
self.group_filter = "Alle"
|
||
self.terminal = detect_terminal()
|
||
self.reachability: dict[int, bool] = {}
|
||
self.checked_hosts: dict[tuple[str, int], bool] = {}
|
||
self.reach_labels: dict[int, tk.Label] = {}
|
||
|
||
COLORS.update(THEMES.get(self.config_data["settings"].get("theme", "neon-green"), THEMES["neon-green"]))
|
||
|
||
self.vars: dict[str, tk.Variable] = {}
|
||
self.settings_vars: dict[str, tk.Variable] = {}
|
||
self.nav_buttons: dict[str, tk.Button] = {}
|
||
self.pages: dict[str, tk.Frame] = {}
|
||
self.search_var = tk.StringVar()
|
||
self.status_var = tk.StringVar(value="Bereit")
|
||
|
||
self.title(APP_TITLE)
|
||
self.geometry("1240x800")
|
||
self.minsize(1000, 640)
|
||
self.configure(bg=COLORS["bg"])
|
||
|
||
self._build_layout()
|
||
self._refresh_all()
|
||
|
||
self.bind("<F5>", lambda _event: self._reload())
|
||
self.bind("<Control-n>", lambda _event: self._add_server())
|
||
self.bind("<Control-s>", lambda _event: self._save_current_server())
|
||
|
||
def _build_layout(self) -> None:
|
||
root = tk.Frame(self, bg=COLORS["bg"])
|
||
root.pack(fill="both", expand=True)
|
||
|
||
self._build_sidebar(root)
|
||
self._build_content(root)
|
||
|
||
def _build_sidebar(self, parent: tk.Frame) -> None:
|
||
sidebar = tk.Frame(parent, bg=COLORS["sidebar"], width=290)
|
||
sidebar.pack(side="left", fill="y")
|
||
sidebar.pack_propagate(False)
|
||
|
||
brand = tk.Frame(sidebar, bg=COLORS["sidebar"])
|
||
brand.pack(fill="x", padx=22, pady=(24, 20))
|
||
|
||
mark = tk.Label(
|
||
brand,
|
||
text="PG",
|
||
bg=COLORS["accent"],
|
||
fg="#001a10",
|
||
font=(UI_FONT[0], 14, "bold"),
|
||
width=4,
|
||
height=2,
|
||
)
|
||
mark.pack(side="left")
|
||
|
||
brand_text = tk.Frame(brand, bg=COLORS["sidebar"])
|
||
brand_text.pack(side="left", padx=12)
|
||
tk.Label(brand_text, text="PulseGate", bg=COLORS["sidebar"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w")
|
||
tk.Label(brand_text, text="Desktop SSH", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w")
|
||
|
||
self._label(sidebar, "Suche", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", padx=22)
|
||
search = self._entry(sidebar, self.search_var)
|
||
search.pack(fill="x", padx=22, pady=(7, 14))
|
||
self.search_var.trace_add("write", lambda *_: self._refresh_server_cards())
|
||
|
||
self._label(sidebar, "Gruppenfilter", bg=COLORS["sidebar"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", padx=22)
|
||
self.filter_row = tk.Frame(sidebar, bg=COLORS["sidebar"])
|
||
self.filter_row.pack(fill="x", padx=22, pady=(7, 20))
|
||
|
||
nav = tk.Frame(sidebar, bg=COLORS["sidebar"])
|
||
nav.pack(fill="x", padx=22)
|
||
for view, label in (("servers", "Server"), ("commands", "Commands"), ("settings", "Settings")):
|
||
button = self._nav_button(nav, label, lambda value=view: self._show_view(value))
|
||
button.pack(fill="x", pady=4)
|
||
self.nav_buttons[view] = button
|
||
|
||
stats = self._card(sidebar, bg=COLORS["surface"], padx=14, pady=12)
|
||
stats.pack(fill="x", padx=22, pady=(28, 10))
|
||
head = tk.Frame(stats, bg=COLORS["surface"])
|
||
head.pack(fill="x")
|
||
self._label(head, "PulseGate", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(side="left")
|
||
self._label(head, "v" + APP_VERSION, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(side="right", anchor="e", pady=(3, 0))
|
||
terminal_text = "Terminal bereit" if self.terminal else "Kein Terminal gefunden"
|
||
terminal_fg = STATUS_OK if self.terminal else COLORS["danger"]
|
||
self._label(stats, f"● {terminal_text}", bg=COLORS["surface"], fg=terminal_fg, font=FONT_SMALL).pack(anchor="w", pady=(8, 0))
|
||
self.server_count_label = self._label(stats, "0 Server", bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 10, "bold"))
|
||
self.server_count_label.pack(anchor="w", pady=(7, 0))
|
||
self._label(stats, str(self.path), bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL, wraplength=210).pack(anchor="w", pady=(8, 0))
|
||
|
||
self._button(sidebar, "Über PulseGate", self._about, variant="ghost").pack(fill="x", padx=22, side="bottom", pady=(0, 16))
|
||
|
||
def _build_content(self, parent: tk.Frame) -> None:
|
||
content = tk.Frame(parent, bg=COLORS["bg"])
|
||
content.pack(side="left", fill="both", expand=True, padx=24, pady=22)
|
||
|
||
topbar = tk.Frame(content, bg=COLORS["bg"])
|
||
topbar.pack(fill="x", pady=(0, 18))
|
||
|
||
title_box = tk.Frame(topbar, bg=COLORS["bg"])
|
||
title_box.pack(side="left", fill="x", expand=True)
|
||
tk.Label(title_box, textvariable=self.status_var, bg=COLORS["bg"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w")
|
||
self.title_label = tk.Label(title_box, text="Server", bg=COLORS["bg"], fg=COLORS["text"], font=FONT_TITLE)
|
||
self.title_label.pack(anchor="w")
|
||
|
||
self._button(topbar, "Neu laden", self._reload, variant="ghost").pack(side="right", padx=(8, 0))
|
||
self._button(topbar, "Server hinzufügen", self._add_server).pack(side="right")
|
||
|
||
self.page_host = tk.Frame(content, bg=COLORS["bg"])
|
||
self.page_host.pack(fill="both", expand=True)
|
||
|
||
self.pages["servers"] = tk.Frame(self.page_host, bg=COLORS["bg"])
|
||
self.pages["commands"] = tk.Frame(self.page_host, bg=COLORS["bg"])
|
||
self.pages["settings"] = tk.Frame(self.page_host, bg=COLORS["bg"])
|
||
|
||
self._build_servers_page(self.pages["servers"])
|
||
self._build_commands_page(self.pages["commands"])
|
||
self._build_settings_page(self.pages["settings"])
|
||
|
||
def _build_servers_page(self, page: tk.Frame) -> None:
|
||
left = tk.Frame(page, bg=COLORS["bg"], width=400)
|
||
left.pack(side="left", fill="both", padx=(0, 18))
|
||
left.pack_propagate(False)
|
||
|
||
self.server_canvas = tk.Canvas(left, bg=COLORS["bg"], highlightthickness=0, bd=0)
|
||
scrollbar = tk.Scrollbar(
|
||
left,
|
||
orient="vertical",
|
||
command=self.server_canvas.yview,
|
||
bg=COLORS["surface_3"],
|
||
activebackground=COLORS["accent"],
|
||
troughcolor=COLORS["bg"],
|
||
relief="flat",
|
||
bd=0,
|
||
width=6,
|
||
)
|
||
self.server_cards = tk.Frame(self.server_canvas, bg=COLORS["bg"])
|
||
self.server_cards.bind("<Configure>", lambda _event: self.server_canvas.configure(scrollregion=self.server_canvas.bbox("all")))
|
||
self.server_canvas.create_window((0, 0), window=self.server_cards, anchor="nw", width=382)
|
||
self.server_canvas.configure(yscrollcommand=scrollbar.set)
|
||
self.server_canvas.pack(side="left", fill="both", expand=True)
|
||
scrollbar.pack(side="right", fill="y")
|
||
|
||
editor_outer = self._card(page, bg=COLORS["surface"], padx=18, pady=18)
|
||
editor_outer.pack(side="left", fill="both", expand=True)
|
||
|
||
header = tk.Frame(editor_outer, bg=COLORS["surface"])
|
||
header.pack(fill="x", pady=(0, 16))
|
||
self.form_title = self._label(header, "Server bearbeiten", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold"))
|
||
self.form_title.pack(side="left")
|
||
self.auth_chip = self._chip(header, "key", COLORS["accent_2"])
|
||
self.auth_chip.pack(side="right")
|
||
|
||
form = tk.Frame(editor_outer, bg=COLORS["surface"])
|
||
form.pack(fill="x")
|
||
fields = [
|
||
("name", "Name"),
|
||
("host", "Host"),
|
||
("user", "User"),
|
||
("port", "Port"),
|
||
("group", "Group"),
|
||
("auth", "Auth"),
|
||
("key", "Key Path"),
|
||
("password_id", "Password ID"),
|
||
]
|
||
|
||
for index, (key, label) in enumerate(fields):
|
||
row = index // 2
|
||
col = index % 2
|
||
cell = tk.Frame(form, bg=COLORS["surface"])
|
||
cell.grid(row=row, column=col, sticky="ew", padx=(0 if col == 0 else 10, 10 if col == 0 else 0), pady=8)
|
||
self._label(cell, label, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w")
|
||
var = tk.StringVar()
|
||
self.vars[key] = var
|
||
if key == "auth":
|
||
widget = self._option_menu(cell, var, ("key", "password"))
|
||
else:
|
||
widget = self._entry(cell, var)
|
||
widget.pack(fill="x", pady=(6, 0), ipady=3)
|
||
|
||
form.columnconfigure(0, weight=1)
|
||
form.columnconfigure(1, weight=1)
|
||
|
||
self.vars["kitty_fix"] = tk.BooleanVar(value=True)
|
||
tk.Checkbutton(
|
||
editor_outer,
|
||
text="Kitty Fix für diesen Server nutzen",
|
||
variable=self.vars["kitty_fix"],
|
||
bg=COLORS["surface"],
|
||
fg=COLORS["text"],
|
||
activebackground=COLORS["surface"],
|
||
activeforeground=COLORS["text"],
|
||
selectcolor=COLORS["field"],
|
||
font=FONT,
|
||
).pack(anchor="w", pady=(12, 0))
|
||
|
||
hint = tk.Frame(editor_outer, bg=COLORS["surface"])
|
||
hint.pack(fill="x", pady=(10, 14))
|
||
self._label(
|
||
hint,
|
||
"Tipp: Doppelklick auf einen Server verbindet direkt · Rechtsklick öffnet das Kontextmenü · Ctrl+S speichert.",
|
||
bg=COLORS["surface"],
|
||
fg=COLORS["muted"],
|
||
font=FONT_SMALL,
|
||
wraplength=520,
|
||
).pack(anchor="w")
|
||
|
||
actions = tk.Frame(editor_outer, bg=COLORS["surface"])
|
||
actions.pack(fill="x")
|
||
self._button(actions, "Verbinden", self._connect).pack(side="left")
|
||
self._button(actions, "SSH Befehl", self._show_command, variant="ghost").pack(side="left", padx=(8, 0))
|
||
self._button(actions, "Kopieren", self._copy_command, variant="ghost").pack(side="left", padx=(8, 0))
|
||
self._button(actions, "Löschen", self._delete_current_server, variant="danger").pack(side="right")
|
||
self._button(actions, "Duplizieren", self._duplicate_current_server, variant="ghost").pack(side="right", padx=(0, 8))
|
||
self._button(actions, "Speichern", self._save_current_server).pack(side="right", padx=(0, 8))
|
||
|
||
self.command_text = tk.Text(
|
||
editor_outer,
|
||
height=5,
|
||
bg=COLORS["field"],
|
||
fg=COLORS["accent"],
|
||
insertbackground=COLORS["text"],
|
||
relief="flat",
|
||
wrap="word",
|
||
font=UI_FONT_MONO,
|
||
padx=12,
|
||
pady=10,
|
||
)
|
||
self.command_text.pack(fill="both", expand=True, pady=(14, 0))
|
||
|
||
def _build_commands_page(self, page: tk.Frame) -> None:
|
||
self.commands_host = tk.Frame(page, bg=COLORS["bg"])
|
||
self.commands_host.pack(fill="both", expand=True)
|
||
intro = self._card(self.commands_host, bg=COLORS["surface"], padx=16, pady=14)
|
||
intro.pack(fill="x", pady=(0, 12))
|
||
self._label(intro, "Quick Commands", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(anchor="w")
|
||
self._label(
|
||
intro,
|
||
"Befehle können lokal auf diesem Rechner oder auf dem aktuell ausgewählten Server (via SSH) ausgeführt werden.",
|
||
bg=COLORS["surface"],
|
||
fg=COLORS["muted"],
|
||
font=FONT_SMALL,
|
||
wraplength=760,
|
||
).pack(anchor="w", pady=(5, 0))
|
||
|
||
def _build_settings_page(self, page: tk.Frame) -> None:
|
||
panel = self._card(page, bg=COLORS["surface"], padx=22, pady=22)
|
||
panel.pack(anchor="nw", fill="x")
|
||
|
||
self._label(panel, "Darstellung", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w")
|
||
self._label(panel, "Das Theme wird beim App-Start angewandt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 12))
|
||
|
||
theme_label = self._label(panel, "Theme", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL)
|
||
theme_label.pack(anchor="w")
|
||
self.settings_vars["theme"] = tk.StringVar()
|
||
self._option_menu(panel, self.settings_vars["theme"], tuple(THEMES.keys())).pack(fill="x", pady=(6, 18), ipady=3)
|
||
|
||
self._label(panel, "Terminal", bg=COLORS["surface"], fg=COLORS["text"], font=(UI_FONT[0], 18, "bold")).pack(anchor="w")
|
||
self._label(panel, "Diese Werte werden beim Starten einer SSH-Verbindung genutzt.", bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w", pady=(4, 12))
|
||
|
||
self.settings_vars["term"] = tk.StringVar()
|
||
for label, key in (("TERM Override", "term"),):
|
||
self._label(panel, label, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT_SMALL).pack(anchor="w")
|
||
self._entry(panel, self.settings_vars[key]).pack(fill="x", pady=(6, 14), ipady=3)
|
||
|
||
self.settings_vars["enable_kitty_fix"] = tk.BooleanVar()
|
||
tk.Checkbutton(
|
||
panel,
|
||
text="Kitty Fix global aktivieren",
|
||
variable=self.settings_vars["enable_kitty_fix"],
|
||
bg=COLORS["surface"],
|
||
fg=COLORS["text"],
|
||
activebackground=COLORS["surface"],
|
||
activeforeground=COLORS["text"],
|
||
selectcolor=COLORS["field"],
|
||
font=FONT,
|
||
).pack(anchor="w", pady=(0, 18))
|
||
|
||
actions = tk.Frame(panel, bg=COLORS["surface"])
|
||
actions.pack(fill="x")
|
||
self._button(actions, "Config-Ordner öffnen", self._open_config_folder, variant="ghost").pack(side="left")
|
||
self._button(actions, "Settings speichern", self._save_settings).pack(side="right")
|
||
|
||
def _refresh_all(self) -> None:
|
||
self._refresh_group_chips()
|
||
self._refresh_server_cards()
|
||
self._load_selected_server()
|
||
self._refresh_commands()
|
||
self._refresh_settings()
|
||
self._show_view(self.current_view)
|
||
|
||
def _refresh_group_chips(self) -> None:
|
||
for child in self.filter_row.winfo_children():
|
||
child.destroy()
|
||
|
||
groups = ["Alle"] + sorted({server.get("group") or "Keine Gruppe" for server in self.config_data["servers"]})
|
||
for group in groups:
|
||
selected = group == self.group_filter
|
||
chip = tk.Label(
|
||
self.filter_row,
|
||
text=group,
|
||
bg=COLORS["accent"] if selected else COLORS["surface_2"],
|
||
fg="#001a10" if selected else COLORS["text"],
|
||
font=(UI_FONT[0], 9, "bold"),
|
||
padx=10,
|
||
pady=5,
|
||
cursor="hand2",
|
||
highlightthickness=1,
|
||
highlightbackground=COLORS["line"] if not selected else COLORS["accent_hi"],
|
||
)
|
||
chip.pack(side="left", padx=(0, 6))
|
||
chip.bind("<Button-1>", lambda _event, value=group: self._set_group_filter(value))
|
||
|
||
def _enter(_event, label=chip, is_selected=selected) -> None:
|
||
if not is_selected:
|
||
label.configure(bg=COLORS["surface_3"], highlightbackground=COLORS["accent_hi"])
|
||
|
||
def _leave(_event, label=chip, is_selected=selected) -> None:
|
||
if not is_selected:
|
||
label.configure(bg=COLORS["surface_2"], highlightbackground=COLORS["line"])
|
||
|
||
chip.bind("<Enter>", _enter)
|
||
chip.bind("<Leave>", _leave)
|
||
|
||
def _set_group_filter(self, group: str) -> None:
|
||
self.group_filter = group
|
||
self._refresh_group_chips()
|
||
self._refresh_server_cards()
|
||
|
||
def _refresh_server_cards(self) -> None:
|
||
for child in self.server_cards.winfo_children():
|
||
child.destroy()
|
||
self.reach_labels.clear()
|
||
|
||
query = (self.search_var.get() or "").strip().lower()
|
||
rendered = 0
|
||
for index, server in enumerate(self.config_data["servers"]):
|
||
haystack = " ".join(str(server.get(key, "")) for key in ("name", "host", "user", "group", "auth")).lower()
|
||
if query and query not in haystack:
|
||
continue
|
||
group = server.get("group") or "Keine Gruppe"
|
||
if self.group_filter != "Alle" and group != self.group_filter:
|
||
continue
|
||
self._server_card(self.server_cards, server, index).pack(fill="x", pady=(0, 10))
|
||
rendered += 1
|
||
self._check_reachability(server, index)
|
||
|
||
if rendered == 0:
|
||
self._empty_state(self.server_cards, "Keine Server gefunden").pack(fill="x")
|
||
|
||
total = len(self.config_data["servers"])
|
||
text = f"{rendered} / {total} Server" if rendered != total else f"{total} Server"
|
||
self.server_count_label.configure(text=text)
|
||
|
||
def _server_card(self, parent: tk.Frame, server: dict, index: int) -> tk.Frame:
|
||
selected = index == self.selected_index
|
||
bg = COLORS["surface_2"] if selected else COLORS["surface"]
|
||
border = COLORS["accent"] if selected else COLORS["line"]
|
||
|
||
card = tk.Frame(parent, bg=border, bd=0)
|
||
inner = tk.Frame(card, bg=bg, padx=14, pady=12)
|
||
inner.pack(fill="both", expand=True, padx=1, pady=1)
|
||
|
||
head = tk.Frame(inner, bg=bg)
|
||
head.pack(fill="x", pady=(0, 6))
|
||
|
||
name = server.get("name") or "Unbenannt"
|
||
self._label(head, name, bg=bg, fg=COLORS["text"], font=FONT_CARD).pack(side="left")
|
||
|
||
status = self.reachability.get(index)
|
||
if status is None:
|
||
status_text, status_fg = "…", COLORS["muted"]
|
||
elif status:
|
||
status_text, status_fg = "● erreichbar", STATUS_OK
|
||
else:
|
||
status_text, status_fg = "● offline", COLORS["danger"]
|
||
status_label = self._label(head, status_text, bg=bg, fg=status_fg, font=FONT_SMALL)
|
||
status_label.pack(side="right", anchor="e", pady=(2, 0))
|
||
self.reach_labels[index] = status_label
|
||
|
||
target = f"{server.get('user')}@{server.get('host')}:{server.get('port') or 22}"
|
||
self._label(inner, target, bg=bg, fg=COLORS["muted"], font=UI_FONT_MONO).pack(anchor="w", pady=(0, 10))
|
||
|
||
chips = tk.Frame(inner, bg=bg)
|
||
chips.pack(fill="x")
|
||
self._chip(chips, server.get("group") or "Keine Gruppe", COLORS["accent_2"], bg=bg).pack(side="left")
|
||
self._chip(chips, server.get("auth") or "key", COLORS["accent"], bg=bg).pack(side="left", padx=(6, 0))
|
||
if server.get("kitty_fix"):
|
||
self._chip(chips, "kitty", COLORS["muted"], bg=bg).pack(side="left", padx=(6, 0))
|
||
|
||
connect_hint = tk.Label(inner, text="Verbinden ›", bg=bg, fg=COLORS["accent_hi"], font=FONT_SMALL)
|
||
connect_hint.place(relx=1.0, rely=0.0, anchor="ne", x=-14, y=6)
|
||
|
||
def _card_enter(_event) -> None:
|
||
card.configure(bg=COLORS["accent_hi"])
|
||
connect_hint.lift()
|
||
|
||
def _card_leave(_event) -> None:
|
||
card.configure(bg=border)
|
||
connect_hint.lower()
|
||
|
||
for widget in (card, inner):
|
||
widget.bind("<Button-1>", lambda _event, value=index: self._select_server(value))
|
||
widget.bind("<Double-Button-1>", lambda _event, value=index: self._connect_at(value))
|
||
widget.bind("<Button-3>", lambda event, value=index: self._server_menu(event, value))
|
||
widget.bind("<Enter>", _card_enter)
|
||
widget.bind("<Leave>", _card_leave)
|
||
for child in inner.winfo_children():
|
||
child.bind("<Button-1>", lambda _event, value=index: self._select_server(value))
|
||
child.bind("<Double-Button-1>", lambda _event, value=index: self._connect_at(value))
|
||
child.bind("<Button-3>", lambda event, value=index: self._server_menu(event, value))
|
||
child.bind("<Enter>", _card_enter)
|
||
child.bind("<Leave>", _card_leave)
|
||
|
||
return card
|
||
|
||
def _check_reachability(self, server: dict, index: int) -> None:
|
||
host = str(server.get("host") or "").strip()
|
||
if not host:
|
||
return
|
||
try:
|
||
port = int(server.get("port") or 22)
|
||
except (TypeError, ValueError):
|
||
return
|
||
|
||
cache_key = (host, port)
|
||
if cache_key in self.checked_hosts:
|
||
self.reachability[index] = self.checked_hosts[cache_key]
|
||
self._update_reachability_indicator(index, self.checked_hosts[cache_key])
|
||
return
|
||
|
||
def worker() -> None:
|
||
try:
|
||
with socket.create_connection((host, port), timeout=CONNECT_TIMEOUT):
|
||
reachable = True
|
||
except OSError:
|
||
reachable = False
|
||
self.checked_hosts[cache_key] = reachable
|
||
self.reachability[index] = reachable
|
||
self.after(0, lambda: self._update_reachability_indicator(index, reachable))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _update_reachability_indicator(self, index: int, reachable: bool) -> None:
|
||
label = self.reach_labels.get(index)
|
||
if label is not None and label.winfo_exists():
|
||
label.configure(
|
||
text="● erreichbar" if reachable else "● nicht erreichbar",
|
||
fg=STATUS_OK if reachable else COLORS["danger"],
|
||
)
|
||
|
||
def _refresh_commands(self) -> None:
|
||
for child in self.commands_host.winfo_children():
|
||
child.destroy()
|
||
|
||
commands = self.config_data["quick_commands"]
|
||
if not commands:
|
||
self._empty_state(self.commands_host, "Keine Quick Commands konfiguriert").pack(fill="x")
|
||
return
|
||
|
||
for command in commands:
|
||
card = self._card(self.commands_host, bg=COLORS["surface"], padx=16, pady=14)
|
||
card.pack(fill="x", pady=(0, 10))
|
||
|
||
head = tk.Frame(card, bg=COLORS["surface"])
|
||
head.pack(fill="x")
|
||
self._label(head, "»", bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 14, "bold")).pack(side="left", padx=(0, 8))
|
||
self._label(head, command.get("name") or "Command", bg=COLORS["surface"], fg=COLORS["text"], font=FONT_CARD).pack(side="left")
|
||
|
||
self._label(card, command.get("command") or "", bg=COLORS["surface"], fg=COLORS["accent"], font=UI_FONT_MONO).pack(anchor="w", pady=(9, 0))
|
||
|
||
actions = tk.Frame(card, bg=COLORS["surface"])
|
||
actions.pack(fill="x", pady=(12, 0))
|
||
self._button(
|
||
actions,
|
||
"Lokal ausführen",
|
||
lambda value=command: self._run_quick_command(value, via_ssh=False),
|
||
variant="ghost",
|
||
).pack(side="left")
|
||
self._button(
|
||
actions,
|
||
"Auf Server ausführen",
|
||
lambda value=command: self._run_quick_command(value, via_ssh=True),
|
||
).pack(side="left", padx=(8, 0))
|
||
|
||
def _refresh_settings(self) -> None:
|
||
settings = self.config_data["settings"]
|
||
terminal = settings["terminal"]
|
||
self.settings_vars["theme"].set(settings.get("theme", "neon-green"))
|
||
self.settings_vars["term"].set(terminal.get("term", "xterm-256color"))
|
||
self.settings_vars["enable_kitty_fix"].set(bool(terminal.get("enable_kitty_fix", True)))
|
||
|
||
def _show_view(self, view: str) -> None:
|
||
self.current_view = view
|
||
for page in self.pages.values():
|
||
page.pack_forget()
|
||
self.pages[view].pack(fill="both", expand=True)
|
||
|
||
titles = {"servers": "Server", "commands": "Quick Commands", "settings": "Settings"}
|
||
self.title_label.configure(text=titles[view])
|
||
|
||
for name, button in self.nav_buttons.items():
|
||
active = name == view
|
||
button.configure(
|
||
bg=COLORS["surface_2"] if active else COLORS["sidebar"],
|
||
fg=COLORS["accent"] if active else COLORS["text"],
|
||
highlightbackground=COLORS["line"] if active else COLORS["sidebar"],
|
||
)
|
||
|
||
def _select_server(self, index: int) -> None:
|
||
self.selected_index = index
|
||
self._refresh_server_cards()
|
||
self._load_selected_server()
|
||
|
||
def _load_selected_server(self) -> None:
|
||
if not self.config_data["servers"]:
|
||
self.form_title.configure(text="Kein Server")
|
||
for variable in self.vars.values():
|
||
variable.set(False if isinstance(variable, tk.BooleanVar) else "")
|
||
return
|
||
|
||
self.selected_index = min(self.selected_index, len(self.config_data["servers"]) - 1)
|
||
server = self.config_data["servers"][self.selected_index]
|
||
self.form_title.configure(text=f"{server.get('name') or 'Server'} bearbeiten")
|
||
self.auth_chip.configure(text=server.get("auth") or "key")
|
||
|
||
for key, variable in self.vars.items():
|
||
if key == "kitty_fix":
|
||
variable.set(bool(server.get(key, True)))
|
||
else:
|
||
variable.set(str(server.get(key, "")))
|
||
|
||
def _collect_server(self) -> dict | None:
|
||
name = self.vars["name"].get().strip()
|
||
host = self.vars["host"].get().strip()
|
||
user = self.vars["user"].get().strip()
|
||
if not name or not host or not user:
|
||
messagebox.showerror(APP_TITLE, "Name, Host und User sind Pflichtfelder.")
|
||
return None
|
||
|
||
try:
|
||
port = int(self.vars["port"].get() or "22")
|
||
except ValueError:
|
||
messagebox.showerror(APP_TITLE, "Port muss eine Zahl sein.")
|
||
return None
|
||
|
||
return {
|
||
"name": name,
|
||
"host": host,
|
||
"user": user,
|
||
"port": port,
|
||
"group": self.vars["group"].get().strip(),
|
||
"auth": self.vars["auth"].get().strip() or "key",
|
||
"key": self.vars["key"].get().strip(),
|
||
"password_id": self.vars["password_id"].get().strip(),
|
||
"kitty_fix": bool(self.vars["kitty_fix"].get()),
|
||
}
|
||
|
||
def _save_current_server(self) -> None:
|
||
server = self._collect_server()
|
||
if server is None:
|
||
return
|
||
self.config_data["servers"][self.selected_index] = server
|
||
save_config(self.path, self.config_data)
|
||
self.status_var.set("Server gespeichert")
|
||
self._refresh_all()
|
||
|
||
def _add_server(self) -> None:
|
||
self.config_data["servers"].append(
|
||
{
|
||
"name": "Neuer Server",
|
||
"host": "",
|
||
"user": "root",
|
||
"port": 22,
|
||
"group": "Homelab",
|
||
"auth": "key",
|
||
"key": "",
|
||
"password_id": "",
|
||
"kitty_fix": True,
|
||
}
|
||
)
|
||
self.selected_index = len(self.config_data["servers"]) - 1
|
||
self.current_view = "servers"
|
||
self._refresh_all()
|
||
|
||
def _duplicate_current_server(self) -> None:
|
||
if not self.config_data["servers"]:
|
||
return
|
||
original = self.config_data["servers"][self.selected_index]
|
||
duplicate = dict(original)
|
||
duplicate["name"] = f"{original.get('name') or 'Server'} (Kopie)"
|
||
self.config_data["servers"].append(duplicate)
|
||
self.selected_index = len(self.config_data["servers"]) - 1
|
||
save_config(self.path, self.config_data)
|
||
self.status_var.set("Server dupliziert")
|
||
self._refresh_all()
|
||
|
||
def _delete_current_server(self) -> None:
|
||
if not self.config_data["servers"]:
|
||
return
|
||
server = self.config_data["servers"][self.selected_index]
|
||
if not messagebox.askyesno(APP_TITLE, f"{server.get('name')} wirklich löschen?"):
|
||
return
|
||
del self.config_data["servers"][self.selected_index]
|
||
self.selected_index = max(0, self.selected_index - 1)
|
||
save_config(self.path, self.config_data)
|
||
self.status_var.set("Server gelöscht")
|
||
self._refresh_all()
|
||
|
||
def _save_settings(self) -> None:
|
||
self.config_data["settings"] = {
|
||
"theme": self.settings_vars["theme"].get().strip() or "neon-green",
|
||
"terminal": {
|
||
"term": self.settings_vars["term"].get().strip() or "xterm-256color",
|
||
"enable_kitty_fix": bool(self.settings_vars["enable_kitty_fix"].get()),
|
||
},
|
||
}
|
||
save_config(self.path, self.config_data)
|
||
self.status_var.set("Settings gespeichert (Theme ab Neustart)")
|
||
self.config_data = load_config(self.path)
|
||
self._refresh_settings()
|
||
|
||
def _open_config_folder(self) -> None:
|
||
folder = self.path.parent
|
||
try:
|
||
if IS_WINDOWS:
|
||
os.startfile(str(folder))
|
||
else:
|
||
subprocess.Popen(["xdg-open", str(folder)])
|
||
except OSError as error:
|
||
messagebox.showerror(APP_TITLE, str(error))
|
||
return
|
||
self.status_var.set(f"Config-Ordner geöffnet: {folder}")
|
||
|
||
def _about(self) -> None:
|
||
messagebox.showinfo(
|
||
"Über PulseGate Desktop",
|
||
f"PulseGate Desktop {APP_VERSION}\n\n"
|
||
"SSH-Manager für Linux und Windows.\n\n"
|
||
f"{APP_URL}",
|
||
)
|
||
|
||
def _reload(self) -> None:
|
||
self.config_data = load_config(self.path)
|
||
self.selected_index = min(self.selected_index, max(0, len(self.config_data["servers"]) - 1))
|
||
self.status_var.set("Neu geladen")
|
||
self._refresh_all()
|
||
|
||
def _current_server(self) -> dict | None:
|
||
if not self.config_data["servers"]:
|
||
messagebox.showerror(APP_TITLE, "Kein Server ausgewählt.")
|
||
return None
|
||
return self.config_data["servers"][self.selected_index]
|
||
|
||
def _show_command(self) -> None:
|
||
server = self._current_server()
|
||
if not server:
|
||
return
|
||
self.command_text.delete("1.0", "end")
|
||
self.command_text.insert("1.0", display_command(server))
|
||
|
||
def _copy_command(self) -> None:
|
||
server = self._current_server()
|
||
if not server:
|
||
return
|
||
self.clipboard_clear()
|
||
self.clipboard_append(display_command(server))
|
||
self.update()
|
||
self.status_var.set(f"SSH Befehl kopiert: {server.get('name')}")
|
||
|
||
def _connect_at(self, index: int) -> None:
|
||
self.selected_index = index
|
||
self._load_selected_server()
|
||
self._connect()
|
||
|
||
def _connect(self) -> None:
|
||
server = self._current_server()
|
||
if not server:
|
||
return
|
||
if not self.terminal:
|
||
messagebox.showerror(APP_TITLE, "Kein unterstützter Terminal-Emulator gefunden.")
|
||
return
|
||
|
||
env = os.environ.copy()
|
||
if not IS_WINDOWS:
|
||
settings = self.config_data["settings"]
|
||
terminal_settings = settings.get("terminal", {})
|
||
if terminal_settings.get("enable_kitty_fix", True) and server.get("kitty_fix", True):
|
||
env["TERM"] = terminal_settings.get("term") or "xterm-256color"
|
||
|
||
try:
|
||
subprocess.Popen(terminal_command(self.terminal, server), env=env)
|
||
except OSError as error:
|
||
messagebox.showerror(APP_TITLE, str(error))
|
||
return
|
||
|
||
self.status_var.set(f"SSH gestartet: {server.get('name')}")
|
||
self._show_command()
|
||
|
||
def _run_quick_command(self, command: dict, via_ssh: bool = False) -> None:
|
||
if not self.terminal:
|
||
messagebox.showerror(APP_TITLE, "Kein unterstützter Terminal-Emulator gefunden.")
|
||
return
|
||
|
||
raw = (command.get("command") or "").strip()
|
||
name = command.get("name") or "Quick Command"
|
||
if not raw:
|
||
messagebox.showwarning(APP_TITLE, f"'{name}' hat keinen Befehl.")
|
||
return
|
||
|
||
try:
|
||
if via_ssh:
|
||
server = self._current_server()
|
||
if not server:
|
||
return
|
||
inner = remote_ssh_command(server, raw)
|
||
title = f"PulseGate - {server.get('name')}"
|
||
else:
|
||
inner = raw
|
||
title = f"PulseGate - {name}"
|
||
subprocess.Popen(open_in_terminal(self.terminal, title, inner))
|
||
except OSError as error:
|
||
messagebox.showerror(APP_TITLE, str(error))
|
||
return
|
||
|
||
self.status_var.set(f"Befehl gestartet: {name}" + (" (via SSH)" if via_ssh else ""))
|
||
|
||
def _server_menu(self, event: tk.Event, index: int) -> None:
|
||
self._select_server(index)
|
||
menu = tk.Menu(self, tearoff=0)
|
||
menu.add_command(label="Verbinden", command=self._connect)
|
||
menu.add_command(label="SSH Befehl kopieren", command=self._copy_command)
|
||
menu.add_separator()
|
||
menu.add_command(label="Duplizieren", command=self._duplicate_current_server)
|
||
menu.add_command(label="Löschen ...", command=self._delete_current_server)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _label(self, parent: tk.Widget, text: str, *, bg: str, fg: str, font: tuple, wraplength: int = 0) -> tk.Label:
|
||
return tk.Label(parent, text=text, bg=bg, fg=fg, font=font, anchor="w", justify="left", wraplength=wraplength)
|
||
|
||
def _entry(self, parent: tk.Widget, variable: tk.StringVar) -> tk.Entry:
|
||
return tk.Entry(
|
||
parent,
|
||
textvariable=variable,
|
||
bg=COLORS["field"],
|
||
fg=COLORS["text"],
|
||
insertbackground=COLORS["text"],
|
||
relief="flat",
|
||
highlightthickness=1,
|
||
highlightbackground=COLORS["line"],
|
||
highlightcolor=COLORS["accent"],
|
||
font=FONT,
|
||
)
|
||
|
||
def _option_menu(self, parent: tk.Widget, variable: tk.StringVar, options: tuple[str, ...]) -> tk.OptionMenu:
|
||
widget = tk.OptionMenu(parent, variable, *options)
|
||
widget.configure(
|
||
bg=COLORS["field"],
|
||
fg=COLORS["text"],
|
||
activebackground=COLORS["surface_2"],
|
||
activeforeground=COLORS["text"],
|
||
highlightthickness=1,
|
||
highlightbackground=COLORS["line"],
|
||
relief="flat",
|
||
font=FONT,
|
||
)
|
||
widget["menu"].configure(
|
||
bg=COLORS["surface_2"],
|
||
fg=COLORS["text"],
|
||
activebackground=COLORS["accent"],
|
||
activeforeground="#001a10",
|
||
font=FONT,
|
||
)
|
||
return widget
|
||
|
||
def _button(self, parent: tk.Widget, text: str, command, *, variant: str = "primary") -> tk.Button:
|
||
if variant == "primary":
|
||
bg = COLORS["accent"]
|
||
fg = "#001a10"
|
||
hover_bg = COLORS["accent_hi"]
|
||
hover_fg = "#001a10"
|
||
elif variant == "ghost":
|
||
bg = COLORS["surface_2"]
|
||
fg = COLORS["text"]
|
||
hover_bg = COLORS["surface_3"]
|
||
hover_fg = COLORS["text"]
|
||
else:
|
||
bg = "#3a1717"
|
||
fg = COLORS["danger"]
|
||
hover_bg = "#522020"
|
||
hover_fg = "#ffd0d0"
|
||
|
||
button = tk.Button(
|
||
parent,
|
||
text=text,
|
||
command=command,
|
||
bg=bg,
|
||
fg=fg,
|
||
activebackground=hover_bg,
|
||
activeforeground=hover_fg,
|
||
relief="flat",
|
||
bd=0,
|
||
padx=14,
|
||
pady=9,
|
||
font=(UI_FONT[0], 10, "bold"),
|
||
cursor="hand2",
|
||
highlightthickness=0,
|
||
)
|
||
button.bind("<Enter>", lambda _event: button.configure(bg=hover_bg, fg=hover_fg))
|
||
button.bind("<Leave>", lambda _event: button.configure(bg=bg, fg=fg))
|
||
return button
|
||
|
||
def _nav_button(self, parent: tk.Widget, text: str, command) -> tk.Button:
|
||
button = tk.Button(
|
||
parent,
|
||
text=text,
|
||
command=command,
|
||
bg=COLORS["sidebar"],
|
||
fg=COLORS["text"],
|
||
activebackground=COLORS["surface_2"],
|
||
activeforeground=COLORS["accent"],
|
||
relief="flat",
|
||
bd=0,
|
||
padx=12,
|
||
pady=10,
|
||
anchor="w",
|
||
font=(UI_FONT[0], 10, "bold"),
|
||
cursor="hand2",
|
||
highlightthickness=1,
|
||
highlightbackground=COLORS["sidebar"],
|
||
)
|
||
|
||
def _enter(_event) -> None:
|
||
if not self._view_active(text):
|
||
button.configure(bg=COLORS["surface_2"])
|
||
|
||
def _leave(_event) -> None:
|
||
if self._view_active(text):
|
||
button.configure(bg=COLORS["surface_2"], highlightbackground=COLORS["line"])
|
||
else:
|
||
button.configure(bg=COLORS["sidebar"], highlightbackground=COLORS["sidebar"])
|
||
|
||
button.bind("<Enter>", _enter)
|
||
button.bind("<Leave>", _leave)
|
||
return button
|
||
|
||
def _view_active(self, name: str) -> bool:
|
||
mapping = {"Server": "servers", "Commands": "commands", "Settings": "settings"}
|
||
return mapping.get(name) == self.current_view
|
||
|
||
def _card(self, parent: tk.Widget, *, bg: str, padx: int, pady: int) -> tk.Frame:
|
||
return tk.Frame(parent, bg=bg, padx=padx, pady=pady, highlightthickness=1, highlightbackground=COLORS["line"])
|
||
|
||
def _chip(self, parent: tk.Widget, text: str, fg: str, *, bg: str | None = None) -> tk.Label:
|
||
return tk.Label(
|
||
parent,
|
||
text=text,
|
||
bg=bg or parent.cget("bg"),
|
||
fg=fg,
|
||
font=(UI_FONT[0], 9, "bold"),
|
||
padx=7,
|
||
pady=3,
|
||
highlightthickness=1,
|
||
highlightbackground=COLORS["surface_3"],
|
||
)
|
||
|
||
def _empty_state(self, parent: tk.Widget, text: str, symbol: str = "◦") -> tk.Frame:
|
||
card = self._card(parent, bg=COLORS["surface"], padx=16, pady=18)
|
||
self._label(card, symbol, bg=COLORS["surface"], fg=COLORS["accent"], font=(UI_FONT[0], 18, "bold")).pack(side="left", padx=(0, 12))
|
||
self._label(card, text, bg=COLORS["surface"], fg=COLORS["muted"], font=FONT).pack(side="left")
|
||
return card
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = PulseGateDesktop(config_path())
|
||
app.mainloop() |