added clipcascade
This commit is contained in:
Executable
+397
@@ -0,0 +1,397 @@
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
import webbrowser
|
||||
from pystray import Icon, MenuItem as item, Menu
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from core.config import Config
|
||||
from gui.info import CustomDialog
|
||||
from core.constants import *
|
||||
|
||||
if PLATFORM != WINDOWS:
|
||||
import subprocess
|
||||
|
||||
|
||||
class TaskbarPanel:
|
||||
def __init__(
|
||||
self,
|
||||
on_connect_callback: callable = None,
|
||||
on_disconnect_callback: callable = None,
|
||||
on_logoff_callback: callable = None,
|
||||
new_version_available: list = None,
|
||||
github_url: str = GITHUB_URL,
|
||||
donation_url: str = None,
|
||||
ws_interface=None, # type= interfaces.ws_interface.WSInterface
|
||||
config: Config = None,
|
||||
):
|
||||
self.on_connect_callback = on_connect_callback
|
||||
self.on_disconnect_callback = on_disconnect_callback
|
||||
self.on_logoff_callback = on_logoff_callback
|
||||
self.new_version_available = new_version_available
|
||||
self.github_url = github_url
|
||||
self.donation_url = donation_url
|
||||
self.ws_interface = ws_interface
|
||||
self.config = config
|
||||
|
||||
self.is_disconnecting = False
|
||||
self.disconnecting_items = None
|
||||
self.is_file_download_enabled = False
|
||||
self.file_download_items = None
|
||||
self.previous_stats: str = ""
|
||||
self.previous_stats_items = None
|
||||
|
||||
self.root = tk.Tk()
|
||||
self.root.withdraw() # Hide the root window
|
||||
|
||||
# Hide dock icon on macOS after creating tkinter window
|
||||
if PLATFORM == MACOS:
|
||||
try:
|
||||
from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
|
||||
NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initial state: Connected
|
||||
self.is_connected = True
|
||||
|
||||
# Create the tray icon
|
||||
self.icon = Icon(
|
||||
"ClipCascade", self.create_clipboard_icon(), menu=self.create_menu()
|
||||
)
|
||||
|
||||
self.icon.title = "ClipCascade"
|
||||
|
||||
self.update_stats() # Start the stats update thread
|
||||
|
||||
def run(self):
|
||||
self.icon.run()
|
||||
|
||||
def _create_clipboard_base_image(self):
|
||||
"""Shared clipboard artwork for normal tray icon and file-download badge variant."""
|
||||
width, height = 64, 64
|
||||
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
fill_color = (220, 220, 220)
|
||||
outline_color = (255, 255, 255)
|
||||
|
||||
board_coords = (12, 12, 52, 57)
|
||||
try:
|
||||
draw.rounded_rectangle(
|
||||
board_coords, radius=5, fill=None, outline=outline_color, width=3
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
draw.rectangle(board_coords, fill=None, outline=outline_color)
|
||||
|
||||
clip_coords = (22, 7, 42, 17)
|
||||
try:
|
||||
draw.rounded_rectangle(
|
||||
clip_coords, radius=3, fill=fill_color, outline=outline_color, width=3
|
||||
)
|
||||
except (AttributeError, TypeError):
|
||||
draw.rectangle(clip_coords, fill=fill_color, outline=outline_color)
|
||||
|
||||
return image
|
||||
|
||||
def create_clipboard_icon(self):
|
||||
return self._create_clipboard_base_image()
|
||||
|
||||
def create_clipboard_icon_with_dot(self):
|
||||
"""Same clipboard as normal state, plus a blue notification dot for pending file download."""
|
||||
image = self._create_clipboard_base_image().copy()
|
||||
draw = ImageDraw.Draw(image)
|
||||
width, height = 64, 64
|
||||
|
||||
dot_radius = 8
|
||||
dot_center_x = width - dot_radius - 5
|
||||
dot_center_y = dot_radius + 5
|
||||
dot_bbox = [
|
||||
dot_center_x - dot_radius,
|
||||
dot_center_y - dot_radius,
|
||||
dot_center_x + dot_radius,
|
||||
dot_center_y + dot_radius,
|
||||
]
|
||||
draw.ellipse(dot_bbox, fill=(0, 128, 255, 255))
|
||||
|
||||
highlight_radius = dot_radius // 2
|
||||
highlight_center_x = dot_center_x - highlight_radius // 2
|
||||
highlight_center_y = dot_center_y - highlight_radius // 2
|
||||
highlight_bbox = [
|
||||
highlight_center_x - highlight_radius,
|
||||
highlight_center_y - highlight_radius,
|
||||
highlight_center_x + highlight_radius,
|
||||
highlight_center_y + highlight_radius,
|
||||
]
|
||||
draw.ellipse(highlight_bbox, fill=(255, 255, 255, 180))
|
||||
|
||||
return image
|
||||
|
||||
def create_menu(self, item_: tuple = None):
|
||||
"""Create the menu for the tray icon.
|
||||
|
||||
Args:
|
||||
item_ (tuple, optional): The item that triggered the menu. Defaults to None.
|
||||
item_ = (text, location, callback)
|
||||
Returns:
|
||||
Menu: The menu for the tray icon.
|
||||
"""
|
||||
# Menu items
|
||||
menu_items = [
|
||||
Menu.SEPARATOR,
|
||||
item("🗒️ Open Logs", self._open_logs),
|
||||
item("📂 Program Files", self._open_program_location),
|
||||
Menu.SEPARATOR,
|
||||
item("🏠 Homepage", self._open_homepage),
|
||||
item("❓ Help", self._open_help),
|
||||
item("💟 Donate", self._open_donate),
|
||||
item("🌐 GitHub", self._open_github),
|
||||
Menu.SEPARATOR,
|
||||
item("🔒 Logoff and Quit", self._on_logoff),
|
||||
item("❌ Quit", self._on_quit),
|
||||
]
|
||||
|
||||
# Add connect/disconnect option (top of the menu - 0 index)
|
||||
if not self.is_connected:
|
||||
menu_items.insert(0, item("🔗 Connect", self._on_connect, default=True))
|
||||
else:
|
||||
if self.is_disconnecting and self.disconnecting_items is not None:
|
||||
menu_items.insert(
|
||||
self.disconnecting_items[1],
|
||||
item(self.disconnecting_items[0], self.disconnecting_items[2]),
|
||||
)
|
||||
else:
|
||||
menu_items.insert(0, item("⛓️💥 Disconnect", self._on_disconnect))
|
||||
|
||||
# Add update option (before the last 3 items)
|
||||
if self.new_version_available is not None and self.new_version_available[0]:
|
||||
menu_items.insert(
|
||||
len(menu_items) - 3,
|
||||
item(
|
||||
f"🔄 Update ({self.new_version_available[2]} ➞ {self.new_version_available[1]})",
|
||||
self._on_update,
|
||||
),
|
||||
)
|
||||
|
||||
# Add files download option (top of the menu - 0 index)
|
||||
if self.is_file_download_enabled and self.file_download_items is not None:
|
||||
menu_items.insert(
|
||||
self.file_download_items[1],
|
||||
item(
|
||||
self.file_download_items[0],
|
||||
self.file_download_items[2],
|
||||
default=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Add stats option (top of the menu - 0 index)
|
||||
if self.previous_stats_items is not None:
|
||||
menu_items.insert(
|
||||
self.previous_stats_items[1],
|
||||
item(
|
||||
self.previous_stats_items[0],
|
||||
self.previous_stats_items[2],
|
||||
enabled=self.previous_stats_items[2] is not None,
|
||||
),
|
||||
)
|
||||
|
||||
return Menu(*menu_items)
|
||||
|
||||
def update_menu(self, item_: tuple = None):
|
||||
self.icon.menu = self.create_menu(item_=item_)
|
||||
|
||||
def update_stats(self):
|
||||
threading.Thread(target=self._update_stats_thread, daemon=True).start()
|
||||
|
||||
def _update_stats_thread(self):
|
||||
while True:
|
||||
current_stats = self.ws_interface.get_stats()
|
||||
if current_stats is not None and self.previous_stats != current_stats:
|
||||
self.previous_stats = current_stats
|
||||
self.previous_stats_items = (current_stats, 0, None)
|
||||
self.update_menu()
|
||||
time.sleep(1) # Sleep for 1 second
|
||||
|
||||
@staticmethod
|
||||
def open_webbrowser(url):
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e:
|
||||
CustomDialog(
|
||||
f"Failed to open the browser. Here is the URL: {url}\nError: {e}",
|
||||
msg_type="error",
|
||||
).mainloop()
|
||||
|
||||
def _on_update(self, icon, item):
|
||||
TaskbarPanel.open_webbrowser(self.new_version_available[3])
|
||||
|
||||
def _on_connect(self, icon, item):
|
||||
if self.on_connect_callback:
|
||||
try:
|
||||
self.on_connect_callback()
|
||||
except Exception as e:
|
||||
pass
|
||||
self.is_connected = True
|
||||
self.update_menu()
|
||||
|
||||
def _on_disconnect(self, icon, item):
|
||||
if self.on_disconnect_callback:
|
||||
self.on_disconnect_callback()
|
||||
if self.ws_interface is not None and self.ws_interface.is_auto_reconnecting:
|
||||
threading.Thread(
|
||||
target=self._wait_to_disconnect, args=(icon, item), daemon=True
|
||||
).start()
|
||||
else:
|
||||
self.is_connected = False
|
||||
self.update_menu()
|
||||
|
||||
def _wait_to_disconnect(self, icon, item):
|
||||
"""
|
||||
Wait for the auto-reconnect timer to expire before disconnecting.
|
||||
"""
|
||||
if self.ws_interface is None:
|
||||
return
|
||||
|
||||
timeout = math.ceil(self.ws_interface.get_total_timeout() / 1000)
|
||||
while timeout > 0:
|
||||
self.is_disconnecting = True
|
||||
self.disconnecting_items = (
|
||||
f"⏳ Disconnecting... ({timeout} sec)",
|
||||
0,
|
||||
None,
|
||||
) # text, location, callback
|
||||
self.update_menu()
|
||||
time.sleep(1) # seconds
|
||||
timeout -= 1
|
||||
self.is_disconnecting = False
|
||||
self.disconnecting_items = None
|
||||
self.ws_interface.is_auto_reconnecting = False
|
||||
self.is_connected = False
|
||||
self.update_menu()
|
||||
|
||||
def _open_homepage(self, icon, item):
|
||||
TaskbarPanel.open_webbrowser(self.config.data["server_url"])
|
||||
|
||||
def _open_github(self, icon, item):
|
||||
TaskbarPanel.open_webbrowser(self.github_url)
|
||||
|
||||
def _open_help(self, icon, item):
|
||||
TaskbarPanel.open_webbrowser(HELP_URL)
|
||||
|
||||
def _open_donate(self, icon, item):
|
||||
if self.donation_url is not None:
|
||||
TaskbarPanel.open_webbrowser(self.donation_url)
|
||||
|
||||
def open_location(self, path):
|
||||
if PLATFORM == WINDOWS:
|
||||
os.startfile(path)
|
||||
elif PLATFORM == MACOS:
|
||||
subprocess.run(["open", path])
|
||||
elif PLATFORM.startswith(LINUX):
|
||||
subprocess.run(["xdg-open", path])
|
||||
|
||||
def _open_logs(self, icon, item):
|
||||
log_file_path = os.path.join(get_program_files_directory(), LOG_FILE_NAME)
|
||||
if os.path.exists(log_file_path):
|
||||
try:
|
||||
self.open_location(log_file_path)
|
||||
except Exception as e:
|
||||
CustomDialog(
|
||||
f"Failed to open the log file '{log_file_path}'.\nError: {e}",
|
||||
msg_type="error",
|
||||
).mainloop()
|
||||
else:
|
||||
CustomDialog(
|
||||
f"Log file not found at '{log_file_path}'.", msg_type="error"
|
||||
).mainloop()
|
||||
|
||||
def _open_program_location(self, icon, item):
|
||||
try:
|
||||
program_location = get_program_files_directory()
|
||||
self.open_location(program_location)
|
||||
except Exception as e:
|
||||
CustomDialog(
|
||||
f"Failed to open the program location '{program_location}'.\nError: {e}",
|
||||
msg_type="error",
|
||||
).mainloop()
|
||||
|
||||
def enable_files_download(self, files):
|
||||
self.is_file_download_enabled = True
|
||||
self.icon.icon = self.create_clipboard_icon_with_dot()
|
||||
self.file_download_items = (
|
||||
"📥 Download File(s)",
|
||||
0,
|
||||
lambda icon, item: self._on_download(icon, item, files),
|
||||
)
|
||||
self.update_menu()
|
||||
|
||||
def disable_files_download(self):
|
||||
self.is_file_download_enabled = False
|
||||
self.file_download_items = None
|
||||
self.icon.icon = self.create_clipboard_icon()
|
||||
self.update_menu()
|
||||
|
||||
def _on_download(self, icon, item, files):
|
||||
"""Download the files to the user's Downloads folder."""
|
||||
try:
|
||||
try:
|
||||
if self.config.data["default_file_download_location"] != "":
|
||||
target_directory = self.config.data[
|
||||
"default_file_download_location"
|
||||
]
|
||||
else:
|
||||
target_directory = filedialog.askdirectory(
|
||||
title="Select location to Save File(s)",
|
||||
initialdir=get_downloads_folder(),
|
||||
)
|
||||
if not target_directory:
|
||||
logging.debug("No directory selected. Exiting.")
|
||||
return
|
||||
except RuntimeError as re:
|
||||
target_directory = os.path.join(
|
||||
get_program_files_directory(), "downloads"
|
||||
)
|
||||
if not os.path.exists(target_directory):
|
||||
os.makedirs(target_directory)
|
||||
logging.error(
|
||||
f"A runtime error occurred while starting filedialog to select a directory. Error: {re}.\n"
|
||||
+ f"Setting the default location to the program directory '{target_directory}'."
|
||||
)
|
||||
CustomDialog(
|
||||
f"ClipCascade 📥: Saving files to the program directory '{target_directory}'.",
|
||||
msg_type="info",
|
||||
timeout=5000,
|
||||
).mainloop()
|
||||
|
||||
# Save each file to the chosen directory
|
||||
for filename, file_obj in files.items():
|
||||
file_path = os.path.join(target_directory, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_obj.getvalue())
|
||||
logging.debug(f"Saved: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
msg = f"An error occurred while downloading files. Error: {e}"
|
||||
logging.error(msg)
|
||||
CustomDialog(
|
||||
msg,
|
||||
msg_type="error",
|
||||
).mainloop()
|
||||
|
||||
def _on_logoff(self, icon, item):
|
||||
try:
|
||||
if self.on_logoff_callback:
|
||||
self.on_logoff_callback()
|
||||
self.icon.stop()
|
||||
self.root.quit()
|
||||
except Exception as e:
|
||||
CustomDialog(
|
||||
f"An error occurred while logging off: {e}", msg_type="error"
|
||||
).mainloop()
|
||||
|
||||
def _on_quit(self, icon, item):
|
||||
self.icon.stop()
|
||||
self.root.quit()
|
||||
Reference in New Issue
Block a user