claude-code-usage 1.0.2__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/icon_generator.py CHANGED
@@ -1,55 +1,36 @@
1
1
  """
2
2
  Icon generation utilities for Claude Code usage overlay.
3
3
 
4
- Generates circular gauge icons using Cairo for system tray display.
4
+ Generates circular gauge icons using Pillow for system tray display.
5
+ Cross-platform: works on Windows, macOS, and Linux.
5
6
  """
6
- import cairo
7
- import math
8
- import os
9
- import tempfile
10
- from pathlib import Path
11
7
  from typing import Tuple
12
8
 
13
- # Secure temp directory for icons (created once, reused)
14
- _icon_temp_dir: str | None = None
9
+ from PIL import Image, ImageDraw
15
10
 
16
11
 
17
- def _get_secure_icon_dir() -> str:
18
- """Get or create a secure temporary directory for icon files.
19
-
20
- Returns:
21
- Path to secure temp directory with restricted permissions (0o700)
22
- """
23
- global _icon_temp_dir
24
- if _icon_temp_dir is None or not Path(_icon_temp_dir).exists():
25
- # Create secure temp directory owned by current user only
26
- _icon_temp_dir = tempfile.mkdtemp(prefix="claude-usage-icons-")
27
- os.chmod(_icon_temp_dir, 0o700)
28
- return _icon_temp_dir
29
-
30
-
31
- def get_color_for_percentage(percentage: float) -> Tuple[float, float, float]:
12
+ def get_color_for_percentage(percentage: float) -> Tuple[int, int, int, int]:
32
13
  """
33
- Return RGB color tuple based on usage percentage.
14
+ Return RGBA color tuple based on usage percentage.
34
15
 
35
16
  Args:
36
17
  percentage: Usage percentage (0-100)
37
18
 
38
19
  Returns:
39
- RGB tuple in Cairo format (0.0-1.0 range)
20
+ RGBA tuple in Pillow format (0-255 range)
40
21
  - Green for < 50%
41
22
  - Yellow for 50-75%
42
23
  - Red for >= 75%
43
24
  """
44
25
  if percentage < 50:
45
- return (0.2, 0.8, 0.2) # Green
26
+ return (51, 204, 51, 255) # Green
46
27
  elif percentage < 75:
47
- return (0.9, 0.7, 0.0) # Yellow
28
+ return (230, 179, 0, 255) # Yellow
48
29
  else:
49
- return (0.9, 0.2, 0.2) # Red
30
+ return (230, 51, 51, 255) # Red
50
31
 
51
32
 
52
- def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) -> str:
33
+ def generate_gauge_icon(percentage: float, color_percentage: float, size: int = 22) -> Image.Image:
53
34
  """
54
35
  Generate a circular gauge icon showing usage percentage.
55
36
 
@@ -59,7 +40,7 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
59
40
  size: Icon size in pixels (default: 22)
60
41
 
61
42
  Returns:
62
- Absolute path to generated PNG file in /tmp
43
+ PIL.Image.Image object (RGBA mode) for use with pystray
63
44
 
64
45
  Notes:
65
46
  - percentage controls the arc fill amount (how full the gauge is)
@@ -68,6 +49,53 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
68
49
  """
69
50
  # Get color based on color_percentage parameter
70
51
  fill_color = get_color_for_percentage(color_percentage)
