Files
ClaudeProjectManager-main/gui/project_tile.py
Claude Project Manager d1667f9e0d Fix Aktivitätenstatus
2025-07-08 10:33:20 +02:00

932 Zeilen
37 KiB
Python

"""
Project Tile Component
Individual tile for each project in the grid
"""
import customtkinter as ctk
import tkinter as tk
from datetime import datetime
import os
import subprocess
import platform
import time
from typing import Callable, Optional
from pathlib import Path
from gui.styles import COLORS, FONTS, TILE_SIZE, BUTTON_STYLES
from utils.logger import logger
class ProjectTile(ctk.CTkFrame):
def __init__(self, parent, project, on_open: Callable, on_readme: Callable,
on_delete: Optional[Callable] = None, is_vps: bool = False,
on_stop: Optional[Callable] = None, is_running: bool = False,
on_rename: Optional[Callable] = None, on_select: Optional[Callable] = None):
super().__init__(
parent,
width=TILE_SIZE['width'],
height=TILE_SIZE['height'],
fg_color=COLORS['bg_vps'] if is_vps else COLORS['bg_tile'],
corner_radius=10,
border_width=3,
border_color=COLORS['bg_vps'] if is_vps else COLORS['bg_tile']
)
self.project = project
self.on_open = on_open
self.on_readme = on_readme
self.on_delete = on_delete
self.on_stop = on_stop
self.on_rename = on_rename
self.on_select = on_select
self.is_vps = is_vps
self.is_running = is_running
self.is_selected = False
self.activity_animation_id = None
self.activity_pulse_value = 0
self.has_team_activity = False
self.grid_propagate(False)
self.setup_ui()
# Hover effect
self.bind("<Enter>", self.on_hover_enter)
self.bind("<Leave>", self.on_hover_leave)
# Click to select - only bind to the main frame
self.bind("<Button-1>", self._on_click)
def setup_ui(self):
"""Create tile UI elements"""
# Main container with padding
container = ctk.CTkFrame(self, fg_color="transparent")
container.pack(fill="both", expand=True, padx=TILE_SIZE['padding'], pady=TILE_SIZE['padding'])
# Bind click to container for selection (but not buttons)
container.bind("<Button-1>", self._on_click)
# Title row with edit button
title_row = ctk.CTkFrame(container, fg_color="transparent")
title_row.pack(fill="x", pady=(0, 5))
# Activity indicator (initially hidden)
self.activity_indicator = ctk.CTkLabel(
title_row,
text="🟢",
font=('Segoe UI', 10),
text_color=COLORS['accent_success'],
width=20
)
# Don't pack initially
# Title
title_text = self.project.name
if self.is_vps:
title_text = "🌐 " + title_text
self.title_label = ctk.CTkLabel(
title_row,
text=title_text,
font=FONTS['tile_title'],
text_color=COLORS['text_primary'],
anchor="w"
)
self.title_label.pack(side="left", fill="x", expand=True)
# Edit button (not for VPS)
if not self.is_vps and self.on_rename:
self.edit_button = ctk.CTkButton(
title_row,
text="📝",
command=lambda: self.on_rename(self.project),
width=25,
height=25,
fg_color="transparent",
hover_color=COLORS['bg_secondary'],
text_color=COLORS['text_secondary'],
font=('Segoe UI', 12)
)
self.edit_button.pack(side="right", padx=(5, 0))
# Path/Description
path_text = self.project.path
if len(path_text) > 40:
path_text = "..." + path_text[-37:]
self.path_label = ctk.CTkLabel(
container,
text=path_text,
font=FONTS['small'],
text_color=COLORS['text_secondary'],
anchor="w"
)
self.path_label.pack(fill="x")
# Tags if available
if self.project.tags:
tags_text = "".join(self.project.tags[:3])
self.tags_label = ctk.CTkLabel(
container,
text=tags_text,
font=FONTS['small'],
text_color=COLORS['text_dim'],
anchor="w"
)
self.tags_label.pack(fill="x", pady=(2, 0))
# Gitea repo link if available
if hasattr(self.project, 'gitea_repo') and self.project.gitea_repo:
self.gitea_label = ctk.CTkLabel(
container,
text=f"🔗 {self.project.gitea_repo}",
font=FONTS['small'],
text_color=COLORS['accent_success'],
anchor="w"
)
self.gitea_label.pack(fill="x", pady=(2, 0))
# Spacer
ctk.CTkFrame(container, height=1, fg_color="transparent").pack(expand=True)
# Last accessed
try:
last_accessed = datetime.fromisoformat(self.project.last_accessed)
time_diff = datetime.now() - last_accessed
if time_diff.days > 0:
time_text = f"{time_diff.days} days ago"
elif time_diff.seconds > 3600:
hours = time_diff.seconds // 3600
time_text = f"{hours} hours ago"
else:
minutes = time_diff.seconds // 60
time_text = f"{minutes} minutes ago" if minutes > 0 else "Just now"
except:
time_text = "Never"
self.time_label = ctk.CTkLabel(
container,
text=f"Last opened: {time_text}",
font=FONTS['small'],
text_color=COLORS['text_dim']
)
self.time_label.pack(fill="x", pady=(0, 10))
# Status message (initially hidden)
self.status_message = ctk.CTkLabel(
container,
text="",
font=FONTS['small'],
text_color=COLORS['accent_warning'],
anchor="w"
)
# Don't pack it initially - will be shown when needed
# Buttons - First row
button_frame = ctk.CTkFrame(container, fg_color="transparent")
button_frame.pack(fill="x")
# Open/Start/Stop button
if self.is_vps:
# VPS also uses red when running
btn_style = BUTTON_STYLES['danger'] if self.is_running else BUTTON_STYLES['vps']
# Special text for VPS Docker
if self.project.id == "vps-docker-permanent":
button_text = "Restart"
else:
button_text = "Connect"
else:
# Use danger (red) style when running, primary (cyan) when not
btn_style = BUTTON_STYLES['danger'] if self.is_running else BUTTON_STYLES['primary']
button_text = "Start Claude"
self.open_button = ctk.CTkButton(
button_frame,
text=button_text,
command=self._handle_open_click,
width=60 if (self.is_vps and self.project.id == "vps-permanent") else 95, # Smaller width only for main VPS Server
**btn_style
)
self.open_button.pack(side="left", padx=(0, 5))
# CMD button only for main VPS Server tile (not Admin Panel or Docker)
if self.is_vps and self.project.id == "vps-permanent":
self.cmd_button = ctk.CTkButton(
button_frame,
text="CMD",
command=self._open_cmd_ssh,
width=50,
**BUTTON_STYLES['secondary']
)
self.cmd_button.pack(side="left", padx=(0, 5))
# Gitea button (not for VPS)
if not self.is_vps:
self.gitea_button = ctk.CTkButton(
button_frame,
text="Gitea ▼",
command=self._show_gitea_menu,
width=60,
**BUTTON_STYLES['secondary']
)
self.gitea_button.pack(side="left", padx=(0, 2))
# Activity button - show even if not connected for better UX
from services.activity_sync import activity_service
self.activity_button = ctk.CTkButton(
button_frame,
text="",
command=self._toggle_activity,
width=30,
**BUTTON_STYLES['secondary']
)
self.activity_button.pack(side="left")
# Update button appearance based on connection status
if not activity_service.is_configured() or not activity_service.connected:
self.activity_button.configure(state="normal", text_color=COLORS['text_dim'])
# Delete button (not for VPS) - keep it in first row
if not self.is_vps and self.on_delete:
self.delete_button = ctk.CTkButton(
button_frame,
text="",
command=lambda: self.on_delete(self.project),
width=30,
fg_color=COLORS['bg_secondary'],
hover_color=COLORS['accent_error'],
text_color=COLORS['text_secondary']
)
self.delete_button.pack(side="right")
# Second row - Open Explorer button (only for non-VPS tiles)
if not self.is_vps:
explorer_frame = ctk.CTkFrame(container, fg_color="transparent")
explorer_frame.pack(fill="x", pady=(5, 0))
self.explorer_button = ctk.CTkButton(
explorer_frame,
text="Open Explorer",
command=self._open_explorer,
width=195, # Full width to match the buttons above
**BUTTON_STYLES['secondary']
)
self.explorer_button.pack()
# Don't apply click bindings - they interfere with button functionality
def on_hover_enter(self, event):
"""Handle mouse enter"""
if not self.is_vps:
self.configure(fg_color=COLORS['bg_tile_hover'])
def on_hover_leave(self, event):
"""Handle mouse leave"""
if not self.is_vps and not self.is_selected:
self.configure(fg_color=COLORS['bg_tile'])
def _on_click(self, event):
"""Handle click event"""
# Don't process if click was on a button or functional element
clicked_widget = event.widget
# Check if clicked on any button
if isinstance(clicked_widget, ctk.CTkButton):
return
# Walk up the widget hierarchy to check parent widgets
parent = clicked_widget
while parent:
if isinstance(parent, ctk.CTkButton):
return
# Stop at the tile itself
if parent == self:
break
parent = parent.master if hasattr(parent, 'master') else None
if self.on_select:
self.on_select(self.project)
def set_selected(self, selected: bool):
"""Set the selected state of the tile"""
self.is_selected = selected
if selected:
# Show border for selected tile
self.configure(border_width=2, border_color=COLORS['accent_primary'])
else:
# Remove border for unselected tile
self.configure(border_width=0)
def _handle_open_click(self):
"""Handle open/stop button click"""
if self.is_running and self.on_stop:
self.on_stop(self.project)
else:
# Disable button immediately to prevent multiple clicks
self.open_button.configure(state="disabled")
self.on_open(self.project)
def update_status(self, is_running: bool):
"""Update the running status of the tile"""
self.is_running = is_running
# Update button color and state
if self.is_vps:
btn_style = BUTTON_STYLES['danger'] if is_running else BUTTON_STYLES['vps']
else:
btn_style = BUTTON_STYLES['danger'] if is_running else BUTTON_STYLES['primary']
self.open_button.configure(
fg_color=btn_style['fg_color'],
hover_color=btn_style['hover_color'],
state="normal" # Re-enable button when status updates
)
# Update status message
if is_running:
self.status_message.configure(text="Claude instance is running. Close it to start a new one.")
self.status_message.pack(fill="x", pady=(0, 5), before=self.open_button.master)
else:
self.status_message.pack_forget()
def update_project(self, project):
"""Update tile with new project data"""
self.project = project
# Update last accessed time
self.time_label.configure(text=f"Last opened: Just now")
# Update title
title_text = self.project.name
if self.is_vps:
title_text = "🌐 " + title_text
self.title_label.configure(text=title_text)
# Update Gitea label if exists
if hasattr(self.project, 'gitea_repo') and self.project.gitea_repo:
if hasattr(self, 'gitea_label'):
self.gitea_label.configure(text=f"🔗 {self.project.gitea_repo}")
else:
# Create new Gitea label if it doesn't exist
self.gitea_label = ctk.CTkLabel(
self.winfo_children()[0], # Get container
text=f"🔗 {self.project.gitea_repo}",
font=FONTS['small'],
text_color=COLORS['accent_success'],
anchor="w"
)
# Pack it after tags label or path label
if hasattr(self, 'tags_label'):
self.gitea_label.pack(fill="x", pady=(2, 0), after=self.tags_label)
else:
self.gitea_label.pack(fill="x", pady=(2, 0), after=self.path_label)
def _bind_click_recursive(self, widget):
"""Recursively bind left-click to all children except buttons"""
# Don't bind to buttons - they have their own click handlers
if not isinstance(widget, ctk.CTkButton):
widget.bind("<Button-1>", self._on_click)
# Recursively bind to children only if not a button
for child in widget.winfo_children():
self._bind_click_recursive(child)
def _open_explorer(self):
"""Open the project folder in the file explorer"""
path = self.project.path
if os.path.exists(path):
if platform.system() == 'Windows':
os.startfile(path)
elif platform.system() == 'Darwin': # macOS
subprocess.Popen(['open', path])
else: # Linux and other Unix-like
subprocess.Popen(['xdg-open', path])
def _open_cmd_ssh(self):
"""Open CMD with SSH connection to VPS server with automatic authentication"""
import tempfile
import os
# SSH credentials
ssh_host = "91.99.192.14"
ssh_user = "claude-dev"
ssh_pass = "z0E1Al}q2H?Yqd!O"
if platform.system() == 'Windows':
# Create a temporary batch file for Windows
with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f:
batch_content = f'''@echo off
echo Connecting to VPS Server...
echo.
echo Please wait while establishing connection...
echo.
REM Try using plink if available (PuTTY command line)
where plink >nul 2>&1
if %ERRORLEVEL% EQU 0 (
echo Using PuTTY plink for connection...
plink -ssh -l {ssh_user} -pw "{ssh_pass}" {ssh_host}
) else (
echo PuTTY plink not found. Trying ssh with manual password entry...
echo.
echo Password: {ssh_pass}
echo.
echo Please copy the password above and paste it when prompted.
echo.
pause
ssh {ssh_user}@{ssh_host}
)
pause
'''
f.write(batch_content)
temp_batch = f.name
# Open CMD with the batch file
cmd = f'start "VPS SSH Connection" cmd /k "{temp_batch}"'
subprocess.Popen(cmd, shell=True)
# Clean up temp file after a delay
def cleanup():
time.sleep(2)
try:
os.unlink(temp_batch)
except:
pass
import threading
threading.Thread(target=cleanup, daemon=True).start()
else:
# For Unix-like systems, use sshpass if available
ssh_command = f'sshpass -p "{ssh_pass}" ssh {ssh_user}@{ssh_host}'
fallback_command = f'echo "Password: {ssh_pass}"; echo "Copy and paste when prompted:"; ssh {ssh_user}@{ssh_host}'
if platform.system() == 'Darwin': # macOS
# Check if sshpass is available
check_cmd = 'which sshpass'
result = subprocess.run(check_cmd, shell=True, capture_output=True)
if result.returncode == 0:
cmd = f'''osascript -e 'tell app "Terminal" to do script "{ssh_command}"' '''
else:
cmd = f'''osascript -e 'tell app "Terminal" to do script "{fallback_command}"' '''
subprocess.Popen(cmd, shell=True)
else: # Linux
# Try common terminal emulators
terminals = ['gnome-terminal', 'konsole', 'xterm', 'terminal']
for term in terminals:
try:
# Check if sshpass is available
check_cmd = 'which sshpass'
result = subprocess.run(check_cmd, shell=True, capture_output=True)
if result.returncode == 0:
if term == 'gnome-terminal':
subprocess.Popen([term, '--', 'bash', '-c', ssh_command])
else:
subprocess.Popen([term, '-e', ssh_command])
else:
if term == 'gnome-terminal':
subprocess.Popen([term, '--', 'bash', '-c', fallback_command])
else:
subprocess.Popen([term, '-e', fallback_command])
break
except FileNotFoundError:
continue
def _show_gitea_menu(self):
"""Show Gitea operations menu"""
# Create menu
menu = tk.Menu(self, tearoff=0)
menu.configure(
bg=COLORS['bg_secondary'],
fg=COLORS['text_primary'],
activebackground=COLORS['bg_tile_hover'],
activeforeground=COLORS['text_primary'],
borderwidth=0,
relief="flat"
)
# Check if project is a git repository
project_path = Path(self.project.path)
is_git_repo = (project_path / ".git").exists()
# Check if has remote
has_remote = False
if is_git_repo:
try:
# Hide console window on Windows
startupinfo = None
if hasattr(subprocess, 'STARTUPINFO'):
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
result = subprocess.run(
["git", "remote", "-v"],
cwd=project_path,
capture_output=True,
text=True,
startupinfo=startupinfo
)
has_remote = bool(result.stdout.strip())
except:
pass
if is_git_repo:
# Git repository options
menu.add_command(label="📊 Status anzeigen", command=lambda: self._gitea_operation("status"))
menu.add_separator()
menu.add_command(label="🔧 Repository reparieren", command=lambda: self._gitea_operation("fix_repo"))
menu.add_separator()
menu.add_command(label="💾 Commit", command=lambda: self._gitea_operation("commit"))
if has_remote:
menu.add_command(label="⬆️ Push", command=lambda: self._gitea_operation("push"))
menu.add_command(label="⬇️ Pull", command=lambda: self._gitea_operation("pull"))
menu.add_command(label="🔄 Fetch", command=lambda: self._gitea_operation("fetch"))
else:
menu.add_command(label="🔗 Mit Gitea verknüpfen", command=lambda: self._gitea_operation("link"))
menu.add_separator()
menu.add_command(label="🌿 Branch verwalten", command=lambda: self._gitea_operation("branch"))
menu.add_separator()
menu.add_command(label="🗂️ Große Dateien verwalten", command=lambda: self._gitea_operation("manage_large"))
menu.add_command(label="📦 Git LFS einrichten", command=lambda: self._gitea_operation("lfs"))
menu.add_separator()
menu.add_command(label="🔍 Verbindung testen", command=lambda: self._gitea_operation("test"))
menu.add_command(label="🔎 Repository verifizieren", command=lambda: self._gitea_operation("verify"))
else:
# Not a git repository
menu.add_command(label="🚀 Zu Gitea pushen", command=lambda: self._gitea_operation("init_push"))
menu.add_command(label="📥 Git initialisieren", command=lambda: self._gitea_operation("init"))
menu.add_separator()
menu.add_command(label="🔍 Verbindung testen", command=lambda: self._gitea_operation("test"))
# Show menu at button position
try:
# Get button position
x = self.gitea_button.winfo_rootx()
y = self.gitea_button.winfo_rooty() + self.gitea_button.winfo_height()
menu.tk_popup(x, y)
finally:
menu.grab_release()
def _gitea_operation(self, operation: str):
"""Trigger Gitea operation callback"""
# Get the main window reference
main_window = self.winfo_toplevel()
if hasattr(main_window, 'master') and hasattr(main_window.master, 'gitea_operation'):
main_window.master.gitea_operation(self.project, operation)
else:
# Try to find the main window
root = self.winfo_toplevel()
if hasattr(root, 'gitea_operation'):
root.gitea_operation(self.project, operation)
def _start_activity(self):
"""Start activity for this project"""
from services.activity_sync import activity_service
logger.info(f"Starting activity for project: {self.project.name}")
# Update UI optimistically immediately
self.update_activity_status(True, activity_service.user_name, True)
success = activity_service.start_activity(
self.project.name,
self.project.path,
self.project.description if hasattr(self.project, 'description') else ""
)
logger.info(f"Activity start result for {self.project.name}: success={success}")
if not success:
# Revert on failure
logger.error(f"Failed to start activity for {self.project.name}, reverting UI")
self.update_activity_status(False)
from tkinter import messagebox
messagebox.showerror(
"Fehler",
"Aktivität konnte nicht gestartet werden."
)
def _stop_activity(self):
"""Stop current activity"""
from services.activity_sync import activity_service
logger.info(f"Stopping activity for project: {self.project.name}")
# Update UI optimistically immediately
self.update_activity_status(False)
success = activity_service.stop_activity()
logger.info(f"Activity stop result: success={success}")
if not success:
# Revert on failure - check if we're still the active project
current = activity_service.get_current_activity()
if current and current.get('projectName') == self.project.name:
logger.error(f"Failed to stop activity for {self.project.name}, reverting UI")
self.update_activity_status(True, activity_service.user_name, True)
else:
logger.error(f"Failed to stop activity, but no current activity found")
def _toggle_activity(self):
"""Toggle activity for this project"""
logger.info(f"Activity button clicked for project: {self.project.name}")
from services.activity_sync import activity_service
if not activity_service.is_configured():
logger.warning("Activity service not configured")
from tkinter import messagebox
messagebox.showinfo(
"Activity Server",
"Bitte konfigurieren Sie den Activity Server in den Einstellungen."
)
return
if not activity_service.connected:
logger.warning("Activity service not connected")
from tkinter import messagebox
messagebox.showwarning(
"Nicht verbunden",
"Keine Verbindung zum Activity Server.\n\nDer Server ist möglicherweise nicht erreichbar."
)
return
current_activity = activity_service.get_current_activity()
is_this_project_active = (current_activity and
current_activity.get('projectName') == self.project.name)
logger.info(f"Current activity status: {is_this_project_active}")
if is_this_project_active:
self._stop_activity()
else:
self._start_activity()
def update_activity_status(self, is_active: bool = False, user_name: str = None, is_own_activity: bool = False):
"""Update activity indicator on tile"""
logger.debug(f"update_activity_status called for {self.project.name}: is_active={is_active}, user_name={user_name}, is_own_activity={is_own_activity}")
if is_active:
# Start border animation for team activity
if not is_own_activity:
self.has_team_activity = True
self._start_activity_animation()
else:
self.has_team_activity = False
self._stop_activity_animation()
# Show indicator with appropriate color
if is_own_activity:
# Green for own activity
self.activity_indicator.configure(text="🟢", text_color=COLORS['accent_success'])
else:
# Orange for others' activity
self.activity_indicator.configure(text="🟠", text_color=COLORS['accent_warning'])
self.activity_indicator.pack(side="left", padx=(0, 5))
if user_name:
# Create tooltip with user name
self.activity_indicator.configure(cursor="hand2")
self.activity_indicator.bind("<Enter>", lambda e: self._show_activity_tooltip(user_name))
self.activity_indicator.bind("<Leave>", lambda e: self._hide_activity_tooltip())
# Update activity button if exists
if hasattr(self, 'activity_button'):
if is_own_activity:
self.activity_button.configure(text="")
else:
# Keep play button if someone else is active
self.activity_button.configure(text="")
else:
# Hide indicator
self.activity_indicator.pack_forget()
# Stop border animation
self.has_team_activity = False
self._stop_activity_animation()
# Update activity button if exists
if hasattr(self, 'activity_button'):
self.activity_button.configure(text="")
def _show_activity_tooltip(self, user_name: str):
"""Show tooltip with active user name"""
# Create tooltip
self.tooltip = ctk.CTkToplevel(self)
self.tooltip.wm_overrideredirect(True)
self.tooltip.configure(fg_color=COLORS['bg_secondary'])
# Format text for multiple users
if ", " in user_name:
users = user_name.split(", ")
if len(users) == 1:
text = f"{users[0]} arbeitet hieran"
else:
text = f"{len(users)} Personen arbeiten hieran:\n" + "\n".join(f"{u}" for u in users)
else:
text = f"{user_name} arbeitet hieran"
label = ctk.CTkLabel(
self.tooltip,
text=text,
font=FONTS['small'],
text_color=COLORS['text_primary'],
fg_color=COLORS['bg_secondary'],
justify="left"
)
label.pack(padx=8, pady=5)
# Position tooltip
x = self.activity_indicator.winfo_rootx()
y = self.activity_indicator.winfo_rooty() + 20
self.tooltip.geometry(f"+{x}+{y}")
def _hide_activity_tooltip(self):
"""Hide activity tooltip"""
if hasattr(self, 'tooltip'):
self.tooltip.destroy()
def check_activity(self):
"""Check if this project has active users"""
from services.activity_sync import activity_service
logger.debug(f"check_activity called for project: {self.project.name}")
# First check if this is our own current activity
current_activity = activity_service.get_current_activity()
is_own_current = (current_activity and
current_activity.get('projectName') == self.project.name)
logger.debug(f"Current activity check - is_own_current: {is_own_current}, current_activity: {current_activity}")
# Get all activities for this project from server
active_users = []
is_own_activity = False
has_other_users = False
for activity in activity_service.activities:
if activity.get('projectName') == self.project.name and activity.get('isActive'):
user_name = activity.get('userName', 'Unknown')
active_users.append(user_name)
# Check if it's the current user's activity
if activity.get('userId') == activity_service.user_id:
is_own_activity = True
else:
has_other_users = True
logger.debug(f"Server activities - active_users: {active_users}, is_own_activity: {is_own_activity}, has_other_users: {has_other_users}")
# If we have a local current activity, ensure it's included
if is_own_current:
is_own_activity = True
if activity_service.user_name not in active_users:
active_users.append(activity_service.user_name)
logger.debug(f"Added local user to active_users: {activity_service.user_name}")
if active_users:
# Show indicator with all active users
user_text = ", ".join(active_users)
# If both own and others are active, show as others (orange) to indicate collaboration
final_is_own = is_own_activity and not has_other_users
logger.info(f"Updating activity status for {self.project.name}: active=True, users={user_text}, is_own={final_is_own}")
self.update_activity_status(True, user_text, final_is_own)
else:
logger.info(f"Updating activity status for {self.project.name}: active=False")
self.update_activity_status(False)
def _start_activity_animation(self):
"""Start animated border for team activity"""
if self.activity_animation_id:
return # Animation already running
def animate():
if not self.has_team_activity:
self.activity_animation_id = None
return
# Calculate pulsing color
self.activity_pulse_value = (self.activity_pulse_value + 5) % 100
pulse = abs(50 - self.activity_pulse_value) / 50 # 0 to 1 pulsing
# Interpolate between orange and a brighter orange
base_color = COLORS['accent_warning'] # Orange
bright_factor = 0.3 + (0.7 * pulse) # Pulse between 0.3 and 1.0 brightness
# Create pulsing border color
if base_color.startswith('#'):
# Convert hex to RGB, apply brightness, convert back
r = int(base_color[1:3], 16)
g = int(base_color[3:5], 16)
b = int(base_color[5:7], 16)
# Apply brightness
r = min(255, int(r + (255 - r) * (1 - bright_factor)))
g = min(255, int(g + (255 - g) * (1 - bright_factor)))
b = min(255, int(b + (255 - b) * (1 - bright_factor)))
pulse_color = f"#{r:02x}{g:02x}{b:02x}"
else:
pulse_color = base_color
self.configure(border_color=pulse_color)
# Continue animation
self.activity_animation_id = self.after(50, animate)
animate()
def _stop_activity_animation(self):
"""Stop animated border"""
if self.activity_animation_id:
self.after_cancel(self.activity_animation_id)
self.activity_animation_id = None
# Reset border to default
default_color = COLORS['bg_vps'] if self.is_vps else COLORS['bg_tile']
self.configure(border_color=default_color)
class AddProjectTile(ctk.CTkFrame):
"""Special tile for adding new projects"""
def __init__(self, parent, on_add: Callable):
super().__init__(
parent,
width=TILE_SIZE['width'],
height=TILE_SIZE['height'],
fg_color=COLORS['bg_secondary'],
corner_radius=10,
border_width=2,
border_color=COLORS['border_primary']
)
self.on_add = on_add
self.grid_propagate(False)
self.setup_ui()
# Hover effect
self.bind("<Enter>", self.on_hover_enter)
self.bind("<Leave>", self.on_hover_leave)
# Make entire tile clickable with cursor change
self.configure(cursor="hand2")
self.bind("<Button-1>", lambda e: self.on_add())
for child in self.winfo_children():
child.configure(cursor="hand2")
child.bind("<Button-1>", lambda e: self.on_add())
# Also bind to all nested children
self._bind_children_recursive(child)
def setup_ui(self):
"""Create add tile UI"""
container = ctk.CTkFrame(self, fg_color="transparent")
container.pack(fill="both", expand=True)
# Folder icon instead of plus
folder_icon = ctk.CTkLabel(
container,
text="📁",
font=('Segoe UI', 48),
text_color=COLORS['text_dim']
)
folder_icon.pack(expand=True)
# Text
text_label = ctk.CTkLabel(
container,
text="Add New Project",
font=FONTS['tile_title'],
text_color=COLORS['text_secondary']
)
text_label.pack(pady=(0, 10))
# Instruction text
instruction_label = ctk.CTkLabel(
container,
text="Click to select folder",
font=FONTS['small'],
text_color=COLORS['text_dim']
)
instruction_label.pack(pady=(0, 20))
def on_hover_enter(self, event):
"""Handle mouse enter"""
self.configure(border_color=COLORS['accent_primary'])
def on_hover_leave(self, event):
"""Handle mouse leave"""
self.configure(border_color=COLORS['border_primary'])
def _bind_children_recursive(self, widget):
"""Recursively bind click event to all children"""
for child in widget.winfo_children():
child.configure(cursor="hand2")
child.bind("<Button-1>", lambda e: self.on_add())
self._bind_children_recursive(child)