feat: Apple-style UI with draggable PanedWindow layout

Replace fixed-width panels with horizontal PanedWindow for resizable
left/center/right columns. Unify color palette, smooth button styles,
refine entry fields with focus highlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-05-26 18:07:01 +03:00
parent 419e268ee2
commit 38ebcc5d04

View File

@@ -26,7 +26,7 @@ VERSION = "2.0"
# ── Card Colors (Catppuccin) ────────────────────────────────── # ── Card Colors (Catppuccin) ──────────────────────────────────
CARD_COLORS = [ CARD_COLORS = [
"#89b4fa", # blue "#6c8fff", # blue
"#a6e3a1", # green "#a6e3a1", # green
"#cba6f7", # purple "#cba6f7", # purple
"#fab387", # orange "#fab387", # orange
@@ -521,8 +521,8 @@ def setup_entry_clipboard(entry):
return "break" return "break"
def _context_menu(event): def _context_menu(event):
menu = tk.Menu(entry, tearoff=0, bg="#313244", fg="#cdd6f4", menu = tk.Menu(entry, tearoff=0, bg="#252536", fg="#e0e0e8",
activebackground="#45475a") activebackground="#353550")
menu.add_command(label="Cut", command=_cut) menu.add_command(label="Cut", command=_cut)
menu.add_command(label="Copy", command=_copy) menu.add_command(label="Copy", command=_copy)
menu.add_command(label="Paste", command=_paste) menu.add_command(label="Paste", command=_paste)
@@ -814,9 +814,9 @@ class TerminalWidget(tk.Frame):
self.text = tk.Text( self.text = tk.Text(
self, self,
bg="#1e1e2e", bg="#1e1e2e",
fg="#cdd6f4", fg="#e0e0e8",
insertbackground="#cdd6f4", insertbackground="#e0e0e8",
selectbackground="#45475a", selectbackground="#353550",
font=("Consolas", 11), font=("Consolas", 11),
wrap=tk.CHAR, wrap=tk.CHAR,
padx=6, padx=6,
@@ -870,7 +870,7 @@ class TerminalWidget(tk.Frame):
self.text.bind("<Control-Shift-C>", self._term_copy) self.text.bind("<Control-Shift-C>", self._term_copy)
self.text.tag_configure("error", foreground="#f38ba8") self.text.tag_configure("error", foreground="#f38ba8")
self.text.tag_configure("info", foreground="#89b4fa") self.text.tag_configure("info", foreground="#6c8fff")
def connect(self, host, port, user, password, key_file=None): def connect(self, host, port, user, password, key_file=None):
self.running = True self.running = True
@@ -1054,11 +1054,12 @@ class SessionDialog(tk.Toplevel):
self.entries = {} self.entries = {}
for i, (label, key, default) in enumerate(fields): for i, (label, key, default) in enumerate(fields):
tk.Label(frame, text=label, bg="#1e1e2e", fg="#cdd6f4", tk.Label(frame, text=label, bg="#1e1e2e", fg="#e0e0e8",
font=("Consolas", 10)).grid(row=i, column=0, sticky="w", pady=3) font=("Helvetica", 10)).grid(row=i, column=0, sticky="w", pady=3)
entry = tk.Entry(frame, width=35, bg="#313244", fg="#cdd6f4", entry = tk.Entry(frame, width=35, bg="#252536", fg="#e0e0e8",
insertbackground="#cdd6f4", relief=tk.FLAT, insertbackground="#e0e0e8", relief=tk.FLAT,
font=("Consolas", 10)) font=("Helvetica", 10), highlightthickness=1,
highlightcolor="#6c8fff", highlightbackground="#2e2e42")
if key == "password": if key == "password":
entry.configure(show="*") entry.configure(show="*")
entry.insert(0, default) entry.insert(0, default)
@@ -1068,14 +1069,15 @@ class SessionDialog(tk.Toplevel):
# Browse button for SSH key file # Browse button for SSH key file
key_entry = self.entries["key_file"] key_entry = self.entries["key_file"]
browse_btn = tk.Button(frame, text="...", bg="#45475a", fg="#cdd6f4", browse_btn = tk.Button(frame, text="...", bg="#2a2a3d", fg="#e0e0e8",
relief=tk.FLAT, font=("Consolas", 9), padx=4, relief=tk.FLAT, bd=0, font=("Helvetica", 9), padx=6,
cursor="hand2", highlightthickness=0,
command=self._browse_key) command=self._browse_key)
key_row = len(fields) - 1 # last field key_row = len(fields) - 1 # last field
browse_btn.grid(row=key_row, column=2, padx=4, pady=3) browse_btn.grid(row=key_row, column=2, padx=4, pady=3)
row_group = len(fields) row_group = len(fields)
tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#cdd6f4", tk.Label(frame, text="Группа:", bg="#1e1e2e", fg="#e0e0e8",
font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3) font=("Consolas", 10)).grid(row=row_group, column=0, sticky="w", pady=3)
group_frame = tk.Frame(frame, bg="#1e1e2e") group_frame = tk.Frame(frame, bg="#1e1e2e")
group_frame.grid(row=row_group, column=1, sticky="ew", pady=3, padx=(8, 0)) group_frame.grid(row=row_group, column=1, sticky="ew", pady=3, padx=(8, 0))
@@ -1090,13 +1092,15 @@ class SessionDialog(tk.Toplevel):
btn_frame = tk.Frame(frame, bg="#1e1e2e") btn_frame = tk.Frame(frame, bg="#1e1e2e")
btn_frame.grid(row=row_group + 1, column=0, columnspan=2, pady=(12, 0)) btn_frame.grid(row=row_group + 1, column=0, columnspan=2, pady=(12, 0))
ok_btn = tk.Button(btn_frame, text="OK", bg="#45475a", fg="#cdd6f4", ok_btn = tk.Button(btn_frame, text="OK", bg="#6c8fff", fg="#ffffff",
activebackground="#585b70", relief=tk.FLAT, activebackground="#5a7de6", relief=tk.FLAT, bd=0,
font=("Consolas", 10), padx=20, pady=4, command=self._ok) font=("Helvetica", 10, "bold"), padx=24, pady=5,
cursor="hand2", highlightthickness=0, command=self._ok)
ok_btn.pack(side=tk.LEFT, padx=4) ok_btn.pack(side=tk.LEFT, padx=4)
cancel_btn = tk.Button(btn_frame, text="Отмена", bg="#45475a", fg="#cdd6f4", cancel_btn = tk.Button(btn_frame, text="Отмена", bg="#2a2a3d", fg="#8899aa",
activebackground="#585b70", relief=tk.FLAT, activebackground="#353550", relief=tk.FLAT, bd=0,
font=("Consolas", 10), padx=20, pady=4, command=self.destroy) font=("Helvetica", 10), padx=24, pady=5,
cursor="hand2", highlightthickness=0, command=self.destroy)
cancel_btn.pack(side=tk.LEFT, padx=4) cancel_btn.pack(side=tk.LEFT, padx=4)
self.entries["host"].focus_set() self.entries["host"].focus_set()
@@ -1156,14 +1160,14 @@ class FileManagerWidget(tk.Frame):
# Don't auto-connect — caller triggers _connect_sftp after SSH is ready # Don't auto-connect — caller triggers _connect_sftp after SSH is ready
def _build_ui(self): def _build_ui(self):
bg, fg = "#1e1e2e", "#cdd6f4" bg, fg = "#1e1e2e", "#e0e0e8"
# Toolbar toolbar = tk.Frame(self, bg="#1e1e2e")
toolbar = tk.Frame(self, bg="#181825") toolbar.pack(fill=tk.X, padx=4, pady=(4, 2))
toolbar.pack(fill=tk.X)
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a", btn_s = {"bg": "#2a2a3d", "fg": "#8899cc", "activebackground": "#353550",
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 4, "pady": 2} "relief": tk.FLAT, "bd": 0, "font": ("Helvetica", 9), "padx": 6, "pady": 3,
"cursor": "hand2", "highlightthickness": 0}
tk.Button(toolbar, text="⬆ Вверх", command=self._go_up, **btn_s).pack(side=tk.LEFT, padx=2, pady=2) tk.Button(toolbar, text="⬆ Вверх", command=self._go_up, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="⟳ Обновить", command=self._refresh, **btn_s).pack(side=tk.LEFT, padx=2, pady=2) tk.Button(toolbar, text="⟳ Обновить", command=self._refresh, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
@@ -1171,15 +1175,16 @@ class FileManagerWidget(tk.Frame):
tk.Button(toolbar, text="⬆ Загрузить", command=self._upload, **btn_s).pack(side=tk.LEFT, padx=2, pady=2) tk.Button(toolbar, text="⬆ Загрузить", command=self._upload, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="⬇ Скачать", command=self._download, **btn_s).pack(side=tk.LEFT, padx=2, pady=2) tk.Button(toolbar, text="⬇ Скачать", command=self._download, **btn_s).pack(side=tk.LEFT, padx=2, pady=2)
# Path bar
path_frame = tk.Frame(self, bg=bg) path_frame = tk.Frame(self, bg=bg)
path_frame.pack(fill=tk.X, padx=4, pady=2) path_frame.pack(fill=tk.X, padx=6, pady=2)
tk.Label(path_frame, text="Путь:", bg=bg, fg="#6c7086", tk.Label(path_frame, text="Путь:", bg=bg, fg="#6c7086",
font=("Consolas", 9)).pack(side=tk.LEFT) font=("Helvetica", 9)).pack(side=tk.LEFT)
self.path_var = tk.StringVar(value="/") self.path_var = tk.StringVar(value="/")
self.path_entry = tk.Entry(path_frame, textvariable=self.path_var, self.path_entry = tk.Entry(path_frame, textvariable=self.path_var,
bg="#313244", fg=fg, insertbackground=fg, bg="#252536", fg=fg, insertbackground=fg,
relief=tk.FLAT, font=("Consolas", 9)) relief=tk.FLAT, font=("Helvetica", 9),
highlightthickness=1, highlightcolor="#6c8fff",
highlightbackground="#2e2e42")
self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4) self.path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4)
self.path_entry.bind("<Return>", lambda e: self._navigate(self.path_var.get())) self.path_entry.bind("<Return>", lambda e: self._navigate(self.path_var.get()))
setup_entry_clipboard(self.path_entry) setup_entry_clipboard(self.path_entry)
@@ -1187,7 +1192,6 @@ class FileManagerWidget(tk.Frame):
tk.Button(path_frame, text="", command=lambda: self._navigate(self.path_var.get()), tk.Button(path_frame, text="", command=lambda: self._navigate(self.path_var.get()),
**btn_s).pack(side=tk.RIGHT) **btn_s).pack(side=tk.RIGHT)
# File tree
tree_frame = tk.Frame(self, bg=bg) tree_frame = tk.Frame(self, bg=bg)
tree_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2) tree_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=2)
@@ -1215,17 +1219,16 @@ class FileManagerWidget(tk.Frame):
self.tree.bind("<Button-2>", self._context_menu) self.tree.bind("<Button-2>", self._context_menu)
self.tree.bind("<Control-Button-1>", self._context_menu) self.tree.bind("<Control-Button-1>", self._context_menu)
# Status bar
self.status_var = tk.StringVar(value="Подключение...") self.status_var = tk.StringVar(value="Подключение...")
tk.Label(self, textvariable=self.status_var, bg="#181825", fg="#6c7086", tk.Label(self, textvariable=self.status_var, bg="#1e1e2e", fg="#555566",
font=("Consolas", 8), anchor=tk.W).pack(fill=tk.X, padx=4) font=("Helvetica", 8), anchor=tk.W).pack(fill=tk.X, padx=6)
# Close button
close_frame = tk.Frame(self, bg=bg) close_frame = tk.Frame(self, bg=bg)
close_frame.pack(fill=tk.X, side=tk.BOTTOM) close_frame.pack(fill=tk.X, side=tk.BOTTOM)
tk.Button(close_frame, text="✕ Закрыть вкладку", tk.Button(close_frame, text="✕ Закрыть вкладку",
bg="#313244", fg="#cdd6f4", activebackground="#45475a", bg="#2a2a3d", fg="#8899aa", activebackground="#353550",
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2, relief=tk.FLAT, bd=0, font=("Helvetica", 9), padx=8, pady=3,
cursor="hand2", highlightthickness=0,
command=self._close).pack(side=tk.RIGHT, padx=4, pady=2) command=self._close).pack(side=tk.RIGHT, padx=4, pady=2)
def _connect_sftp(self): def _connect_sftp(self):
@@ -1272,8 +1275,8 @@ class FileManagerWidget(tk.Frame):
self.tree.insert("", tk.END, text=name, values=(size, mtime, perms), self.tree.insert("", tk.END, text=name, values=(size, mtime, perms),
tags=("dir" if is_dir else "file",)) tags=("dir" if is_dir else "file",))
self.tree.tag_configure("dir", foreground="#89b4fa") self.tree.tag_configure("dir", foreground="#6c8fff")
self.tree.tag_configure("file", foreground="#cdd6f4") self.tree.tag_configure("file", foreground="#e0e0e8")
@staticmethod @staticmethod
def _human_size(n): def _human_size(n):
@@ -1471,8 +1474,8 @@ class FileManagerWidget(tk.Frame):
if sel: if sel:
self.tree.selection_set(sel) self.tree.selection_set(sel)
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4", menu = tk.Menu(self, tearoff=0, bg="#252536", fg="#e0e0e8",
activebackground="#45475a") activebackground="#353550")
if sel: if sel:
tags = self.tree.item(sel, "tags") tags = self.tree.item(sel, "tags")
if "dir" in tags: if "dir" in tags:
@@ -1671,22 +1674,39 @@ class App(tk.Tk):
style = ttk.Style() style = ttk.Style()
style.theme_use("clam") style.theme_use("clam")
bg = "#1e1e2e" bg = "#1e1e2e"
fg = "#cdd6f4" fg = "#e0e0e8"
surface = "#252536"
border = "#2e2e42"
accent = "#6c8fff"
hover = "#353550"
muted = "#6c7086"
style.configure(".", background=bg, foreground=fg, fieldbackground="#313244", borderwidth=0) style.configure(".", background=bg, foreground=fg, fieldbackground=surface,
borderwidth=0, focuscolor=accent)
style.configure("TFrame", background=bg) style.configure("TFrame", background=bg)
style.configure("TLabel", background=bg, foreground=fg) style.configure("TLabel", background=bg, foreground=fg)
style.configure("TButton", background="#45475a", foreground=fg, padding=(10, 5)) style.configure("TButton", background=surface, foreground=fg, padding=(12, 6),
style.map("TButton", background=[("active", "#585b70")]) relief="flat")
style.configure("Treeview", background="#313244", foreground=fg, fieldbackground="#313244", style.map("TButton", background=[("active", hover)])
rowheight=28, borderwidth=0) style.configure("Treeview", background=surface, foreground=fg,
style.configure("Treeview.Heading", background="#45475a", foreground=fg) fieldbackground=surface, rowheight=28, borderwidth=0)
style.map("Treeview", background=[("selected", "#45475a")]) style.configure("Treeview.Heading", background="#22222f", foreground=muted,
style.configure("TNotebook", background=bg, borderwidth=0) padding=(8, 5), relief="flat", font=("Helvetica", 9))
style.configure("TNotebook.Tab", background="#45475a", foreground=fg, padding=(12, 6)) style.map("Treeview", background=[("selected", "#3a3a55")])
style.map("TNotebook.Tab", background=[("selected", "#585b70")]) style.map("Treeview.Heading", background=[("active", "#2a2a3d")])
style.configure("TEntry", fieldbackground="#313244", foreground=fg) style.configure("TNotebook", background=bg, borderwidth=0, tabmargins=[2, 4, 0, 0])
style.configure("TCombobox", fieldbackground="#313244", foreground=fg) style.configure("TNotebook.Tab", background=surface, foreground=muted,
padding=(18, 8), font=("Helvetica", 10))
style.map("TNotebook.Tab", background=[("selected", bg)],
foreground=[("selected", fg)])
style.configure("TEntry", fieldbackground=surface, foreground=fg, padding=5)
style.configure("TCombobox", fieldbackground=surface, foreground=fg)
style.configure("TSeparator", background=border)
style.configure("Sash", sashthickness=5, gripcount=0)
style.configure("TPanedwindow", background=border)
style.configure("TScrollbar", background=surface, troughcolor=bg,
borderwidth=0, relief="flat")
style.map("TScrollbar", background=[("active", hover)])
def _zoom(self, delta): def _zoom(self, delta):
"""Change font size. delta=+1/-1 or 0 to reset.""" """Change font size. delta=+1/-1 or 0 to reset."""
@@ -1739,34 +1759,34 @@ class App(tk.Tk):
canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="") canvas.create_oval(dx - 1, dy - 1, dx + 1, dy + 1, fill="#6c8fff", outline="")
def _build_ui(self): def _build_ui(self):
# ── Top: minimal title bar (Termius style) ── top_bar = tk.Frame(self, bg="#1a1a2a", height=38)
top_bar = tk.Frame(self, bg="#14141e", height=40)
top_bar.pack(fill=tk.X, side=tk.TOP) top_bar.pack(fill=tk.X, side=tk.TOP)
top_bar.pack_propagate(False) top_bar.pack_propagate(False)
tk.Label(top_bar, text=f"{APP_NAME}", bg="#14141e", fg="#e0e0e0", tk.Label(top_bar, text=f"{APP_NAME}", bg="#1a1a2a", fg="#e0e0e8",
font=("Helvetica", 13, "bold")).pack(side=tk.LEFT, padx=12, pady=6) font=("Helvetica", 13, "bold")).pack(side=tk.LEFT, padx=14, pady=6)
tk.Label(top_bar, text=f"v{VERSION}", bg="#14141e", fg="#555", tk.Label(top_bar, text=f"v{VERSION}", bg="#1a1a2a", fg="#555566",
font=("Helvetica", 10)).pack(side=tk.LEFT, pady=6) font=("Helvetica", 10)).pack(side=tk.LEFT, pady=6)
# ── Main layout ── # ── Main layout — horizontal PanedWindow (draggable dividers) ──
main_frame = tk.Frame(self, bg="#1e1e2e") self.main_paned = tk.PanedWindow(
main_frame.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) self, orient=tk.HORIZONTAL, bg="#2e2e42",
sashwidth=5, sashrelief=tk.FLAT, opaqueresize=True,
sashpad=0,
)
self.main_paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
# Left: card-based session panel # Left: card-based session panel
self.left_panel = tk.Frame(main_frame, bg="#1e1e2e", width=320) self.left_panel = tk.Frame(self.main_paned, bg="#1e1e2e")
self.left_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 4))
self.left_panel.pack_propagate(False)
self._build_card_panel(self.left_panel) self._build_card_panel(self.left_panel)
self.main_paned.add(self.left_panel, minsize=220, width=320)
# Center: terminal tabs # Center: terminal tabs
center_frame = tk.Frame(main_frame, bg="#1e1e2e") center_frame = tk.Frame(self.main_paned, bg="#1e1e2e")
center_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.notebook = ttk.Notebook(center_frame) self.notebook = ttk.Notebook(center_frame)
self.notebook.pack(fill=tk.BOTH, expand=True) self.notebook.pack(fill=tk.BOTH, expand=True)
# Welcome tab
welcome = tk.Frame(self.notebook, bg="#1e1e2e") welcome = tk.Frame(self.notebook, bg="#1e1e2e")
self.notebook.add(welcome, text="Welcome") self.notebook.add(welcome, text="Welcome")
tk.Label( tk.Label(
@@ -1782,32 +1802,33 @@ class App(tk.Tk):
font=("Helvetica", 12), font=("Helvetica", 12),
justify=tk.CENTER, justify=tk.CENTER,
bg="#1e1e2e", bg="#1e1e2e",
fg="#cdd6f4", fg="#e0e0e8",
).pack(expand=True) ).pack(expand=True)
self.main_paned.add(center_frame, minsize=300, width=600)
# Right: commands + agent panel # Right: commands + agent panel
self.right_panel = tk.Frame(main_frame, bg="#1e1e2e", width=360) self.right_panel = tk.Frame(self.main_paned, bg="#1e1e2e")
self.right_panel.pack(side=tk.RIGHT, fill=tk.Y, padx=(4, 0))
self.right_panel.pack_propagate(False)
self._build_right_panel(self.right_panel) self._build_right_panel(self.right_panel)
self.main_paned.add(self.right_panel, minsize=200, width=360)
# ── Card Panel (Left) ───────────────────────────────────── # ── Card Panel (Left) ─────────────────────────────────────
def _build_card_panel(self, parent): def _build_card_panel(self, parent):
# Toolbar toolbar = tk.Frame(parent, bg="#1e1e2e")
toolbar = tk.Frame(parent, bg="#181825") toolbar.pack(fill=tk.X, padx=6, pady=(6, 2))
toolbar.pack(fill=tk.X)
btn_style = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a", btn_style = {"bg": "#2a2a3d", "fg": "#8899cc", "activebackground": "#353550",
"activeforeground": "#b4d0fb", "relief": tk.FLAT, "activeforeground": "#aabbee", "relief": tk.FLAT, "bd": 0,
"font": ("Consolas", 10), "padx": 8, "pady": 4} "font": ("Helvetica", 10), "padx": 10, "pady": 5,
"cursor": "hand2", "highlightthickness": 0}
tk.Button(toolbar, text=" Сессия", command=self._new_session, **btn_style).pack(side=tk.LEFT, padx=2, pady=3) tk.Button(toolbar, text=" Сессия", command=self._new_session, **btn_style).pack(side=tk.LEFT, padx=(0, 3), pady=2)
tk.Button(toolbar, text=" Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=2, pady=3) tk.Button(toolbar, text=" Группа", command=self._new_group, **btn_style).pack(side=tk.LEFT, padx=3, pady=2)
tk.Button(toolbar, text="", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._edit_session, width=3, **btn_style).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._delete_session, width=3, **btn_style).pack(side=tk.LEFT, padx=2, pady=2)
tk.Button(toolbar, text="", command=self._export_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._export_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=2, pady=2)
tk.Button(toolbar, text="", command=self._import_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=1, pady=3) tk.Button(toolbar, text="", command=self._import_sessions, width=3, **btn_style).pack(side=tk.RIGHT, padx=2, pady=2)
# Scrollable card area # Scrollable card area
self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0) self.card_canvas = tk.Canvas(parent, bg="#1e1e2e", highlightthickness=0)
@@ -1836,45 +1857,49 @@ class App(tk.Tk):
self.card_canvas.bind("<Button-4>", lambda e: self.card_canvas.yview_scroll(-1, "units")) self.card_canvas.bind("<Button-4>", lambda e: self.card_canvas.yview_scroll(-1, "units"))
self.card_canvas.bind("<Button-5>", lambda e: self.card_canvas.yview_scroll(1, "units")) self.card_canvas.bind("<Button-5>", lambda e: self.card_canvas.yview_scroll(1, "units"))
# Quick connect at bottom qf = tk.Frame(parent, bg="#1e1e2e", padx=10, pady=10)
qf = tk.Frame(parent, bg="#181825", padx=8, pady=8)
qf.pack(fill=tk.X, side=tk.BOTTOM) qf.pack(fill=tk.X, side=tk.BOTTOM)
tk.Label(qf, text="Быстрое подключение", bg="#181825", fg="#6c7086", sep = tk.Frame(qf, bg="#2e2e42", height=1)
font=("Consolas", 9, "bold")).pack(anchor="w", pady=(0, 4)) sep.pack(fill=tk.X, pady=(0, 8))
row1 = tk.Frame(qf, bg="#181825") tk.Label(qf, text="Быстрое подключение", bg="#1e1e2e", fg="#6c7086",
row1.pack(fill=tk.X, pady=1) font=("Helvetica", 9, "bold")).pack(anchor="w", pady=(0, 6))
tk.Label(row1, text="Хост:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT)
self.q_host = tk.Entry(row1, width=14, bg="#313244", fg="#cdd6f4", entry_style = {"bg": "#252536", "fg": "#e0e0e8", "insertbackground": "#e0e0e8",
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9)) "relief": tk.FLAT, "font": ("Helvetica", 10),
self.q_host.pack(side=tk.LEFT, padx=(4, 6)) "highlightthickness": 1, "highlightcolor": "#6c8fff",
"highlightbackground": "#2e2e42"}
row1 = tk.Frame(qf, bg="#1e1e2e")
row1.pack(fill=tk.X, pady=2)
tk.Label(row1, text="Хост:", bg="#1e1e2e", fg="#6c7086", font=("Helvetica", 9)).pack(side=tk.LEFT)
self.q_host = tk.Entry(row1, width=14, **entry_style)
self.q_host.pack(side=tk.LEFT, padx=(4, 8))
setup_entry_clipboard(self.q_host) setup_entry_clipboard(self.q_host)
tk.Label(row1, text="Порт:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT) tk.Label(row1, text="Порт:", bg="#1e1e2e", fg="#6c7086", font=("Helvetica", 9)).pack(side=tk.LEFT)
self.q_port = tk.Entry(row1, width=5, bg="#313244", fg="#cdd6f4", self.q_port = tk.Entry(row1, width=5, **entry_style)
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_port.insert(0, "22") self.q_port.insert(0, "22")
self.q_port.pack(side=tk.LEFT, padx=4) self.q_port.pack(side=tk.LEFT, padx=4)
setup_entry_clipboard(self.q_port) setup_entry_clipboard(self.q_port)
row2 = tk.Frame(qf, bg="#181825") row2 = tk.Frame(qf, bg="#1e1e2e")
row2.pack(fill=tk.X, pady=1) row2.pack(fill=tk.X, pady=2)
tk.Label(row2, text="Юзер:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT) tk.Label(row2, text="Юзер:", bg="#1e1e2e", fg="#6c7086", font=("Helvetica", 9)).pack(side=tk.LEFT)
self.q_user = tk.Entry(row2, width=10, bg="#313244", fg="#cdd6f4", self.q_user = tk.Entry(row2, width=10, **entry_style)
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_user.insert(0, "root") self.q_user.insert(0, "root")
self.q_user.pack(side=tk.LEFT, padx=(4, 6)) self.q_user.pack(side=tk.LEFT, padx=(4, 8))
setup_entry_clipboard(self.q_user) setup_entry_clipboard(self.q_user)
tk.Label(row2, text="Пароль:", bg="#181825", fg="#6c7086", font=("Consolas", 9)).pack(side=tk.LEFT) tk.Label(row2, text="Пароль:", bg="#1e1e2e", fg="#6c7086", font=("Helvetica", 9)).pack(side=tk.LEFT)
self.q_pass = tk.Entry(row2, width=10, bg="#313244", fg="#cdd6f4", show="*", self.q_pass = tk.Entry(row2, width=10, show="*", **entry_style)
insertbackground="#cdd6f4", relief=tk.FLAT, font=("Consolas", 9))
self.q_pass.pack(side=tk.LEFT, padx=4) self.q_pass.pack(side=tk.LEFT, padx=4)
setup_entry_clipboard(self.q_pass) setup_entry_clipboard(self.q_pass)
tk.Button(qf, text="Подключиться", bg="#89b4fa", fg="#1e1e2e", tk.Button(qf, text="Подключиться", bg="#6c8fff", fg="#ffffff",
activebackground="#6c8fff", relief=tk.FLAT, activebackground="#5a7de6", relief=tk.FLAT, bd=0,
font=("Consolas", 9, "bold"), pady=3, font=("Helvetica", 10, "bold"), pady=6, cursor="hand2",
command=self._quick_connect).pack(fill=tk.X, pady=(4, 0)) highlightthickness=0,
command=self._quick_connect).pack(fill=tk.X, pady=(6, 0))
self.q_host.bind("<Return>", lambda e: self._quick_connect()) self.q_host.bind("<Return>", lambda e: self._quick_connect())
self.q_pass.bind("<Return>", lambda e: self._quick_connect()) self.q_pass.bind("<Return>", lambda e: self._quick_connect())
@@ -1913,29 +1938,26 @@ class App(tk.Tk):
self._create_group_section(self.card_inner, "Без группы", ungrouped, "#6c7086") self._create_group_section(self.card_inner, "Без группы", ungrouped, "#6c7086")
def _create_group_section(self, parent, group_name, sessions, color): def _create_group_section(self, parent, group_name, sessions, color):
# Group container with colored left border
group_frame = tk.Frame(parent, bg="#1e1e2e") group_frame = tk.Frame(parent, bg="#1e1e2e")
group_frame.pack(fill=tk.X, padx=4, pady=(6, 2)) group_frame.pack(fill=tk.X, padx=6, pady=(8, 2))
# Group header header = tk.Frame(group_frame, bg="#1e1e2e")
header = tk.Frame(group_frame, bg="#181825")
header.pack(fill=tk.X) header.pack(fill=tk.X)
# Color indicator tk.Frame(header, bg=color, width=3).pack(side=tk.LEFT, fill=tk.Y, padx=(0, 8))
tk.Frame(header, bg=color, width=4).pack(side=tk.LEFT, fill=tk.Y)
tk.Label(header, text=f" {group_name}", bg="#181825", fg=color, tk.Label(header, text=group_name, bg="#1e1e2e", fg=color,
font=("Consolas", 11, "bold"), pady=6).pack(side=tk.LEFT, fill=tk.X, expand=True, anchor="w") font=("Helvetica", 11, "bold"), pady=5).pack(side=tk.LEFT, fill=tk.X, expand=True, anchor="w")
count_text = f"{len(sessions)}" count_text = f"{len(sessions)}"
tk.Label(header, text=count_text, bg="#181825", fg="#6c7086", tk.Label(header, text=count_text, bg="#1e1e2e", fg="#555566",
font=("Consolas", 9), padx=8).pack(side=tk.RIGHT) font=("Helvetica", 9), padx=8).pack(side=tk.RIGHT)
# Group context menu on header # Group context menu on header
if group_name != "Без группы": if group_name != "Без группы":
def _group_menu(event, gn=group_name): def _group_menu(event, gn=group_name):
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4", menu = tk.Menu(self, tearoff=0, bg="#252536", fg="#e0e0e8",
activebackground="#45475a") activebackground="#353550")
menu.add_command(label="Переименовать", command=lambda: self._rename_group_by_name(gn)) menu.add_command(label="Переименовать", command=lambda: self._rename_group_by_name(gn))
menu.add_command(label="Удалить группу", command=lambda: self._delete_group_by_name(gn)) menu.add_command(label="Удалить группу", command=lambda: self._delete_group_by_name(gn))
menu.tk_popup(event.x_root, event.y_root) menu.tk_popup(event.x_root, event.y_root)
@@ -1965,57 +1987,48 @@ class App(tk.Tk):
def _create_session_card(self, parent, session, index, accent_color): def _create_session_card(self, parent, session, index, accent_color):
is_selected = (index == self._selected_card_idx) is_selected = (index == self._selected_card_idx)
card_bg = "#3b3b5c" if is_selected else "#313244" card_bg = "#2e2e42" if is_selected else "#252536"
card = tk.Frame(parent, bg=card_bg, padx=10, pady=8, cursor="hand2", card = tk.Frame(parent, bg=card_bg, padx=12, pady=10, cursor="hand2",
highlightbackground=accent_color if is_selected else "#45475a", highlightbackground=accent_color if is_selected else "#2e2e42",
highlightthickness=2) highlightthickness=1)
name = session.get("name", "???") name = session.get("name", "???")
host = session.get("host", "") host = session.get("host", "")
port = session.get("port", 22) port = session.get("port", 22)
desc = session.get("description", "") desc = session.get("description", "")
# Name name_label = tk.Label(card, text=name, bg=card_bg, fg="#e0e0e8",
name_label = tk.Label(card, text=name, bg=card_bg, fg="#cdd6f4", font=("Helvetica", 10, "bold"), anchor="w")
font=("Consolas", 10, "bold"), anchor="w")
name_label.pack(fill=tk.X) name_label.pack(fill=tk.X)
# Description (if any)
if desc: if desc:
desc_label = tk.Label(card, text=desc, bg=card_bg, fg="#a6adc8", desc_label = tk.Label(card, text=desc, bg=card_bg, fg="#8899aa",
font=("Consolas", 8), anchor="w") font=("Helvetica", 8), anchor="w")
desc_label.pack(fill=tk.X) desc_label.pack(fill=tk.X)
# Address
addr_text = f"{host}:{port}" addr_text = f"{host}:{port}"
addr_label = tk.Label(card, text=addr_text, bg=card_bg, fg="#6c7086", addr_label = tk.Label(card, text=addr_text, bg=card_bg, fg="#555566",
font=("Consolas", 9), anchor="w") font=("Helvetica", 9), anchor="w")
addr_label.pack(fill=tk.X) addr_label.pack(fill=tk.X)
# Status dot
dot_color = accent_color
dot_canvas = tk.Canvas(card, width=8, height=8, bg=card_bg, highlightthickness=0) dot_canvas = tk.Canvas(card, width=8, height=8, bg=card_bg, highlightthickness=0)
dot_canvas.create_oval(1, 1, 7, 7, fill=dot_color, outline="") dot_canvas.create_oval(1, 1, 7, 7, fill=accent_color, outline="")
dot_canvas.place(relx=1.0, rely=0.0, anchor="ne", x=-4, y=4) dot_canvas.place(relx=1.0, rely=0.0, anchor="ne", x=-4, y=4)
# Bindings # Bindings
def _enter(e): def _enter(e):
if index != self._selected_card_idx: if index != self._selected_card_idx:
card.configure(bg="#3b3b5c") card.configure(bg="#2e2e42")
for w in card.winfo_children(): for w in card.winfo_children():
if isinstance(w, tk.Label): if isinstance(w, (tk.Label, tk.Canvas)):
w.configure(bg="#3b3b5c") w.configure(bg="#2e2e42")
elif isinstance(w, tk.Canvas):
w.configure(bg="#3b3b5c")
def _leave(e): def _leave(e):
bg = "#3b3b5c" if index == self._selected_card_idx else "#313244" bg = "#2e2e42" if index == self._selected_card_idx else "#252536"
card.configure(bg=bg) card.configure(bg=bg)
for w in card.winfo_children(): for w in card.winfo_children():
if isinstance(w, tk.Label): if isinstance(w, (tk.Label, tk.Canvas)):
w.configure(bg=bg)
elif isinstance(w, tk.Canvas):
w.configure(bg=bg) w.configure(bg=bg)
def _click(e): def _click(e):
@@ -2050,13 +2063,13 @@ class App(tk.Tk):
return card return card
def _show_card_context_menu(self, event, index): def _show_card_context_menu(self, event, index):
menu = tk.Menu(self, tearoff=0, bg="#313244", fg="#cdd6f4", menu = tk.Menu(self, tearoff=0, bg="#252536", fg="#e0e0e8",
activebackground="#45475a") activebackground="#353550")
menu.add_command(label="Подключиться", command=lambda: self._connect_by_idx(index)) menu.add_command(label="Подключиться", command=lambda: self._connect_by_idx(index))
menu.add_separator() menu.add_separator()
# Move to group submenu # Move to group submenu
move_menu = tk.Menu(menu, tearoff=0, bg="#313244", fg="#cdd6f4") move_menu = tk.Menu(menu, tearoff=0, bg="#252536", fg="#e0e0e8")
move_menu.add_command(label="Без группы", command=lambda: self._move_and_refresh(index, "")) move_menu.add_command(label="Без группы", command=lambda: self._move_and_refresh(index, ""))
for g in self.store.groups: for g in self.store.groups:
move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g)) move_menu.add_command(label=g, command=lambda g=g: self._move_and_refresh(index, g))
@@ -2075,22 +2088,20 @@ class App(tk.Tk):
# ── Right Panel (Commands + Agent) ──────────────────────── # ── Right Panel (Commands + Agent) ────────────────────────
def _build_right_panel(self, parent): def _build_right_panel(self, parent):
# Toggle button for commands toggle_frame = tk.Frame(parent, bg="#1e1e2e")
toggle_frame = tk.Frame(parent, bg="#181825") toggle_frame.pack(fill=tk.X, padx=6, pady=(6, 2))
toggle_frame.pack(fill=tk.X)
self._toggle_btn = tk.Button( self._toggle_btn = tk.Button(
toggle_frame, text="Команды ▼" if self._commands_visible else "Команды ▶", toggle_frame, text="Команды ▼" if self._commands_visible else "Команды ▶",
bg="#181825", fg="#89b4fa", activebackground="#181825", activeforeground="#6c8fff", bg="#1e1e2e", fg="#8899cc", activebackground="#1e1e2e", activeforeground="#aabbee",
relief=tk.FLAT, font=("Consolas", 10, "bold"), cursor="hand2", relief=tk.FLAT, bd=0, font=("Helvetica", 10, "bold"), cursor="hand2",
command=self._toggle_commands, highlightthickness=0, command=self._toggle_commands,
) )
self._toggle_btn.pack(side=tk.LEFT, padx=6, pady=4) self._toggle_btn.pack(side=tk.LEFT)
# Paned window for commands + agent (draggable splitter)
self._right_paned = tk.PanedWindow( self._right_paned = tk.PanedWindow(
parent, orient=tk.VERTICAL, bg="#45475a", parent, orient=tk.VERTICAL, bg="#2e2e42",
sashwidth=6, sashrelief=tk.FLAT, opaqueresize=True, sashwidth=5, sashrelief=tk.FLAT, opaqueresize=True,
) )
self._right_paned.pack(fill=tk.BOTH, expand=True) self._right_paned.pack(fill=tk.BOTH, expand=True)
@@ -2104,9 +2115,10 @@ class App(tk.Tk):
search_frame.pack(fill=tk.X, padx=4, pady=4) search_frame.pack(fill=tk.X, padx=4, pady=4)
tk.Label(search_frame, text="Поиск:", bg="#1e1e2e", fg="#6c7086", tk.Label(search_frame, text="Поиск:", bg="#1e1e2e", fg="#6c7086",
font=("Consolas", 9)).pack(side=tk.LEFT) font=("Consolas", 9)).pack(side=tk.LEFT)
self.cmd_search = tk.Entry(search_frame, bg="#313244", fg="#cdd6f4", self.cmd_search = tk.Entry(search_frame, bg="#252536", fg="#e0e0e8",
insertbackground="#cdd6f4", relief=tk.FLAT, insertbackground="#e0e0e8", relief=tk.FLAT,
font=("Consolas", 9)) font=("Helvetica", 9), highlightthickness=1,
highlightcolor="#6c8fff", highlightbackground="#2e2e42")
self.cmd_search.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0)) self.cmd_search.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(4, 0))
setup_entry_clipboard(self.cmd_search) setup_entry_clipboard(self.cmd_search)
self.cmd_search.bind("<KeyRelease>", self._filter_commands) self.cmd_search.bind("<KeyRelease>", self._filter_commands)
@@ -2137,11 +2149,11 @@ class App(tk.Tk):
agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e") agent_wrapper = tk.Frame(self._right_paned, bg="#1e1e2e")
self._right_paned.add(agent_wrapper, minsize=120) self._right_paned.add(agent_wrapper, minsize=120)
agent_header = tk.Frame(agent_wrapper, bg="#181825") agent_header = tk.Frame(agent_wrapper, bg="#1e1e2e")
agent_header.pack(fill=tk.X) agent_header.pack(fill=tk.X, padx=6, pady=(6, 2))
tk.Label(agent_header, text="Поиск команд", bg="#181825", fg="#a6e3a1", tk.Label(agent_header, text="Поиск команд", bg="#1e1e2e", fg="#7ecfa0",
font=("Consolas", 11, "bold")).pack(side=tk.LEFT, padx=6, pady=4) font=("Helvetica", 11, "bold")).pack(side=tk.LEFT)
# Agent content # Agent content
self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e") self.agent_frame = tk.Frame(agent_wrapper, bg="#1e1e2e")
@@ -2151,24 +2163,26 @@ class App(tk.Tk):
agent_input = tk.Frame(self.agent_frame, bg="#1e1e2e") agent_input = tk.Frame(self.agent_frame, bg="#1e1e2e")
agent_input.pack(fill=tk.X, pady=(0, 4)) agent_input.pack(fill=tk.X, pady=(0, 4))
self.agent_entry = tk.Entry(agent_input, bg="#313244", fg="#cdd6f4", self.agent_entry = tk.Entry(agent_input, bg="#252536", fg="#e0e0e8",
insertbackground="#cdd6f4", relief=tk.FLAT, insertbackground="#e0e0e8", relief=tk.FLAT,
font=("Consolas", 10)) font=("Helvetica", 10), highlightthickness=1,
highlightcolor="#6c8fff", highlightbackground="#2e2e42")
self.agent_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4)) self.agent_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 4))
setup_entry_clipboard(self.agent_entry) setup_entry_clipboard(self.agent_entry)
self.agent_entry.insert(0, "как обновить ubuntu?") self.agent_entry.insert(0, "как обновить ubuntu?")
self.agent_entry.bind("<Return>", lambda e: self._agent_ask()) self.agent_entry.bind("<Return>", lambda e: self._agent_ask())
tk.Button(agent_input, text="Спросить", bg="#a6e3a1", fg="#1e1e2e", tk.Button(agent_input, text="Спросить", bg="#7ecfa0", fg="#1e1e2e",
activebackground="#89b4fa", relief=tk.FLAT, activebackground="#5ab882", relief=tk.FLAT, bd=0,
font=("Consolas", 9, "bold"), padx=8, pady=2, font=("Helvetica", 9, "bold"), padx=10, pady=3,
cursor="hand2", highlightthickness=0,
command=self._agent_ask).pack(side=tk.RIGHT) command=self._agent_ask).pack(side=tk.RIGHT)
# Result area # Result area
self.agent_result = tk.Text( self.agent_result = tk.Text(
self.agent_frame, bg="#181825", fg="#cdd6f4", self.agent_frame, bg="#1e1e2e", fg="#e0e0e8",
font=("Consolas", 9), wrap=tk.WORD, borderwidth=0, font=("Consolas", 9), wrap=tk.WORD, borderwidth=0,
insertbackground="#cdd6f4", highlightthickness=0, insertbackground="#e0e0e8", highlightthickness=0,
) )
agent_scroll = ttk.Scrollbar(self.agent_frame, command=self.agent_result.yview) agent_scroll = ttk.Scrollbar(self.agent_frame, command=self.agent_result.yview)
self.agent_result.configure(yscrollcommand=agent_scroll.set) self.agent_result.configure(yscrollcommand=agent_scroll.set)
@@ -2177,7 +2191,7 @@ class App(tk.Tk):
self.agent_result.tag_configure("cmd", foreground="#a6e3a1", font=("Consolas", 9, "bold")) self.agent_result.tag_configure("cmd", foreground="#a6e3a1", font=("Consolas", 9, "bold"))
self.agent_result.tag_configure("desc", foreground="#6c7086") self.agent_result.tag_configure("desc", foreground="#6c7086")
self.agent_result.tag_configure("platform", foreground="#89b4fa") self.agent_result.tag_configure("platform", foreground="#6c8fff")
self.agent_result.tag_configure("hint", foreground="#f9e2af") self.agent_result.tag_configure("hint", foreground="#f9e2af")
self.agent_result.tag_configure("error", foreground="#f38ba8") self.agent_result.tag_configure("error", foreground="#f38ba8")
self.agent_result.tag_configure("ai", foreground="#cba6f7") self.agent_result.tag_configure("ai", foreground="#cba6f7")
@@ -2425,21 +2439,23 @@ class App(tk.Tk):
close_btn_frame = tk.Frame(term, bg="#1e1e2e") close_btn_frame = tk.Frame(term, bg="#1e1e2e")
btn_s = {"bg": "#313244", "fg": "#89b4fa", "activebackground": "#45475a", btn_s = {"bg": "#2a2a3d", "fg": "#8899cc", "activebackground": "#353550",
"relief": tk.FLAT, "font": ("Consolas", 9), "padx": 6, "pady": 2} "relief": tk.FLAT, "bd": 0, "font": ("Helvetica", 9), "padx": 8, "pady": 3,
"cursor": "hand2", "highlightthickness": 0}
tk.Button(close_btn_frame, text="SFTP", tk.Button(close_btn_frame, text="SFTP",
command=lambda: self._open_file_manager(term), command=lambda: self._open_file_manager(term),
**btn_s).pack(side=tk.LEFT, padx=4, pady=2) **btn_s).pack(side=tk.LEFT, padx=4, pady=3)
tk.Button(close_btn_frame, text="Reconnect", tk.Button(close_btn_frame, text="Reconnect",
command=lambda: self._reconnect_terminal(term), command=lambda: self._reconnect_terminal(term),
**btn_s).pack(side=tk.LEFT, padx=4, pady=2) **btn_s).pack(side=tk.LEFT, padx=4, pady=3)
tk.Button( tk.Button(
close_btn_frame, text="Close", close_btn_frame, text="Close",
bg="#313244", fg="#cdd6f4", activebackground="#45475a", bg="#2a2a3d", fg="#8899aa", activebackground="#353550",
relief=tk.FLAT, font=("Consolas", 9), padx=6, pady=2, relief=tk.FLAT, bd=0, font=("Helvetica", 9), padx=8, pady=3,
cursor="hand2", highlightthickness=0,
command=lambda: self._close_tab(term), command=lambda: self._close_tab(term),
).pack(side=tk.RIGHT, padx=4, pady=2) ).pack(side=tk.RIGHT, padx=4, pady=3)
close_btn_frame.pack(fill=tk.X, side=tk.BOTTOM) close_btn_frame.pack(fill=tk.X, side=tk.BOTTOM)
def _open_file_manager(self, term): def _open_file_manager(self, term):