52
+ gray_color = (128, 128, 128, 255)
53
+
54
+ # Create transparent RGBA image
55
+ image = Image.new('RGBA', (size, size), (0, 0, 0, 0))
56
+ draw = ImageDraw.Draw(image)
57
+
58
+ # Calculate center and radius (leave 2px margin)
59
+ margin = 2
60
+ line_width = 2
61
+
62
+ # Bounding box for the arc (accounts for margin)
63
+ bbox = [margin, margin, size - margin - 1, size - margin - 1]
64
+
65
+ # Draw gray outline (full circle) as arc from 0 to 360
66
+ draw.arc(bbox, start=0, end=360, fill=gray_color, width=line_width)
67
+
68
+ # Draw colored fill arc (based on percentage)
69
+ if percentage > 0:
70
+ # Pillow arc: 0 degrees = 3 o'clock, angles go counter-clockwise
71
+ # We want: start at 12 o'clock (-90 degrees), go clockwise
72
+ # Pillow arc goes counter-clockwise, so we need to reverse direction
73
+ # Start at -90 (12 o'clock), end at -90 + percentage*3.6
74
+ start_angle = -90
75
+ end_angle = -90 + (360 * percentage / 100)
76
+ draw.arc(bbox, start=start_angle, end=end_angle, fill=fill_color, width=line_width)
77
+
78
+ return image
79
+
80
+
81
+ def generate_gauge_icon_path(percentage: float, color_percentage: float, size: int = 22) -> str:
82
+ """
83
+ Generate a circular gauge icon and save to temp file.
84
+
85
+ This function is for backwards compatibility with code that needs file paths.
86
+ Prefer using generate_gauge_icon() directly with pystray.
87
+
88
+ Args:
89
+ percentage: Usage level to display as arc fill (0-100)
90
+ color_percentage: Percentage used to determine color (0-100)
91
+ size: Icon size in pixels (default: 22)
92
+
93
+ Returns:
94
+ Absolute path to generated PNG file in temp directory
95
+ """
96
+ import os
97
+ import tempfile
98
+ from pathlib import Path
71
99
 
72
100
  # Determine color name for filename (to bust icon cache)
73
101
  if color_percentage < 50:
@@ -77,36 +105,16 @@ def generate_gauge_icon(percentage: float, color_percentage: float, size: int =
77
105
  else:
78
106
  color_name = "red"
79
107
 
80
- # Create Cairo surface with transparent background
81
- surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size)
82
- ctx = cairo.Context(surface)
108
+ # Get or create temp directory
109
+ temp_dir = tempfile.gettempdir()
110
+ icon_dir = os.path.join(temp_dir, "claude-usage-icons")
111
+ os.makedirs(icon_dir, exist_ok=True)
83
112
 
84
- # Clear background (transparent)
85
- ctx.set_source_rgba(0, 0, 0, 0)
86
- ctx.paint()
87
-
88
- # Calculate center and radius (leave 2px margin)
89
- center = size / 2
90
- radius = (size / 2) - 2
113
+ # Generate icon
114
+ image = generate_gauge_icon(percentage, color_percentage, size)
91
115
 
92
- # Draw gray outline (full circle)
93
- ctx.set_source_rgb(0.5, 0.5, 0.5)
94
- ctx.set_line_width(2)
95
- ctx.arc(center, center, radius, 0, 2 * math.pi)
96
- ctx.stroke()
97
-
98
- # Draw colored fill arc (based on percentage)
99
- if percentage > 0:
100
- ctx.set_source_rgb(*fill_color)
101
- ctx.set_line_width(2)
102
- # Start at 12 o'clock (-pi/2), go clockwise by percentage amount
103
- end_angle = -math.pi / 2 + (2 * math.pi * percentage / 100)
104
- ctx.arc(center, center, radius, -math.pi / 2, end_angle)
105
- ctx.stroke()
106
-
107
- # Save to secure temp directory with unique filename (bust GTK icon cache)
108
- icon_dir = _get_secure_icon_dir()
116
+ # Save to file
109
117
  icon_path = os.path.join(icon_dir, f"gauge-{int(percentage)}-{color_name}.png")
110
- surface.write_to_png(icon_path)
118
+ image.save(icon_path, 'PNG')
111
119
 
112
120
  return os.path.abspath(icon_path)
src/notifier.py CHANGED
@@ -1,12 +1,15 @@
1
- """Desktop notification manager for usage threshold alerts."""
1
+ """Desktop notification manager for usage threshold alerts.
2
2
 
3
- import subprocess
4
- import time
3
+ Cross-platform: uses desktop-notifier for Windows, macOS, and Linux.
4
+ """
5
5
 
6
- import gi
6
+ import asyncio
7
+ import sys
8
+ import time
9
+ import webbrowser
10
+ from typing import Optional
7
11
 
8
- gi.require_version('Notify', '0.7')
9
- from gi.repository import Notify
12
+ from desktop_notifier import DesktopNotifier, Urgency, Button
10
13
 
11
14
 
12
15
  class UsageNotifier:
