115 lines
3.6 KiB
Python
115 lines
3.6 KiB
Python
"""Иконка tray: загрузка icon.ico или синтез буквы «T» (Pillow), бейдж статуса."""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Any, List, Tuple
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
def _resample_lanczos() -> int:
|
||
try:
|
||
return Image.Resampling.LANCZOS # type: ignore[attr-defined]
|
||
except AttributeError:
|
||
return Image.LANCZOS
|
||
|
||
|
||
def normalize_tray_icon_image(img: Image.Image, size: int = 64) -> Image.Image:
|
||
"""RGBA, единый размер (для стабильного бейджа). ICO — кадр с максимальной площадью."""
|
||
im = img
|
||
try:
|
||
n = getattr(im, "n_frames", 1)
|
||
if n > 1:
|
||
best: Image.Image | None = None
|
||
best_area = 0
|
||
for i in range(n):
|
||
im.seek(i)
|
||
w, h = im.size
|
||
a = w * h
|
||
if a > best_area:
|
||
best_area = a
|
||
best = im.copy()
|
||
im = best if best is not None else im.copy()
|
||
else:
|
||
im = im.copy()
|
||
except Exception:
|
||
im = img.copy()
|
||
im = im.convert("RGBA")
|
||
if im.size != (size, size):
|
||
im = im.resize((size, size), _resample_lanczos())
|
||
return im
|
||
|
||
|
||
def badge_rgb_for_phase(phase: str) -> Tuple[int, int, int]:
|
||
"""Зелёный — слушает, красный — ошибка, жёлтый — остальное."""
|
||
if phase == "listening":
|
||
return (34, 197, 94)
|
||
if phase == "error":
|
||
return (239, 68, 68)
|
||
return (234, 179, 8)
|
||
|
||
|
||
def apply_status_badge(base: Image.Image, phase: str) -> Image.Image:
|
||
"""Круглый индикатор внизу справа (как бейдж уведомления)."""
|
||
img = base.copy()
|
||
if img.mode != "RGBA":
|
||
img = img.convert("RGBA")
|
||
w, h = img.size
|
||
rgb = badge_rgb_for_phase(phase)
|
||
r = max(4, min(w, h) // 7)
|
||
margin = max(1, min(w, h) // 18)
|
||
cx = w - margin - r
|
||
cy = h - margin - r
|
||
draw = ImageDraw.Draw(img)
|
||
# лёгкая тень для контраста на светлой панели
|
||
draw.ellipse(
|
||
[cx - r + 1, cy - r + 1, cx + r + 1, cy + r + 1],
|
||
fill=(0, 0, 0, 70),
|
||
)
|
||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgb + (255,))
|
||
border_w = max(1, r // 5)
|
||
draw.ellipse(
|
||
[cx - r, cy - r, cx + r, cy + r],
|
||
outline=(255, 255, 255, 230),
|
||
width=border_w,
|
||
)
|
||
return img
|
||
|
||
|
||
def _pick_font(size: int, candidates: List[str]) -> Any:
|
||
for path in candidates:
|
||
try:
|
||
return ImageFont.truetype(path, size=int(size * 0.55))
|
||
except Exception:
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def synthesize_letter_t_icon(size: int, font_candidates: List[str]) -> Image.Image:
|
||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
margin = 2
|
||
draw.ellipse(
|
||
[margin, margin, size - margin, size - margin],
|
||
fill=(0, 136, 204, 255),
|
||
)
|
||
font = _pick_font(size, font_candidates)
|
||
bbox = draw.textbbox((0, 0), "T", font=font)
|
||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||
tx = (size - tw) // 2 - bbox[0]
|
||
ty = (size - th) // 2 - bbox[1]
|
||
draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font)
|
||
return img
|
||
|
||
|
||
def load_ico_or_synthesize(
|
||
ico_path: Path,
|
||
font_candidates: List[str],
|
||
size: int = 64,
|
||
) -> Image.Image:
|
||
if ico_path.exists():
|
||
try:
|
||
return Image.open(str(ico_path))
|
||
except Exception:
|
||
pass
|
||
return synthesize_letter_t_icon(size, font_candidates)
|