Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions src/portkeydrop/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
save_queue,
)
from portkeydrop.services.transfer_service import TransferService, format_transfer_detail
from portkeydrop.system_tray import SystemTrayIcon
from portkeydrop.local_files import (
delete_local,
list_local_dir,
Expand Down Expand Up @@ -124,6 +125,8 @@ def __init__(self) -> None:
except ImportError:
self.build_tag = os.environ.get("PORTKEYDROP_BUILD_TAG")
self._auto_update_check_timer: wx.Timer | None = None
self._tray_icon: SystemTrayIcon | None = None
self._force_exit = False
self._site_manager = SiteManager()
self._transfer_service = TransferService(
notify_window=self,
Expand All @@ -146,6 +149,7 @@ def __init__(self) -> None:
self._bind_events()
self._update_title()
self._refresh_local_files()
self._sync_tray_icon()
wx.CallAfter(self._set_initial_focus)
self._start_auto_update_checks()
wx.CallAfter(self._check_for_updates_on_startup)
Expand All @@ -160,8 +164,9 @@ def _build_menu(self) -> None:
file_menu.AppendSeparator()
file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings")
file_menu.AppendSeparator()
exit_shortcut = "Ctrl+Q" if wx.Platform == "__WXMAC__" else "Alt+F4"
file_menu.Append(wx.ID_EXIT, f"E&xit\t{exit_shortcut}", "Exit application")
# wx maps Ctrl accelerators to Command on macOS; RawCtrl is the physical Ctrl key.
exit_label = "E&xit\tCtrl+Q" if wx.Platform == "__WXMAC__" else "E&xit"
file_menu.Append(wx.ID_EXIT, exit_label, "Exit application")
menubar.Append(file_menu, "&File")

# Edit menu (for file operations)
Expand Down Expand Up @@ -757,7 +762,12 @@ def _on_disconnect(self, event) -> None:
self.tb_host.SetFocus()

def _on_exit(self, event: wx.CommandEvent) -> None:
self.Close()
self.request_exit()

def request_exit(self) -> None:
"""Close the application even when close-to-tray is enabled."""
self._force_exit = True
self.Close(True)

# --- Path bar events ---

Expand Down Expand Up @@ -1816,6 +1826,7 @@ def _on_settings(self, event: wx.CommandEvent) -> None:
)
self.update_check_updates_menu_label()
self._start_auto_update_checks()
self._sync_tray_icon()
self._populate_file_list(
self.remote_file_list,
self._get_visible_files(self._remote_files, self._remote_filter_text),
Expand Down Expand Up @@ -2127,11 +2138,58 @@ def _restore_transfer_queue(self) -> None:
msg = f"Restored {count} pending transfer{'s' if count != 1 else ''} from last session"
wx.CallAfter(self._announce, msg)

def _sync_tray_icon(self) -> None:
"""Create or remove the notification area icon to match settings."""
app_settings = getattr(getattr(self, "_settings", None), "app", None)
enabled = bool(getattr(app_settings, "show_notification_area_icon", True))
if not hasattr(self, "_tray_icon"):
self._tray_icon = None
if enabled and self._tray_icon is None:
try:
self._tray_icon = SystemTrayIcon(self)
except Exception:
logger.warning("Failed to initialize notification area icon", exc_info=True)
self._tray_icon = None
return

if not enabled and self._tray_icon is not None:
self._destroy_tray_icon()

def _destroy_tray_icon(self) -> None:
tray_icon = getattr(self, "_tray_icon", None)
if tray_icon is None:
return
try:
tray_icon.RemoveIcon()
except Exception:
logger.debug("Failed to remove notification area icon", exc_info=True)
try:
tray_icon.Destroy()
except Exception:
logger.debug("Failed to destroy notification area icon", exc_info=True)
self._tray_icon = None

def _should_minimize_to_tray_on_close(self) -> bool:
app_settings = getattr(getattr(self, "_settings", None), "app", None)
return (
not getattr(self, "_force_exit", False)
and getattr(self, "_tray_icon", None) is not None
and bool(getattr(app_settings, "show_notification_area_icon", True))
and bool(getattr(app_settings, "minimize_to_notification_area_on_close", False))
)

def _on_close(self, event) -> None:
"""Save transfer queue and stop timers before closing the window."""
save_queue(self._transfer_service, get_config_dir())
if self._should_minimize_to_tray_on_close():
self.Hide()
self._announce("Portkey Drop is still running in the notification area.")
if event is not None and hasattr(event, "Veto"):
event.Veto()
return
if self._auto_update_check_timer:
self._auto_update_check_timer.Stop()
self._destroy_tray_icon()
if event is not None and hasattr(event, "Skip"):
event.Skip()

Expand Down
18 changes: 18 additions & 0 deletions src/portkeydrop/dialogs/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,18 @@ def _build_display_tab(self) -> None:
control_name="Date format",
)

self.show_tray_icon_check = self._add_checkbox_row(
sizer,
wx.CheckBox(panel, label="Show &notification area icon"),
name="Show notification area icon",
)

self.minimize_to_tray_check = self._add_checkbox_row(
sizer,
wx.CheckBox(panel, label="Minimize to notification area when &closing"),
name="Minimize to notification area on close",
)

sizer.AddStretchSpacer(1)
self.notebook.AddPage(panel, "Display")

Expand Down Expand Up @@ -453,6 +465,10 @@ def _populate(self) -> None:
self.sort_asc_check.SetValue(s.display.sort_ascending)
idx = ["relative", "absolute"].index(s.display.date_format)
self.date_format_choice.SetSelection(idx)
self.show_tray_icon_check.SetValue(getattr(s.app, "show_notification_area_icon", True))
self.minimize_to_tray_check.SetValue(
getattr(s.app, "minimize_to_notification_area_on_close", False)
)
# Connection
idx = list(SUPPORTED_PROTOCOL_VALUES).index(s.connection.protocol)
self.default_proto_choice.SetSelection(idx)
Expand Down Expand Up @@ -492,6 +508,8 @@ def get_settings(self) -> Settings:
s.display.sort_by = self.sort_by_choice.GetStringSelection()
s.display.sort_ascending = self.sort_asc_check.GetValue()
s.display.date_format = self.date_format_choice.GetStringSelection()
s.app.show_notification_area_icon = self.show_tray_icon_check.GetValue()
s.app.minimize_to_notification_area_on_close = self.minimize_to_tray_check.GetValue()

s.connection.protocol = self.default_proto_choice.GetStringSelection()
s.connection.timeout = self.timeout_spin.GetValue()
Expand Down
2 changes: 2 additions & 0 deletions src/portkeydrop/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ class AppSettings:
auto_update_enabled: bool = True
update_check_interval_hours: int = 24
update_channel: str = "stable"
show_notification_area_icon: bool = True
minimize_to_notification_area_on_close: bool = False


@dataclass
Expand Down
138 changes: 138 additions & 0 deletions src/portkeydrop/system_tray.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Notification area icon for Portkey Drop."""

from __future__ import annotations

import logging
import sys
from pathlib import Path
from typing import TYPE_CHECKING

import wx
import wx.adv

if TYPE_CHECKING:
from portkeydrop.app import MainFrame

logger = logging.getLogger(__name__)


class SystemTrayIcon(wx.adv.TaskBarIcon):
"""Cross-platform system tray / notification area icon."""

def __init__(self, frame: MainFrame) -> None:
super().__init__()
self.frame = frame
self._icon_set = False
self._cached_icon: wx.Icon | None = None

self._setup_icon()
self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self._on_left_click)
self.Bind(wx.adv.EVT_TASKBAR_LEFT_DCLICK, self._on_left_click)
self.Bind(wx.adv.EVT_TASKBAR_RIGHT_DOWN, self._on_right_click)

def _setup_icon(self) -> None:
icon = self._load_icon()
if icon is not None and icon.IsOk():
self._cached_icon = icon
self.SetIcon(icon, "Portkey Drop")
self._icon_set = True
logger.debug("Notification area icon set")
else:
logger.warning("Notification area icon could not be loaded")

def _load_icon(self) -> wx.Icon | None:
for icon_path in self._get_icon_paths():
if not icon_path.exists():
continue
try:
bitmap_type = (
wx.BITMAP_TYPE_ICO if icon_path.suffix.lower() == ".ico" else wx.BITMAP_TYPE_PNG
)
icon = wx.Icon(str(icon_path), bitmap_type)
if icon.IsOk():
return icon
except Exception:
logger.debug("Failed to load tray icon from %s", icon_path, exc_info=True)
return self._create_default_icon()

def _get_icon_paths(self) -> list[Path]:
if getattr(sys, "frozen", False):
base_path = Path(sys.executable).parent
return [
base_path / "app.ico",
base_path / "resources" / "app.ico",
base_path / "resources" / "app_32.png",
base_path / "resources" / "app_16.png",
]

package_path = Path(__file__).parent
return [
package_path / "resources" / "app.ico",
package_path / "resources" / "app_32.png",
package_path / "resources" / "app_16.png",
]

def _create_default_icon(self) -> wx.Icon:
bitmap = wx.Bitmap(16, 16)
dc = wx.MemoryDC(bitmap)
dc.SetBackground(wx.Brush(wx.Colour(42, 92, 170)))
dc.Clear()
dc.SetPen(wx.Pen(wx.WHITE, 1))
dc.SetTextForeground(wx.WHITE)
dc.DrawText("P", 4, 1)
dc.SelectObject(wx.NullBitmap)

icon = wx.Icon()
icon.CopyFromBitmap(bitmap)
return icon

def _on_left_click(self, event) -> None:
self.show_main_window()

def _on_right_click(self, event) -> None:
menu = self._create_popup_menu()
try:
self.PopupMenu(menu)
finally:
menu.Destroy()

def _create_popup_menu(self) -> wx.Menu:
menu = wx.Menu()
show_item = menu.Append(wx.ID_ANY, "&Show Portkey Drop")
queue_item = menu.Append(wx.ID_ANY, "Transfer &Queue...")
updates_item = menu.Append(wx.ID_ANY, "Check for &Updates...")
menu.AppendSeparator()
exit_item = menu.Append(wx.ID_EXIT, "E&xit")

self.Bind(wx.EVT_MENU, self._on_show_menu, show_item)
self.Bind(wx.EVT_MENU, self._on_transfer_queue_menu, queue_item)
self.Bind(wx.EVT_MENU, self._on_check_updates_menu, updates_item)
self.Bind(wx.EVT_MENU, self._on_exit_menu, exit_item)
return menu

def _on_show_menu(self, event) -> None:
self.show_main_window()

def _on_transfer_queue_menu(self, event) -> None:
self.show_main_window()
self.frame._on_transfer_queue(event)

def _on_check_updates_menu(self, event) -> None:
self.show_main_window()
self.frame._on_check_updates(event)

def _on_exit_menu(self, event) -> None:
self.frame.request_exit()

def show_main_window(self) -> None:
self.frame.Show(True)
self.frame.Iconize(False)
if sys.platform == "darwin":
self.frame.RequestUserAttention()
else:
self.frame.Raise()
self.frame.SetFocus()

def update_tooltip(self, text: str) -> None:
if self._icon_set and self._cached_icon is not None and self._cached_icon.IsOk():
self.SetIcon(self._cached_icon, text)
34 changes: 34 additions & 0 deletions tests/_wx_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ def _new_id_ref(*_args, **_kwargs):
fake_wx.ListCtrl = lambda *args, **kwargs: _SimpleWidget()
fake_wx.Timer = lambda *args, **kwargs: _SimpleWidget()
fake_wx.FileDataObject = MagicMock()
fake_wx.Icon = MagicMock
fake_wx.Bitmap = MagicMock
fake_wx.MemoryDC = MagicMock
fake_wx.Brush = MagicMock
fake_wx.Colour = MagicMock
fake_wx.Pen = MagicMock
fake_wx.NullBitmap = None

class _Clipboard:
@staticmethod
Expand Down Expand Up @@ -145,6 +152,9 @@ def Close() -> None:
fake_wx.ICON_INFORMATION = 105
fake_wx.ID_EXIT = 200
fake_wx.ID_ABOUT = 201
fake_wx.ID_ANY = -1
fake_wx.BITMAP_TYPE_ICO = 1
fake_wx.BITMAP_TYPE_PNG = 2

fake_wx.StaticBox = lambda *args, **kwargs: _SimpleWidget()
fake_wx.StaticBoxSizer = lambda *args, **kwargs: _SimpleWidget()
Expand Down Expand Up @@ -201,6 +211,30 @@ def SetDescription(self, value: str) -> None:

fake_adv.AboutDialogInfo = _AboutDialogInfo
fake_adv.AboutBox = lambda info: None
fake_adv.EVT_TASKBAR_LEFT_DOWN = object()
fake_adv.EVT_TASKBAR_LEFT_DCLICK = object()
fake_adv.EVT_TASKBAR_RIGHT_DOWN = object()

class _TaskBarIcon:
def __init__(self, *args, **kwargs) -> None:
self._bindings: list[tuple] = []

def Bind(self, *args, **kwargs) -> None:
self._bindings.append((args, kwargs))

def SetIcon(self, *args, **kwargs) -> bool:
return True

def PopupMenu(self, *_args, **_kwargs) -> None:
return None

def RemoveIcon(self) -> bool:
return True

def Destroy(self) -> None:
return None

fake_adv.TaskBarIcon = _TaskBarIcon
fake_wx.adv = fake_adv

return fake_wx, fake_adv
Expand Down
Loading
Loading