@@ -26,8 +29,11 @@ class UsageNotifier:
26
29
  session_thresholds: List of threshold percentages for session (default [50, 75, 90])
27
30
  weekly_thresholds: List of threshold percentages for weekly (default to session_thresholds)
28
31
  """
29
- # Initialize libnotify once
30
- Notify.init(app_name)
32
+ # Initialize desktop-notifier
33
+ self.notifier = DesktopNotifier(
34
+ app_name=app_name,
35
+ notification_limit=10
36
+ )
31
37
 
32
38
  # Configure thresholds
33
39
  self.session_thresholds = session_thresholds if session_thresholds is not None else [50, 75, 90]
@@ -44,8 +50,36 @@ class UsageNotifier:
44
50
  # Pause notifications mode
45
51
  self.pause_notifications = False
46
52
 
47
- # Cache server capabilities
48
- self._actions_supported = None
53
+ # Event loop for async operations
54
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
55
+
56
+ def _get_event_loop(self) -> asyncio.AbstractEventLoop:
57
+ """Get or create event loop for async operations."""
58
+ if self._loop is None or self._loop.is_closed():
59
+ try:
60
+ self._loop = asyncio.get_running_loop()
61
+ except RuntimeError:
62
+ # No running loop, create new one
63
+ self._loop = asyncio.new_event_loop()
64
+ return self._loop
65
+
66
+ def _run_async(self, coro):
67
+ """Run async coroutine synchronously."""
68
+ loop = self._get_event_loop()
69
+ try:
70
+ # Try to run in existing loop
71
+ if loop.is_running():
72
+ # Schedule in running loop (won't block)
73
+ asyncio.ensure_future(coro, loop=loop)
74
+ else:
75
+ loop.run_until_complete(coro)
76
+ except RuntimeError:
77
+ # Fallback: create new loop
78
+ new_loop = asyncio.new_event_loop()
79
+ try:
80
+ new_loop.run_until_complete(coro)
81
+ finally:
82
+ new_loop.close()
49
83
 
50
84
  def check_and_notify(self, session_pct, weekly_pct, session_reset, weekly_reset):
51
85
  """Check thresholds and show notifications if needed.
@@ -126,6 +160,22 @@ class UsageNotifier:
126
160
  crossed = [t for t in thresholds if percentage >= t]
127
161
  return max(crossed) if crossed else None
128
162
 
163
+ def _get_urgency(self, threshold: int) -> Urgency:
164
+ """Map threshold to desktop-notifier Urgency level.
165
+
166
+ Args:
167
+ threshold: Threshold percentage (50, 75, or 90)
168
+
169
+ Returns:
170
+ Urgency enum value
171
+ """
172
+ if threshold >= 90:
173
+ return Urgency.Critical
174
+ elif threshold >= 75:
175
+ return Urgency.Normal
176
+ else:
177
+ return Urgency.Low
178
+
129
179
  def _show_notification(self, metric, percentage, threshold, reset_time):
130
180
  """Show notification for single metric threshold.
131
181
 
@@ -137,12 +187,13 @@ class UsageNotifier:
137
187
  """
138
188
  # Map threshold to urgency and advice
139
189
  urgency_map = {
140
- 50: (Notify.Urgency.LOW, "Heads up"),
141
- 75: (Notify.Urgency.NORMAL, "Consider saving your work"),
142
- 90: (Notify.Urgency.CRITICAL, "Save your work now")
190
+ 50: "Heads up",
191
+ 75: "Consider saving your work",
192
+ 90: "Save your work now"
143
193
  }
144
194
 
145
- urgency, advice = urgency_map[threshold]
195
+ advice = urgency_map[threshold]
196
+ urgency = self._get_urgency(threshold)
146
197
 
147
198
  # Format title
148
199
  metric_name = "Session Usage" if metric == 'session' else "Weekly Usage"
@@ -152,27 +203,8 @@ class UsageNotifier:
152
203
  reset_str = self._format_reset_time(reset_time)
153
204
  body = f"{advice}\n\nResets in {reset_str}"
154
205
 
