Add desktop tray widget + installer wizard
- Desktop widget (Python/pystray): system tray icon showing 5h usage as circular progress bar with Claude starburst logo, 10-step green-to-red color scale, right-click menu with usage stats and configuration - Shared cache: both widget and CLI statusline read/write the same /tmp/claude_usage.json — only one fetcher needs to run - Installer wizard (install_wizard.py): interactive cross-platform setup with component selection, session key prompt, cron/autostart config - OS wrappers: install.sh (Linux/macOS) and install.ps1 (Windows) find Python 3.9+ and launch the wizard - README with topology diagram, usage docs, and configuration reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
claude_usage_widget/__init__.py
Normal file
3
claude_usage_widget/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Claude Usage Widget — System tray usage monitor."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
claude_usage_widget/__main__.py
Normal file
5
claude_usage_widget/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Entry point: python -m claude_usage_widget"""
|
||||
|
||||
from .app import main
|
||||
|
||||
main()
|
||||
91
claude_usage_widget/app.py
Normal file
91
claude_usage_widget/app.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Main orchestrator: tray icon, state, threads."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import simpledialog
|
||||
|
||||
import pystray
|
||||
|
||||
from . import config
|
||||
from .fetcher import UsageFetcher
|
||||
from .menu import build_menu
|
||||
from .renderer import render_icon
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self):
|
||||
self.usage_data = {}
|
||||
self._lock = threading.Lock()
|
||||
self.fetcher = UsageFetcher(on_update=self._on_usage_update)
|
||||
self._icon = pystray.Icon(
|
||||
name="claude-usage",
|
||||
icon=render_icon(0),
|
||||
title="Claude Usage Widget",
|
||||
menu=build_menu(self),
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""Start fetcher and run tray icon (blocks until quit)."""
|
||||
self.fetcher.start()
|
||||
self._icon.run()
|
||||
|
||||
def _on_usage_update(self, data):
|
||||
"""Callback from fetcher thread — update icon + menu."""
|
||||
with self._lock:
|
||||
self.usage_data = data
|
||||
|
||||
pct = data.get("five_hour_pct", 0) if not data.get("error") else 0
|
||||
try:
|
||||
self._icon.icon = render_icon(pct)
|
||||
# Force menu rebuild for dynamic text
|
||||
self._icon.update_menu()
|
||||
except Exception:
|
||||
pass # icon not yet visible
|
||||
|
||||
# Update tooltip
|
||||
if data.get("error"):
|
||||
self._icon.title = f"Claude Usage: {data['error']}"
|
||||
else:
|
||||
self._icon.title = f"Claude Usage: {data.get('five_hour_pct', 0)}%"
|
||||
|
||||
def on_refresh(self):
|
||||
self.fetcher.refresh()
|
||||
|
||||
def on_set_interval(self, seconds):
|
||||
self.fetcher.set_interval(seconds)
|
||||
|
||||
def on_session_key(self):
|
||||
"""Show a simple dialog to enter/update the session key."""
|
||||
threading.Thread(target=self._session_key_dialog, daemon=True).start()
|
||||
|
||||
def _session_key_dialog(self):
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
current = config.get_session_key()
|
||||
key = simpledialog.askstring(
|
||||
"Claude Session Key",
|
||||
"Paste your claude.ai sessionKey cookie value:",
|
||||
initialvalue=current,
|
||||
parent=root,
|
||||
)
|
||||
root.destroy()
|
||||
if key is not None and key.strip():
|
||||
config.set_session_key(key.strip())
|
||||
self.fetcher.refresh()
|
||||
|
||||
def on_quit(self):
|
||||
self.fetcher.stop()
|
||||
self._icon.stop()
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
app = App()
|
||||
app.run()
|
||||
62
claude_usage_widget/config.py
Normal file
62
claude_usage_widget/config.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""JSON config management for Claude Usage Widget."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# All config lives under claude-statusline (shared with the CLI statusline)
|
||||
if sys.platform == "win32":
|
||||
_CONFIG_DIR = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "claude-statusline")
|
||||
else:
|
||||
_CONFIG_DIR = os.environ.get("CLAUDE_STATUSLINE_CONFIG") or os.path.join(
|
||||
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "claude-statusline"
|
||||
)
|
||||
|
||||
CONFIG_PATH = os.path.join(_CONFIG_DIR, "widget-config.json")
|
||||
SESSION_KEY_PATH = os.path.join(_CONFIG_DIR, "session-key")
|
||||
|
||||
DEFAULTS = {
|
||||
"refresh_interval": 300, # seconds
|
||||
"org_id": "",
|
||||
}
|
||||
|
||||
|
||||
def load():
|
||||
"""Load config, merging with defaults for missing keys."""
|
||||
cfg = dict(DEFAULTS)
|
||||
try:
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
cfg.update(json.load(f))
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def save(cfg):
|
||||
"""Persist config to disk."""
|
||||
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
|
||||
|
||||
def get_session_key():
|
||||
"""Return session key from env var or shared config file."""
|
||||
key = os.environ.get("CLAUDE_SESSION_KEY", "").strip()
|
||||
if key:
|
||||
return key
|
||||
try:
|
||||
with open(SESSION_KEY_PATH, "r") as f:
|
||||
key = f.read().strip()
|
||||
if key and not key.startswith("#"):
|
||||
return key
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def set_session_key(key):
|
||||
"""Write session key to shared config file."""
|
||||
os.makedirs(os.path.dirname(SESSION_KEY_PATH), exist_ok=True)
|
||||
with open(SESSION_KEY_PATH, "w") as f:
|
||||
f.write(key.strip() + "\n")
|
||||
os.chmod(SESSION_KEY_PATH, 0o600)
|
||||
240
claude_usage_widget/fetcher.py
Normal file
240
claude_usage_widget/fetcher.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Usage fetcher — Python port of claude-statusline/fetch-usage.js (urllib only).
|
||||
|
||||
Writes to the same shared cache file as fetch-usage.js so both the CLI
|
||||
statusline and the desktop widget see the same data from a single fetch.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0",
|
||||
"Accept": "application/json",
|
||||
"Referer": "https://claude.ai/",
|
||||
"Origin": "https://claude.ai",
|
||||
}
|
||||
|
||||
# Shared cache path — identical to fetch-usage.js default
|
||||
if sys.platform == "win32":
|
||||
_DEFAULT_CACHE = os.path.join(os.environ.get("TEMP", os.path.expanduser("~")), "claude_usage.json")
|
||||
else:
|
||||
_DEFAULT_CACHE = os.path.join(os.environ.get("TMPDIR", "/tmp"), "claude_usage.json")
|
||||
|
||||
CACHE_PATH = os.environ.get("CLAUDE_USAGE_CACHE", _DEFAULT_CACHE)
|
||||
|
||||
|
||||
# -- Cache I/O (compatible with fetch-usage.js format) -----------------------
|
||||
|
||||
|
||||
def write_cache(data):
|
||||
"""Write raw API response (or error dict) to the shared cache file."""
|
||||
cache_dir = os.path.dirname(CACHE_PATH)
|
||||
if cache_dir:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
with open(CACHE_PATH, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def read_cache():
|
||||
"""Read the shared cache file. Returns (data_dict, age_seconds) or (None, inf)."""
|
||||
try:
|
||||
mtime = os.path.getmtime(CACHE_PATH)
|
||||
age = time.time() - mtime
|
||||
with open(CACHE_PATH, "r") as f:
|
||||
data = json.load(f)
|
||||
return data, age
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return None, float("inf")
|
||||
|
||||
|
||||
# -- API requests ------------------------------------------------------------
|
||||
|
||||
|
||||
def _request(url, session_key):
|
||||
req = urllib.request.Request(url, headers={
|
||||
**_HEADERS,
|
||||
"Cookie": f"sessionKey={session_key}",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def _discover_org_id(session_key):
|
||||
orgs = _request("https://claude.ai/api/organizations", session_key)
|
||||
if not orgs:
|
||||
raise RuntimeError("No organizations found")
|
||||
return orgs[0]["uuid"]
|
||||
|
||||
|
||||
def fetch_usage(session_key, org_id=""):
|
||||
"""Fetch usage data once. Returns (data_dict, org_id) or raises."""
|
||||
if not org_id:
|
||||
org_id = _discover_org_id(session_key)
|
||||
data = _request(f"https://claude.ai/api/organizations/{org_id}/usage", session_key)
|
||||
return data, org_id
|
||||
|
||||
|
||||
# -- Parsing -----------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_usage(data):
|
||||
"""Extract display-friendly usage info from raw API response.
|
||||
|
||||
Returns dict with keys:
|
||||
five_hour_pct, five_hour_resets_at, five_hour_resets_in,
|
||||
seven_day_pct, seven_day_resets_at, seven_day_resets_in,
|
||||
error
|
||||
"""
|
||||
if data is None:
|
||||
return {"error": "no data"}
|
||||
|
||||
# Error states written by either fetcher
|
||||
if isinstance(data.get("_error"), str):
|
||||
err = data["_error"]
|
||||
if err == "auth_expired":
|
||||
return {"error": "session expired"}
|
||||
return {"error": err}
|
||||
|
||||
result = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for window, prefix in [("five_hour", "five_hour"), ("seven_day", "seven_day")]:
|
||||
block = data.get(window, {})
|
||||
pct = round(block.get("utilization", 0))
|
||||
resets_at = block.get("resets_at", "")
|
||||
resets_in = ""
|
||||
if resets_at:
|
||||
try:
|
||||
dt = datetime.fromisoformat(resets_at)
|
||||
delta = dt - now
|
||||
total_seconds = max(0, int(delta.total_seconds()))
|
||||
days = total_seconds // 86400
|
||||
hours = (total_seconds % 86400) // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
if days > 0:
|
||||
resets_in = f"{days}d {hours}h"
|
||||
elif hours > 0:
|
||||
resets_in = f"{hours}h {minutes}m"
|
||||
else:
|
||||
resets_in = f"{minutes}m"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
result[f"{prefix}_pct"] = pct
|
||||
result[f"{prefix}_resets_at"] = resets_at
|
||||
result[f"{prefix}_resets_in"] = resets_in
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# -- Background fetcher thread -----------------------------------------------
|
||||
|
||||
|
||||
class UsageFetcher:
|
||||
"""Background daemon thread that periodically fetches usage data.
|
||||
|
||||
Writes every fetch result to the shared cache file so the CLI statusline
|
||||
(and any other consumer) sees the same data. Before fetching, checks if
|
||||
the cache is already fresh enough (e.g. populated by the Node.js cron
|
||||
fetcher) and skips the API call if so.
|
||||
"""
|
||||
|
||||
def __init__(self, on_update):
|
||||
"""on_update(parsed_dict) called on each successful or failed fetch."""
|
||||
self._on_update = on_update
|
||||
self._stop = threading.Event()
|
||||
self._refresh_now = threading.Event()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._cfg = config.load()
|
||||
self._org_id = self._cfg.get("org_id", "")
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
self._refresh_now.set() # unblock wait
|
||||
|
||||
def refresh(self):
|
||||
"""Trigger an immediate fetch (bypasses cache freshness check)."""
|
||||
self._refresh_now.set()
|
||||
|
||||
def set_interval(self, seconds):
|
||||
"""Change refresh interval and trigger immediate fetch."""
|
||||
self._cfg["refresh_interval"] = seconds
|
||||
config.save(self._cfg)
|
||||
self._refresh_now.set()
|
||||
|
||||
@property
|
||||
def interval(self):
|
||||
return self._cfg.get("refresh_interval", 300)
|
||||
|
||||
def _loop(self):
|
||||
# On startup, try to show cached data immediately
|
||||
self._load_from_cache()
|
||||
|
||||
while not self._stop.is_set():
|
||||
forced = self._refresh_now.is_set()
|
||||
self._refresh_now.clear()
|
||||
self._do_fetch(force=forced)
|
||||
self._refresh_now.wait(timeout=self._cfg.get("refresh_interval", 300))
|
||||
|
||||
def _load_from_cache(self):
|
||||
"""Read existing cache for instant display on startup."""
|
||||
data, age = read_cache()
|
||||
if data and age < self._cfg.get("refresh_interval", 300):
|
||||
parsed = parse_usage(data)
|
||||
self._on_update(parsed)
|
||||
|
||||
def _do_fetch(self, force=False):
|
||||
"""Fetch from API, or use cache if fresh enough.
|
||||
|
||||
If force=True (manual refresh), always hit the API.
|
||||
Otherwise, skip if cache is younger than half the refresh interval
|
||||
(meaning another fetcher like the Node.js cron job already updated it).
|
||||
"""
|
||||
if not force:
|
||||
data, age = read_cache()
|
||||
freshness_threshold = self._cfg.get("refresh_interval", 300) / 2
|
||||
if data and not data.get("_error") and age < freshness_threshold:
|
||||
parsed = parse_usage(data)
|
||||
self._on_update(parsed)
|
||||
log.debug("Cache is fresh (%.0fs old), skipping API call", age)
|
||||
return
|
||||
|
||||
session_key = config.get_session_key()
|
||||
if not session_key:
|
||||
error_data = {"_error": "no_session_key"}
|
||||
write_cache(error_data)
|
||||
self._on_update({"error": "no session key"})
|
||||
return
|
||||
|
||||
try:
|
||||
data, org_id = fetch_usage(session_key, self._org_id)
|
||||
if org_id and org_id != self._org_id:
|
||||
self._org_id = org_id
|
||||
self._cfg["org_id"] = org_id
|
||||
config.save(self._cfg)
|
||||
write_cache(data)
|
||||
parsed = parse_usage(data)
|
||||
self._on_update(parsed)
|
||||
except urllib.error.HTTPError as e:
|
||||
error_key = "auth_expired" if e.code in (401, 403) else "api_error"
|
||||
error_data = {"_error": error_key, "_status": e.code}
|
||||
write_cache(error_data)
|
||||
self._on_update({"error": "session expired" if error_key == "auth_expired" else f"HTTP {e.code}"})
|
||||
log.warning("Fetch failed: HTTP %d", e.code)
|
||||
except Exception as e:
|
||||
error_data = {"_error": "fetch_failed", "_message": str(e)}
|
||||
write_cache(error_data)
|
||||
self._on_update({"error": str(e)})
|
||||
log.warning("Fetch failed: %s", e)
|
||||
68
claude_usage_widget/menu.py
Normal file
68
claude_usage_widget/menu.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""pystray Menu builder with dynamic text and radio interval selection."""
|
||||
|
||||
import pystray
|
||||
|
||||
_INTERVALS = [
|
||||
(60, "1 min"),
|
||||
(300, "5 min"),
|
||||
(900, "15 min"),
|
||||
(1800, "30 min"),
|
||||
]
|
||||
|
||||
|
||||
def build_menu(app):
|
||||
"""Build the right-click menu. `app` must expose:
|
||||
|
||||
- app.usage_data (dict from fetcher.parse_usage)
|
||||
- app.fetcher (UsageFetcher instance)
|
||||
- app.on_refresh()
|
||||
- app.on_set_interval(seconds)
|
||||
- app.on_session_key()
|
||||
- app.on_quit()
|
||||
"""
|
||||
|
||||
def _five_hour_text(_):
|
||||
d = app.usage_data
|
||||
if not d or d.get("error"):
|
||||
return f"5h Usage: {d.get('error', '?')}" if d else "5h Usage: loading..."
|
||||
return f"5h Usage: {d.get('five_hour_pct', 0)}%"
|
||||
|
||||
def _five_hour_reset(_):
|
||||
d = app.usage_data
|
||||
r = d.get("five_hour_resets_in", "") if d else ""
|
||||
return f"Resets in: {r}" if r else "Resets in: —"
|
||||
|
||||
def _seven_day_text(_):
|
||||
d = app.usage_data
|
||||
if not d or d.get("error"):
|
||||
return "7d Usage: —"
|
||||
return f"7d Usage: {d.get('seven_day_pct', 0)}%"
|
||||
|
||||
def _seven_day_reset(_):
|
||||
d = app.usage_data
|
||||
r = d.get("seven_day_resets_in", "") if d else ""
|
||||
return f"Resets in: {r}" if r else "Resets in: —"
|
||||
|
||||
def _make_interval_item(seconds, label):
|
||||
return pystray.MenuItem(
|
||||
label,
|
||||
lambda _, s=seconds: app.on_set_interval(s),
|
||||
checked=lambda _, s=seconds: app.fetcher.interval == s,
|
||||
radio=True,
|
||||
)
|
||||
|
||||
interval_items = [_make_interval_item(s, l) for s, l in _INTERVALS]
|
||||
|
||||
return pystray.Menu(
|
||||
pystray.MenuItem(_five_hour_text, None, enabled=False),
|
||||
pystray.MenuItem(_five_hour_reset, None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(_seven_day_text, None, enabled=False),
|
||||
pystray.MenuItem(_seven_day_reset, None, enabled=False),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Refresh Now", lambda _: app.on_refresh()),
|
||||
pystray.MenuItem("Refresh Interval", pystray.Menu(*interval_items)),
|
||||
pystray.MenuItem("Session Key...", lambda _: app.on_session_key()),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Quit", lambda _: app.on_quit()),
|
||||
)
|
||||
124
claude_usage_widget/renderer.py
Normal file
124
claude_usage_widget/renderer.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Icon renderer: Claude starburst logo + circular usage arc."""
|
||||
|
||||
import math
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SIZE = 128
|
||||
CENTER = SIZE // 2
|
||||
ARC_WIDTH = 16
|
||||
ARC_RADIUS = (SIZE - ARC_WIDTH) // 2 # inner edge of arc
|
||||
|
||||
# Color thresholds for the usage arc (10% increments)
|
||||
_COLORS = [
|
||||
(10, "#4CAF50"), # green
|
||||
(20, "#43A047"), # green (darker)
|
||||
(30, "#7CB342"), # light green
|
||||
(40, "#C0CA33"), # lime
|
||||
(50, "#FDD835"), # yellow
|
||||
(60, "#FFC107"), # amber
|
||||
(70, "#FFB300"), # amber (darker)
|
||||
(80, "#FF9800"), # orange
|
||||
(90, "#FF5722"), # deep orange
|
||||
(100, "#F44336"), # red
|
||||
]
|
||||
|
||||
|
||||
def _hex_to_rgba(hex_color, alpha=255):
|
||||
h = hex_color.lstrip("#")
|
||||
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha)
|
||||
|
||||
|
||||
def _arc_color(pct):
|
||||
for threshold, color in _COLORS:
|
||||
if pct <= threshold:
|
||||
return color
|
||||
return _COLORS[-1][1]
|
||||
|
||||
|
||||
def _draw_starburst(img):
|
||||
"""Draw 8-petal starburst logo at 50% opacity."""
|
||||
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
num_petals = 8
|
||||
# Petal geometry: elongated ellipse approximated as polygon
|
||||
petal_length = 38
|
||||
petal_width = 10
|
||||
|
||||
for i in range(num_petals):
|
||||
angle = math.radians(i * (360 / num_petals))
|
||||
|
||||
# Build petal as 4-point diamond shape
|
||||
# Tip of petal (far from center)
|
||||
tip_x = CENTER + math.cos(angle) * petal_length
|
||||
tip_y = CENTER + math.sin(angle) * petal_length
|
||||
|
||||
# Base points (perpendicular to petal direction, near center)
|
||||
perp = angle + math.pi / 2
|
||||
base_offset = 6 # distance from center to base of petal
|
||||
bx = CENTER + math.cos(angle) * base_offset
|
||||
by = CENTER + math.sin(angle) * base_offset
|
||||
|
||||
left_x = bx + math.cos(perp) * (petal_width / 2)
|
||||
left_y = by + math.sin(perp) * (petal_width / 2)
|
||||
right_x = bx - math.cos(perp) * (petal_width / 2)
|
||||
right_y = by - math.sin(perp) * (petal_width / 2)
|
||||
|
||||
# Inner point (towards center, slight taper)
|
||||
inner_x = CENTER - math.cos(angle) * 2
|
||||
inner_y = CENTER - math.sin(angle) * 2
|
||||
|
||||
polygon = [(inner_x, inner_y), (left_x, left_y), (tip_x, tip_y), (right_x, right_y)]
|
||||
draw.polygon(polygon, fill=(255, 255, 255, 128))
|
||||
|
||||
# Center dot
|
||||
dot_r = 4
|
||||
draw.ellipse(
|
||||
[CENTER - dot_r, CENTER - dot_r, CENTER + dot_r, CENTER + dot_r],
|
||||
fill=(255, 255, 255, 128),
|
||||
)
|
||||
|
||||
img = Image.alpha_composite(img, layer)
|
||||
return img
|
||||
|
||||
|
||||
def _draw_arc(img, pct):
|
||||
"""Draw circular progress arc from 12 o'clock, clockwise."""
|
||||
if pct <= 0:
|
||||
return img
|
||||
|
||||
layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
color = _hex_to_rgba(_arc_color(pct))
|
||||
pct = min(pct, 100)
|
||||
|
||||
# Pillow arc: 0 = 3 o'clock, angles go counter-clockwise for arc()
|
||||
# We want: start at 12 o'clock (-90), sweep clockwise
|
||||
# Pillow draws counter-clockwise from start to end, so we use:
|
||||
# start = -90, end = -90 + (pct/100)*360
|
||||
start_angle = -90
|
||||
end_angle = -90 + (pct / 100) * 360
|
||||
|
||||
margin = ARC_WIDTH // 2
|
||||
bbox = [margin, margin, SIZE - margin, SIZE - margin]
|
||||
draw.arc(bbox, start_angle, end_angle, fill=color, width=ARC_WIDTH)
|
||||
|
||||
img = Image.alpha_composite(img, layer)
|
||||
return img
|
||||
|
||||
|
||||
def render_icon(pct):
|
||||
"""Render a 128x128 RGBA icon with starburst + usage arc.
|
||||
|
||||
Args:
|
||||
pct: Usage percentage (0-100). Arc is invisible at 0.
|
||||
|
||||
Returns:
|
||||
PIL.Image.Image (RGBA)
|
||||
"""
|
||||
img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
img = _draw_starburst(img)
|
||||
img = _draw_arc(img, pct)
|
||||
return img
|
||||
Reference in New Issue
Block a user