Initial commit
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
import sys
|
||||
import os
|
||||
import ctypes
|
||||
import math
|
||||
import random
|
||||
import json
|
||||
import datetime
|
||||
import traceback
|
||||
import requests
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from PySide6.QtGui import QPixmap, QPainter, QColor, QIcon, QFont
|
||||
|
||||
# Hide console window on Windows immediately if running as compiled executable
|
||||
def hide_console():
|
||||
try:
|
||||
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
||||
if hwnd:
|
||||
ctypes.windll.user32.ShowWindow(hwnd, 0) # SW_HIDE = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
hide_console()
|
||||
base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Redirect stdout and stderr to a log file
|
||||
log_path = os.path.join(base_dir, "widget_output.log")
|
||||
try:
|
||||
log_file = open(log_path, "a", encoding="utf-8")
|
||||
sys.stdout = log_file
|
||||
sys.stderr = log_file
|
||||
except Exception:
|
||||
class DummyWriter:
|
||||
def write(self, x): pass
|
||||
def flush(self): pass
|
||||
sys.stdout = DummyWriter()
|
||||
sys.stderr = DummyWriter()
|
||||
|
||||
# Weather descriptions in Russian
|
||||
WEATHER_DESCRIPTIONS = {
|
||||
0: "Ясно",
|
||||
1: "Преимущественно ясно",
|
||||
2: "Переменная облачность",
|
||||
3: "Пасмурно",
|
||||
45: "Туман",
|
||||
48: "Осаждающийся туман",
|
||||
51: "Слабая морось",
|
||||
53: "Умеренная морось",
|
||||
55: "Плотная морось",
|
||||
56: "Слабая ледяная морось",
|
||||
57: "Плотная ледяная морось",
|
||||
61: "Слабый дождь",
|
||||
63: "Умеренный дождь",
|
||||
65: "Сильный дождь",
|
||||
66: "Слабый ледяной дождь",
|
||||
67: "Сильный ледяной дождь",
|
||||
71: "Слабый снегопад",
|
||||
73: "Умеренный снегопад",
|
||||
75: "Сильный снегопад",
|
||||
77: "Снежные зерна",
|
||||
80: "Слабый ливневый дождь",
|
||||
81: "Умеренный ливневый дождь",
|
||||
82: "Сильный ливневый дождь",
|
||||
85: "Слабый снежный ливень",
|
||||
86: "Сильный снежный ливень",
|
||||
95: "Гроза",
|
||||
96: "Гроза со слабым градом",
|
||||
99: "Гроза с сильным градом"
|
||||
}
|
||||
|
||||
# Drawing helpers for weather elements
|
||||
def draw_cloud(painter, x, y, size, color, opacity=1.0):
|
||||
painter.save()
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
c = QColor(color)
|
||||
if opacity < 1.0:
|
||||
c.setAlpha(int(opacity * 255))
|
||||
painter.setBrush(QtGui.QBrush(c))
|
||||
|
||||
r1 = size * 0.28
|
||||
r2 = size * 0.38
|
||||
r3 = size * 0.28
|
||||
|
||||
painter.drawEllipse(x + size*0.05, y + size*0.25, r1*2, r1*2)
|
||||
painter.drawEllipse(x + size*0.2, y + size*0.05, r2*2, r2*2)
|
||||
painter.drawEllipse(x + size*0.48, y + size*0.25, r3*2, r3*2)
|
||||
painter.drawRoundedRect(x + size*0.1, y + size*0.3, size*0.55, size*0.26, size*0.13, size*0.13)
|
||||
painter.restore()
|
||||
|
||||
def draw_sun(painter, cx, cy, r, angle):
|
||||
painter.save()
|
||||
|
||||
# Center circle
|
||||
grad = QtGui.QLinearGradient(cx - r, cy - r, cx + r, cy + r)
|
||||
grad.setColorAt(0, QColor("#ffe259"))
|
||||
grad.setColorAt(1, QColor("#ffa751"))
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(grad)
|
||||
painter.drawEllipse(cx - r, cy - r, r*2, r*2)
|
||||
|
||||
# Rays
|
||||
painter.setPen(QtGui.QPen(QColor("#ffa751"), max(1.5, r * 0.18), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
|
||||
for i in range(8):
|
||||
deg = angle + i * 45
|
||||
rad = math.radians(deg)
|
||||
x1 = cx + (r * 1.28) * math.cos(rad)
|
||||
y1 = cy + (r * 1.28) * math.sin(rad)
|
||||
x2 = cx + (r * 1.58) * math.cos(rad)
|
||||
y2 = cy + (r * 1.58) * math.sin(rad)
|
||||
painter.drawLine(x1, y1, x2, y2)
|
||||
|
||||
painter.restore()
|
||||
|
||||
def draw_moon(painter, cx, cy, r, float_y):
|
||||
painter.save()
|
||||
path = QtGui.QPainterPath()
|
||||
path.addEllipse(cx - r, cy - r + float_y, r*2, r*2)
|
||||
|
||||
cut_path = QtGui.QPainterPath()
|
||||
cut_path.addEllipse(cx - r * 0.3, cy - r + float_y, r*2, r*2)
|
||||
|
||||
moon_path = path.subtracted(cut_path)
|
||||
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#e2e8f0"))
|
||||
painter.drawPath(moon_path)
|
||||
painter.restore()
|
||||
|
||||
# Vector-animated custom weather icon widget
|
||||
class AnimatedWeatherIcon(QtWidgets.QWidget):
|
||||
def __init__(self, parent=None, size=48, auto_start=True):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(size, size)
|
||||
self.size_val = size
|
||||
self.weather_code = 3
|
||||
self.is_day = True
|
||||
self.time_val = random.random() * 100.0 # offset start to desynchronize clouds
|
||||
self.sun_angle = random.randint(0, 360)
|
||||
|
||||
# Slanted Rain drop offsets
|
||||
self.rain_offsets = [0.0, 5.0, 10.0]
|
||||
# Snow flake offsets
|
||||
self.snow_offsets = [0.0, 6.0, 3.0]
|
||||
|
||||
self.lightning_timer = 0
|
||||
self.lightning_visible = False
|
||||
|
||||
self.timer = QtCore.QTimer(self)
|
||||
self.timer.timeout.connect(self.animate)
|
||||
if auto_start:
|
||||
self.timer.start(33)
|
||||
|
||||
def set_weather(self, code, is_day):
|
||||
self.weather_code = code
|
||||
self.is_day = is_day
|
||||
self.update()
|
||||
|
||||
def start_animation(self):
|
||||
if not self.timer.isActive():
|
||||
self.timer.start(33)
|
||||
|
||||
def stop_animation(self):
|
||||
if self.timer.isActive():
|
||||
self.timer.stop()
|
||||
|
||||
def animate(self):
|
||||
self.time_val += 0.06
|
||||
self.sun_angle = (self.sun_angle + 0.8) % 360.0
|
||||
|
||||
# Rain animation
|
||||
for i in range(len(self.rain_offsets)):
|
||||
self.rain_offsets[i] += 0.5
|
||||
if self.rain_offsets[i] > 12.0:
|
||||
self.rain_offsets[i] = 0.0
|
||||
|
||||
# Snow animation
|
||||
for i in range(len(self.snow_offsets)):
|
||||
self.snow_offsets[i] += 0.3
|
||||
if self.snow_offsets[i] > 12.0:
|
||||
self.snow_offsets[i] = 0.0
|
||||
|
||||
# Lightning flash animation
|
||||
self.lightning_timer += 1
|
||||
if self.lightning_visible:
|
||||
if self.lightning_timer > 3:
|
||||
self.lightning_visible = False
|
||||
self.lightning_timer = 0
|
||||
else:
|
||||
if self.lightning_timer > 80 and random.random() < 0.04:
|
||||
self.lightning_visible = True
|
||||
self.lightning_timer = 0
|
||||
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
w = self.width()
|
||||
h = self.height()
|
||||
cx = w / 2
|
||||
cy = h / 2
|
||||
|
||||
code = self.weather_code
|
||||
|
||||
if code == 0:
|
||||
weather_type = "clear"
|
||||
elif code in (1, 2):
|
||||
weather_type = "partly_cloudy"
|
||||
elif code == 3:
|
||||
weather_type = "cloudy"
|
||||
elif code in (45, 48):
|
||||
weather_type = "fog"
|
||||
elif code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82):
|
||||
weather_type = "rain"
|
||||
elif code in (71, 73, 75, 77, 85, 86):
|
||||
weather_type = "snow"
|
||||
elif code in (95, 96, 99):
|
||||
weather_type = "storm"
|
||||
else:
|
||||
weather_type = "cloudy"
|
||||
|
||||
float_y = math.sin(self.time_val) * (w * 0.05)
|
||||
|
||||
if weather_type == "clear":
|
||||
if self.is_day:
|
||||
draw_sun(painter, cx, cy, w * 0.28, self.sun_angle)
|
||||
else:
|
||||
draw_moon(painter, cx, cy, w * 0.28, float_y)
|
||||
|
||||
elif weather_type == "partly_cloudy":
|
||||
if self.is_day:
|
||||
draw_sun(painter, cx + w*0.14, cy - h*0.12, w * 0.22, self.sun_angle)
|
||||
else:
|
||||
draw_moon(painter, cx + w*0.14, cy - h*0.12, w * 0.2, float_y)
|
||||
draw_cloud(painter, cx - w*0.3, cy - h*0.2 + float_y, w * 0.64, "#e0f2fe")
|
||||
|
||||
elif weather_type == "cloudy":
|
||||
draw_cloud(painter, cx - w*0.14, cy - h*0.28 + float_y*0.5, w * 0.58, "#bae6fd", opacity=0.8)
|
||||
draw_cloud(painter, cx - w*0.3, cy - h*0.18 + float_y, w * 0.66, "#ffffff")
|
||||
|
||||
elif weather_type == "fog":
|
||||
draw_cloud(painter, cx - w*0.24, cy - h*0.26 + float_y, w * 0.6, "#e0f2fe", opacity=0.8)
|
||||
painter.save()
|
||||
painter.setPen(QtGui.QPen(QColor("#bae6fd"), max(1.5, w * 0.06), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
|
||||
drift = math.sin(self.time_val * 0.6) * (w * 0.06)
|
||||
painter.drawLine(cx - w*0.3 + drift, cy + h*0.18, cx + w*0.3 + drift, cy + h*0.18)
|
||||
painter.drawLine(cx - w*0.2 - drift, cy + h*0.28, cx + w*0.2 - drift, cy + h*0.28)
|
||||
painter.restore()
|
||||
|
||||
elif weather_type == "rain":
|
||||
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#bae6fd")
|
||||
painter.save()
|
||||
painter.setPen(QtGui.QPen(QColor("#38bdf8"), max(1.5, w * 0.05), QtCore.Qt.PenStyle.SolidLine, QtCore.Qt.PenCapStyle.RoundCap))
|
||||
for i, offset in enumerate(self.rain_offsets):
|
||||
rx = cx - w*0.15 + i * (w*0.15)
|
||||
ry = cy + h*0.14 + offset
|
||||
if ry < cy + h*0.42:
|
||||
painter.drawLine(rx, ry, rx - w*0.03, ry + h*0.12)
|
||||
painter.restore()
|
||||
|
||||
elif weather_type == "snow":
|
||||
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#e0f2fe")
|
||||
painter.save()
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#38bdf8"))
|
||||
for i, offset in enumerate(self.snow_offsets):
|
||||
sx = cx - w*0.15 + i * (w*0.15) + math.sin(self.time_val + i) * (w * 0.03)
|
||||
sy = cy + h*0.14 + offset
|
||||
if sy < cy + h*0.42:
|
||||
painter.drawEllipse(sx - w*0.04, sy - w*0.04, w*0.08, w*0.08)
|
||||
painter.restore()
|
||||
|
||||
elif weather_type == "storm":
|
||||
draw_cloud(painter, cx - w*0.3, cy - h*0.25 + float_y, w * 0.64, "#94a3b8")
|
||||
if self.lightning_visible:
|
||||
painter.save()
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#fbbf24"))
|
||||
scale = w / 48.0
|
||||
points = [
|
||||
QtCore.QPoint(cx - 2*scale, cy + 6*scale),
|
||||
QtCore.QPoint(cx - 10*scale, cy + 18*scale),
|
||||
QtCore.QPoint(cx - 3*scale, cy + 18*scale),
|
||||
QtCore.QPoint(cx - 7*scale, cy + 30*scale),
|
||||
QtCore.QPoint(cx + 5*scale, cy + 14*scale),
|
||||
QtCore.QPoint(cx - 1*scale, cy + 14*scale),
|
||||
]
|
||||
painter.drawPolygon(points)
|
||||
painter.restore()
|
||||
|
||||
# Color themes list: Background gradients + Borders
|
||||
THEMES = [
|
||||
# Theme 0: Deep Violet/Indigo (Default)
|
||||
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #121026, stop:1 #1c183a); border: 1px solid rgba(255, 255, 255, 0.08);",
|
||||
# Theme 1: Deep Navy Blue
|
||||
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #09111e, stop:1 #14244a); border: 1px solid rgba(255, 255, 255, 0.08);",
|
||||
# Theme 2: Forest Emerald Green
|
||||
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #04160f, stop:1 #0c2d1e); border: 1px solid rgba(255, 255, 255, 0.08);",
|
||||
# Theme 3: Dark Ruby Red
|
||||
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #1b0707, stop:1 #351212); border: 1px solid rgba(255, 255, 255, 0.08);",
|
||||
# Theme 4: Sleek Charcoal Black
|
||||
"background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #121212, stop:1 #242424); border: 1px solid rgba(255, 255, 255, 0.08);"
|
||||
]
|
||||
|
||||
class WeatherWidget(QtWidgets.QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.drag_position = QtCore.QPoint()
|
||||
self.press_pos = QtCore.QPoint()
|
||||
self.press_time = datetime.datetime.now()
|
||||
self.expanded = False
|
||||
self.theme_index = 0
|
||||
|
||||
self.init_ui()
|
||||
self.load_config()
|
||||
|
||||
# Setup tray icon
|
||||
self.tray_icon = QtWidgets.QSystemTrayIcon(self)
|
||||
self.tray_icon.setIcon(self.create_tray_icon())
|
||||
|
||||
tray_menu = QtWidgets.QMenu()
|
||||
refresh_action = tray_menu.addAction("Обновить")
|
||||
refresh_action.triggered.connect(self.update_weather)
|
||||
exit_action = tray_menu.addAction("Выход")
|
||||
exit_action.triggered.connect(self.close_app)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
self.tray_icon.show()
|
||||
|
||||
# Setup update timer (10 minutes)
|
||||
self.timer = QtCore.QTimer(self)
|
||||
self.timer.timeout.connect(self.update_weather)
|
||||
self.timer.start(10 * 60 * 1000)
|
||||
|
||||
# Setup startup fade-in animation
|
||||
self.setWindowOpacity(0.0)
|
||||
self.fade_in_animation = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||||
self.fade_in_animation.setDuration(400)
|
||||
self.fade_in_animation.setStartValue(0.0)
|
||||
self.fade_in_animation.setEndValue(1.0)
|
||||
self.fade_in_animation.start()
|
||||
|
||||
# Initial weather load
|
||||
self.update_weather()
|
||||
|
||||
def get_config_path(self):
|
||||
if getattr(sys, 'frozen', False):
|
||||
base_dir = os.path.dirname(sys.executable)
|
||||
else:
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(base_dir, "widget_config.json")
|
||||
|
||||
def save_config(self):
|
||||
try:
|
||||
config = {
|
||||
"x": self.x(),
|
||||
"y": self.y(),
|
||||
"theme_index": self.theme_index
|
||||
}
|
||||
with open(self.get_config_path(), "w") as f:
|
||||
json.dump(config, f)
|
||||
except Exception as e:
|
||||
print(f"Error saving config: {e}")
|
||||
|
||||
def load_config(self):
|
||||
# Balanced proportions: width 280 x height 102
|
||||
widget_width = 280
|
||||
widget_height = 102
|
||||
|
||||
screen = QtWidgets.QApplication.primaryScreen()
|
||||
screen_geom = screen.availableGeometry()
|
||||
|
||||
# Default position: bottom-right
|
||||
default_x = screen_geom.right() - widget_width - 20
|
||||
default_y = screen_geom.bottom() - widget_height - 20
|
||||
|
||||
config_path = self.get_config_path()
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
x = config.get("x", default_x)
|
||||
y = config.get("y", default_y)
|
||||
self.theme_index = config.get("theme_index", 0)
|
||||
|
||||
# Check if position is within current screen boundaries
|
||||
if 0 <= x < screen_geom.right() and 0 <= y < screen_geom.bottom():
|
||||
self.setGeometry(x, y, widget_width, widget_height)
|
||||
self.apply_theme()
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error loading config: {e}")
|
||||
|
||||
self.setGeometry(default_x, default_y, widget_width, widget_height)
|
||||
self.apply_theme()
|
||||
|
||||
def apply_theme(self):
|
||||
theme_style = THEMES[self.theme_index]
|
||||
self.container.setStyleSheet(f"""
|
||||
#container {{
|
||||
{theme_style}
|
||||
border-radius: 22px;
|
||||
}}
|
||||
#container:hover {{
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}}
|
||||
QLabel {{
|
||||
color: #ffffff;
|
||||
}}
|
||||
""")
|
||||
|
||||
def cycle_theme(self):
|
||||
self.theme_index = (self.theme_index + 1) % len(THEMES)
|
||||
self.apply_theme()
|
||||
self.save_config()
|
||||
|
||||
def create_tray_icon(self):
|
||||
# Dynamically draw a tray icon (stylized clouds/sun)
|
||||
pixmap = QPixmap(16, 16)
|
||||
pixmap.fill(QtCore.Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
# Background cloud circles
|
||||
painter.setBrush(QColor("#38bdf8"))
|
||||
painter.drawEllipse(1, 6, 8, 8)
|
||||
painter.drawEllipse(6, 4, 9, 9)
|
||||
painter.drawEllipse(4, 2, 8, 8)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
def init_ui(self):
|
||||
# Frameless, non-always-on-top, tool window (no taskbar button)
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.WindowType.FramelessWindowHint |
|
||||
QtCore.Qt.WindowType.Tool |
|
||||
QtCore.Qt.WindowType.Window
|
||||
)
|
||||
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
||||
|
||||
# Outer layout to provide space for the drop shadow
|
||||
outer_layout = QtWidgets.QVBoxLayout(self)
|
||||
outer_layout.setContentsMargins(12, 12, 12, 12)
|
||||
|
||||
# Container frame
|
||||
self.container = QtWidgets.QFrame()
|
||||
self.container.setObjectName("container")
|
||||
self.apply_theme()
|
||||
|
||||
# Soft Drop Shadow
|
||||
shadow = QtWidgets.QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(15)
|
||||
shadow.setXOffset(0)
|
||||
shadow.setYOffset(4)
|
||||
shadow.setColor(QColor(0, 0, 0, 120))
|
||||
self.container.setGraphicsEffect(shadow)
|
||||
|
||||
# Layout inside container (QVBoxLayout to hold current weather + divider + forecast)
|
||||
container_layout = QtWidgets.QVBoxLayout(self.container)
|
||||
container_layout.setContentsMargins(16, 8, 16, 8)
|
||||
container_layout.setSpacing(6)
|
||||
|
||||
# Top Row: Current weather layout (QHBoxLayout)
|
||||
top_layout = QtWidgets.QHBoxLayout()
|
||||
top_layout.setSpacing(12)
|
||||
|
||||
# Left Side: Weather Icon (beautiful vector-animated)
|
||||
self.icon_label = AnimatedWeatherIcon(self.container, size=48, auto_start=True)
|
||||
top_layout.addWidget(self.icon_label, alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Right Side: Content
|
||||
right_layout = QtWidgets.QVBoxLayout()
|
||||
right_layout.setSpacing(0)
|
||||
|
||||
# Row 1: Updated Time (aligned to top-right)
|
||||
time_layout = QtWidgets.QHBoxLayout()
|
||||
time_layout.addStretch()
|
||||
self.update_time_label = QtWidgets.QLabel("обновление...")
|
||||
self.update_time_label.setFont(QFont("Segoe UI", 7))
|
||||
self.update_time_label.setStyleSheet("color: rgba(255, 255, 255, 0.45);")
|
||||
time_layout.addWidget(self.update_time_label)
|
||||
right_layout.addLayout(time_layout)
|
||||
|
||||
# Row 2: Temperature
|
||||
self.temp_label = QtWidgets.QLabel("--°")
|
||||
self.temp_label.setFont(QFont("Segoe UI", 24, QFont.Weight.Bold))
|
||||
self.temp_label.setStyleSheet("line-height: 1;")
|
||||
right_layout.addWidget(self.temp_label)
|
||||
|
||||
# Row 3: Condition text
|
||||
self.condition_label = QtWidgets.QLabel("Загрузка...")
|
||||
self.condition_label.setFont(QFont("Segoe UI", 9.5, QFont.Weight.DemiBold))
|
||||
self.condition_label.setStyleSheet("color: rgba(255, 255, 255, 0.9);")
|
||||
right_layout.addWidget(self.condition_label)
|
||||
|
||||
# Row 4: Detailed info (apparent, wind, humidity)
|
||||
self.details_label = QtWidgets.QLabel("ощущ. --° | -- м/с | --%")
|
||||
self.details_label.setFont(QFont("Segoe UI", 8))
|
||||
self.details_label.setStyleSheet("color: rgba(255, 255, 255, 0.55);")
|
||||
right_layout.addWidget(self.details_label)
|
||||
|
||||
top_layout.addLayout(right_layout)
|
||||
container_layout.addLayout(top_layout)
|
||||
|
||||
# Divider Line
|
||||
self.divider = QtWidgets.QFrame()
|
||||
self.divider.setFrameShape(QtWidgets.QFrame.Shape.HLine)
|
||||
self.divider.setStyleSheet("background-color: rgba(255, 255, 255, 0.1); max-height: 1px; border: none; margin-top: 2px; margin-bottom: 2px;")
|
||||
container_layout.addWidget(self.divider)
|
||||
|
||||
# Forecast Panel
|
||||
self.forecast_panel = QtWidgets.QWidget()
|
||||
self.forecast_panel.setObjectName("forecast_panel")
|
||||
forecast_layout = QtWidgets.QHBoxLayout(self.forecast_panel)
|
||||
forecast_layout.setContentsMargins(0, 4, 0, 4)
|
||||
forecast_layout.setSpacing(8)
|
||||
|
||||
self.forecast_days = []
|
||||
for i in range(3):
|
||||
# Premium Glassmorphic cards for forecast ("нано-банану")
|
||||
day_widget = QtWidgets.QFrame()
|
||||
day_widget.setObjectName("forecast_card")
|
||||
day_widget.setStyleSheet("""
|
||||
#forecast_card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
#forecast_card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
""")
|
||||
|
||||
day_widget_layout = QtWidgets.QVBoxLayout(day_widget)
|
||||
day_widget_layout.setContentsMargins(4, 6, 4, 6)
|
||||
day_widget_layout.setSpacing(2)
|
||||
day_widget_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
day_label = QtWidgets.QLabel("---")
|
||||
day_label.setFont(QFont("Segoe UI", 8, QFont.Weight.Bold))
|
||||
day_label.setStyleSheet("color: rgba(255, 255, 255, 0.6);")
|
||||
day_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# Forecast icon (stops drawing CPU when collapsed)
|
||||
icon_widget = AnimatedWeatherIcon(day_widget, size=32, auto_start=False)
|
||||
|
||||
temp_label = QtWidgets.QLabel("--° / --°")
|
||||
temp_label.setFont(QFont("Segoe UI", 7.5))
|
||||
temp_label.setStyleSheet("color: rgba(255, 255, 255, 0.95);")
|
||||
temp_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
day_widget_layout.addWidget(day_label)
|
||||
day_widget_layout.addWidget(icon_widget, alignment=QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
day_widget_layout.addWidget(temp_label)
|
||||
|
||||
forecast_layout.addWidget(day_widget)
|
||||
self.forecast_days.append({
|
||||
"day_label": day_label,
|
||||
"icon_label": icon_widget,
|
||||
"temp_label": temp_label
|
||||
})
|
||||
|
||||
container_layout.addWidget(self.forecast_panel)
|
||||
|
||||
# Set forecast initial state
|
||||
self.forecast_panel.setVisible(False)
|
||||
self.divider.setVisible(False)
|
||||
|
||||
outer_layout.addWidget(self.container)
|
||||
|
||||
# Mouse dragging and closing implementation
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.MouseButton.RightButton:
|
||||
self.close_app()
|
||||
event.accept()
|
||||
elif event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||||
self.press_pos = event.globalPosition().toPoint()
|
||||
self.press_time = datetime.datetime.now()
|
||||
self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons() == QtCore.Qt.MouseButton.LeftButton:
|
||||
self.move(event.globalPosition().toPoint() - self.drag_position)
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||||
self.save_config()
|
||||
# Detect left click for expand/collapse
|
||||
release_pos = event.globalPosition().toPoint()
|
||||
distance = (release_pos - self.press_pos).manhattanLength()
|
||||
duration = (datetime.datetime.now() - self.press_time).total_seconds()
|
||||
|
||||
if distance < 5 and duration < 0.25:
|
||||
self.toggle_expand()
|
||||
event.accept()
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
||||
self.cycle_theme()
|
||||
event.accept()
|
||||
|
||||
def toggle_expand(self):
|
||||
self.expanded = not self.expanded
|
||||
|
||||
# Animate geometry height change (collapsed 102 -> expanded 192)
|
||||
start_geom = self.geometry()
|
||||
target_height = 192 if self.expanded else 102
|
||||
end_geom = QtCore.QRect(start_geom.x(), start_geom.y(), start_geom.width(), target_height)
|
||||
|
||||
self.resize_anim = QtCore.QPropertyAnimation(self, b"geometry")
|
||||
self.resize_anim.setDuration(220)
|
||||
self.resize_anim.setEasingCurve(QtCore.QEasingCurve.Type.InOutQuad)
|
||||
self.resize_anim.setStartValue(start_geom)
|
||||
self.resize_anim.setEndValue(end_geom)
|
||||
|
||||
if self.expanded:
|
||||
# Show widgets and start forecast animations
|
||||
self.divider.setVisible(True)
|
||||
self.forecast_panel.setVisible(True)
|
||||
for item in self.forecast_days:
|
||||
item["icon_label"].start_animation()
|
||||
self.resize_anim.start()
|
||||
else:
|
||||
# Hide forecast elements FIRST to release size constraints, allowing successful window shrink
|
||||
self.divider.setVisible(False)
|
||||
self.forecast_panel.setVisible(False)
|
||||
for item in self.forecast_days:
|
||||
item["icon_label"].stop_animation()
|
||||
self.resize_anim.start()
|
||||
|
||||
# Update weather fetch (with fade transition)
|
||||
def update_weather(self):
|
||||
self.fade_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||||
self.fade_anim.setDuration(250)
|
||||
self.fade_anim.setStartValue(self.windowOpacity())
|
||||
self.fade_anim.setEndValue(0.3)
|
||||
|
||||
def on_fade_out():
|
||||
self.perform_weather_fetch()
|
||||
self.fade_in_anim = QtCore.QPropertyAnimation(self, b"windowOpacity")
|
||||
self.fade_in_anim.setDuration(250)
|
||||
self.fade_in_anim.setStartValue(0.3)
|
||||
self.fade_in_anim.setEndValue(1.0)
|
||||
self.fade_in_anim.start()
|
||||
|
||||
self.fade_anim.finished.connect(on_fade_out)
|
||||
self.fade_anim.start()
|
||||
|
||||
def perform_weather_fetch(self):
|
||||
# API URL for Krasnoyarsk coordinates with daily variables included
|
||||
url = "https://api.open-meteo.com/v1/forecast?latitude=56.0184&longitude=92.8672¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&wind_speed_unit=ms&timezone=Asia%2FKrasnoyarsk"
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
current = data.get("current", {})
|
||||
|
||||
# Current Temperature
|
||||
temp = round(current.get("temperature_2m", 0))
|
||||
temp_str = f"+{temp}°" if temp > 0 else f"{temp}°"
|
||||
self.temp_label.setText(temp_str)
|
||||
|
||||
# Current Weather code & description
|
||||
code = current.get("weather_code", 3)
|
||||
condition = WEATHER_DESCRIPTIONS.get(code, "Пасмурно")
|
||||
self.condition_label.setText(condition)
|
||||
|
||||
# Current Apparent temp, wind, humidity
|
||||
app_temp = round(current.get("apparent_temperature", 0))
|
||||
app_temp_str = f"+{app_temp}°" if app_temp > 0 else f"{app_temp}°"
|
||||
wind = round(current.get("wind_speed_10m", 0))
|
||||
humidity = current.get("relative_humidity_2m", 0)
|
||||
|
||||
self.details_label.setText(f"ощущ. {app_temp_str} | {wind} м/с | {humidity}%")
|
||||
|
||||
# Current Animated Weather Icon
|
||||
is_day = current.get("is_day", 1) == 1
|
||||
self.icon_label.set_weather(code, is_day)
|
||||
|
||||
# Last updated timestamp
|
||||
now = datetime.datetime.now()
|
||||
self.update_time_label.setText(f"обновлено {now.strftime('%H:%M')}")
|
||||
|
||||
# Parse 3-day forecast (indices 1, 2, 3 in daily data)
|
||||
daily = data.get("daily", {})
|
||||
daily_times = daily.get("time", [])
|
||||
daily_codes = daily.get("weather_code", [])
|
||||
daily_maxs = daily.get("temperature_2m_max", [])
|
||||
daily_mins = daily.get("temperature_2m_min", [])
|
||||
|
||||
for i in range(1, 4):
|
||||
if i < len(daily_times):
|
||||
date_str = daily_times[i]
|
||||
f_code = daily_codes[i]
|
||||
max_temp = round(daily_maxs[i])
|
||||
min_temp = round(daily_mins[i])
|
||||
|
||||
# Get weekday name in Russian
|
||||
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
|
||||
weekdays_ru = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
day_name = weekdays_ru[dt.weekday()]
|
||||
|
||||
# Format forecast temps
|
||||
max_str = f"+{max_temp}°" if max_temp > 0 else f"{max_temp}°"
|
||||
min_str = f"+{min_temp}°" if min_temp > 0 else f"{min_temp}°"
|
||||
|
||||
# Apply to forecast labels
|
||||
self.forecast_days[i-1]["day_label"].setText(day_name)
|
||||
self.forecast_days[i-1]["temp_label"].setText(f"{max_str} / {min_str}")
|
||||
self.forecast_days[i-1]["icon_label"].set_weather(f_code, True)
|
||||
else:
|
||||
self.set_offline_status("Ошибка сервера")
|
||||
except Exception as e:
|
||||
print(f"Fetch error: {e}")
|
||||
self.set_offline_status("Нет сети")
|
||||
|
||||
def set_offline_status(self, message):
|
||||
self.condition_label.setText(message)
|
||||
self.temp_label.setText("--°")
|
||||
self.details_label.setText("ощущ. --° | -- м/с | --%")
|
||||
self.icon_label.set_weather(3, True) # Show cloud
|
||||
for item in self.forecast_days:
|
||||
item["day_label"].setText("---")
|
||||
item["temp_label"].setText("--° / --°")
|
||||
item["icon_label"].set_weather(3, True)
|
||||
|
||||
def close_app(self):
|
||||
self.tray_icon.hide()
|
||||
QtWidgets.QApplication.quit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
# Ensure app doesn't close when main window is hidden/minimized
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
widget = WeatherWidget()
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
Reference in New Issue
Block a user