155
- # Create notification
156
- notification = Notify.Notification.new(
157
- title,
158
- body,
159
- "dialog-information"
160
- )
161
-
162
- # Set urgency level
163
- notification.set_urgency(urgency)
164
-
165
- # Add action button if server supports it
166
- if self._server_supports_actions():
167
- notification.add_action(
168
- "open-claude",
169
- "Open Claude Code",
170
- self._on_open_claude,
171
- None
172
- )
173
-
174
- # Show notification
175
- notification.show()
206
+ # Show notification asynchronously
207
+ self._run_async(self._send_notification(title, body, urgency))
176
208
 
177
209
  def _show_combined_notification(self, threshold, session_pct, weekly_pct,
178
210
  session_reset, weekly_reset):
@@ -187,12 +219,13 @@ class UsageNotifier:
187
219
  """
188
220
  # Map threshold to urgency and advice
189
221
  urgency_map = {
190
- 50: (Notify.Urgency.LOW, "Heads up"),
191
- 75: (Notify.Urgency.NORMAL, "Consider saving your work"),
192
- 90: (Notify.Urgency.CRITICAL, "Save your work now")
222
+ 50: "Heads up",
223
+ 75: "Consider saving your work",
224
+ 90: "Save your work now"
193
225
  }
194
226
 
195
- urgency, advice = urgency_map[threshold]
227
+ advice = urgency_map[threshold]
228
+ urgency = self._get_urgency(threshold)
196
229
 
197
230
  # Format title (show threshold, not individual percentages)
198
231
  title = f"Session & Weekly Usage: {threshold}%"
@@ -204,27 +237,32 @@ class UsageNotifier:
204
237
  f"Session resets in {session_reset_str}\n"
205
238
  f"Weekly resets in {weekly_reset_str}")
206
239
 
207
- # Create notification
208
- notification = Notify.Notification.new(
209
- title,
210
- body,
211
- "dialog-information"
212
- )
240
+ # Show notification asynchronously
241
+ self._run_async(self._send_notification(title, body, urgency))
213
242
 
214
- # Set urgency level
215
- notification.set_urgency(urgency)
243
+ async def _send_notification(self, title: str, body: str, urgency: Urgency):
244
+ """Send notification via desktop-notifier.
216
245
 
217
- # Add action button if server supports it
218
- if self._server_supports_actions():
219
- notification.add_action(
220
- "open-claude",
221
- "Open Claude Code",
222
- self._on_open_claude,
223
- None
246
+ Args:
247
+ title: Notification title
248
+ body: Notification body text
249
+ urgency: Urgency level
250
+ """
251
+ try:
252
+ await self.notifier.send(
253
+ title=title,
254
+ message=body,
255
+ urgency=urgency,
256
+ buttons=[
257
+ Button(
258
+ title="Open Claude",
259
+ on_pressed=lambda: webbrowser.open("https://claude.ai")
260
+ )
261
+ ]
224
262
  )
225
-
226
- # Show notification
227
- notification.show()
263
+ except Exception as e:
264
+ # Log but don't crash on notification failure
265
+ print(f"Notification error: {e}", file=sys.stderr)
228
266
 
229
267
  def _format_reset_time(self, reset_timestamp):
230
268
  """Format Unix timestamp to human-readable time until reset.
@@ -253,31 +291,6 @@ class UsageNotifier:
253
291
  minutes = remainder // 60
254
292
  return f"{minutes}m" if minutes > 0 else "shortly"
255
293
 
256
- def _server_supports_actions(self):
257
- """Check if notification server supports action buttons.
258
-
259
- Returns:
260
- bool: True if server supports actions, False otherwise
261
- """
262
- if self._actions_supported is None:
263
- caps = Notify.get_server_caps()
264
- self._actions_supported = caps and 'actions' in caps
265
- return self._actions_supported
266
-
267
- def _on_open_claude(self, notification, action, user_data):
268
- """Callback when 'Open Claude Code' button is clicked.
269
-
270
- Args:
271
- notification: The notification that triggered the action
272
- action: Action identifier string
273
- user_data: User data passed to add_action (unused)
274
- """
275
- # Close the notification
276
- notification.close()
277
-
278
- # Open Claude Code in browser
279
- subprocess.Popen(["/usr/bin/xdg-open", "https://claude.ai"])
280
-
281
294
  def set_thresholds(self, session_thresholds, weekly_thresholds=None):
282
295
  """Update threshold configuration.
283
296