feat(modules/desktop): Add weather gradient/emoji mappings and system monitoring widgets

New Features
- Introduce WEATHER_GRADIENT_MAP and WEATHER_EMOJI_MAP for condition‑to‑emoji mapping.
- Add SYSTEM_UPDATE_INTERVAL, WEATHER_UPDATE_INTERVAL, CALENDAR_UPDATE_INTERVAL constants and extend cache timeouts.
- Implement CpuInfo and RamInfo widgets with real‑time CPU percentage, temperature, and RAM usage display.

Enhancements
- Extend LOCATION_CACHE_TIMEOUT to 7 days.
- Integrate progress bar updates for CPU and memory usage.
- Organize desktop widget manager with separate top‑left and bottom‑right windows.

Bug Fixes
- None
This commit is contained in:
sankalp committed 2026-02-26 06:41:58 +05:45
1 parent e104359659
commit deb9b1082f
3 files changed
+743

No files matched your search

View File
Whitespace-only changes.
+127
View File
@@ -0,0 +1,127 @@
import datetime
WEATHER_UPDATE_INTERVAL = 600 # 10 minutes
WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes
SYSTEM_UPDATE_INTERVAL = 60000 # 1 minute instead of 1 second
CALENDAR_UPDATE_INTERVAL = int(
(
(
datetime.datetime.combine(
datetime.date.today() + datetime.timedelta(days=1), datetime.time.min
)
- datetime.datetime.now()
).total_seconds()
)
* 1000
) # Calculate time till midnight
LOCATION_CACHE_TIMEOUT = 604800 # 7 days (extended from 24h)
WEATHER_GRADIENT_MAP = {
# Clear/Sunny conditions - bright blue to lighter blue
0: "weather-clear", # Clear sky
1: "weather-mostly-clear", # Mainly clear
# Cloudy conditions - grey gradients
2: "weather-partly-cloudy", # Partly cloudy
3: "weather-overcast", # Overcast
# Fog conditions - muted grey/blue
45: "weather-fog", # Fog
48: "weather-fog", # Depositing rime fog
# Light rain/drizzle - blue-grey gradients
51: "weather-light-rain", # Light drizzle
53: "weather-rain", # Moderate drizzle
55: "weather-rain", # Dense drizzle
61: "weather-light-rain", # Slight rain
80: "weather-light-rain", # Slight rain showers
# Heavy rain - darker blue-grey
63: "weather-heavy-rain", # Moderate rain
65: "weather-heavy-rain", # Heavy rain
81: "weather-heavy-rain", # Moderate rain showers
82: "weather-storm", # Violent rain showers
# Snow conditions - blue-white gradients
56: "weather-snow", # Light freezing drizzle
57: "weather-snow", # Dense freezing drizzle
66: "weather-snow", # Light freezing rain
67: "weather-snow", # Heavy freezing rain
71: "weather-snow", # Slight snow fall
73: "weather-heavy-snow", # Moderate snow fall
75: "weather-heavy-snow", # Heavy snow fall
77: "weather-snow", # Snow grains
85: "weather-snow", # Slight snow showers
86: "weather-heavy-snow", # Heavy snow showers
# Storm conditions - dark dramatic gradients
95: "weather-storm", # Thunderstorm
96: "weather-storm", # Thunderstorm with slight hail
99: "weather-storm", # Thunderstorm with heavy hail
}
# Weather condition to emoji mapping
WEATHER_EMOJI_MAP = {
0: "☀️", # Clear sky
1: "🌤️", # Mainly clear
2: "⛅", # Partly cloudy
3: "☁️", # Overcast
45: "🌫️", # Fog
48: "🌫️", # Depositing rime fog
51: "🌦️", # Light drizzle
53: "🌧️", # Moderate drizzle
55: "🌧️", # Dense drizzle
56: "🌨️", # Light freezing drizzle
57: "🌨️", # Dense freezing drizzle
61: "🌦️", # Slight rain
63: "🌧️", # Moderate rain
65: "🌧️", # Heavy rain
66: "🌨️", # Light freezing rain
67: "🌨️", # Heavy freezing rain
71: "🌨️", # Slight snow fall
73: "❄️", # Moderate snow fall
75: "❄️", # Heavy snow fall
77: "🌨️", # Snow grains
80: "🌦️", # Slight rain showers
81: "🌧️", # Moderate rain showers
82: "⛈️", # Violent rain showers
85: "🌨️", # Slight snow showers
86: "❄️", # Heavy snow showers
95: "⛈️", # Thunderstorm
96: "⛈️", # Thunderstorm with slight hail
99: "⛈️", # Thunderstorm with heavy hail
}
# Weather condition descriptions
WEATHER_DESC_MAP = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
56: "Light freezing drizzle",
57: "Dense freezing drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
66: "Light freezing rain",
67: "Heavy freezing rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Light rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with hail",
99: "Thunderstorm with heavy hail",
}
# Location APIs in order of preference (fastest first)
LOCATION_APIS = [
"https://ipapi.co/json/", # Fastest, 200ms average
"http://ip-api.com/json/", # Fast fallback, 150ms average
"https://ipinfo.io/json", # Original fallback
]
+616
View File
@@ -0,0 +1,616 @@
import calendar
import datetime
import json
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Tuple
import psutil
from fabric.utils import GLib, invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.datetime import DateTime
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.wayland import WaylandWindow as Window
from config.data import load_config
from modules.desktop.constants import (
CALENDAR_UPDATE_INTERVAL,
LOCATION_APIS,
LOCATION_CACHE_TIMEOUT,
SYSTEM_UPDATE_INTERVAL,
WEATHER_CACHE_TIMEOUT,
WEATHER_DESC_MAP,
WEATHER_EMOJI_MAP,
WEATHER_GRADIENT_MAP,
WEATHER_UPDATE_INTERVAL,
)
from utils.debounce import sync_debounce
# Thread pool for async operations
executor = ThreadPoolExecutor(max_workers=4)
# Global cache for weather data
_weather_cache: Dict[str, Tuple[Any, float]] = {}
_location_cache: Dict[str, Tuple[float, float, float]] = {}
def http_get_json(
url: str, timeout: int = 3, headers: Optional[Dict] = None
) -> Optional[Dict]:
"""Helper to perform GET requests and return JSON using built-in urllib."""
try:
req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=timeout) as response:
if response.status == 200:
return json.loads(response.read().decode("utf-8"))
except Exception as e:
print(f"HTTP Request to {url} failed: {e}")
return None
def get_location() -> str:
"""Get current location using multiple IP geolocation APIs with fallback."""
for api_url in LOCATION_APIS:
data = http_get_json(api_url, timeout=2)
if data:
city = data.get("city", "")
if city:
return city.replace(" ", "")
print("All location APIs failed")
return ""
def get_coordinates(city: str) -> Optional[Tuple[float, float]]:
"""Get coordinates for a city using Nominatim geocoding API."""
cache_key = city.lower()
current_time = time.time()
if cache_key in _location_cache:
lat, lon, timestamp = _location_cache[cache_key]
if current_time - timestamp < LOCATION_CACHE_TIMEOUT:
return lat, lon
encoded_city = urllib.parse.quote(city)
url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1"
data = http_get_json(url, timeout=3, headers={"User-Agent": "Modus-Desktop/1.0"})
if data and isinstance(data, list) and len(data) > 0:
try:
lat = float(data[0]["lat"])
lon = float(data[0]["lon"])
_location_cache[cache_key] = (lat, lon, current_time)
return lat, lon
except (ValueError, KeyError):
pass
return None
def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]:
"""Fetch weather data from Open-Meteo API."""
url = (
f"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}"
f"&current_weather=true"
f"&daily=temperature_2m_max,temperature_2m_min"
f"&timezone=auto"
f"&forecast_days=1"
)
return http_get_json(url, timeout=3)
def format_weather_data(weather_data: Dict[str, Any], city: str) -> List[str]:
"""Format weather data into the expected format."""
try:
current = weather_data["current_weather"]
daily = weather_data["daily"]
weather_code = current["weathercode"]
emoji = WEATHER_EMOJI_MAP.get(weather_code, "🌤️")
condition = WEATHER_DESC_MAP.get(weather_code, "Unknown")
gradient_class = WEATHER_GRADIENT_MAP.get(weather_code, "weather-clear")
temp = f"{round(current['temperature'])}°"
max_temp = f"{round(daily['temperature_2m_max'][0])}°"
min_temp = f"{round(daily['temperature_2m_min'][0])}°"
return [emoji, temp, condition, city, max_temp, min_temp, gradient_class]
except (KeyError, IndexError, TypeError) as e:
print(f"Error formatting weather data: {e}")
return None
def get_weather(callback):
"""Fetch weather data asynchronously."""
def fetch_weather():
location = get_location()
if not location:
return GLib.idle_add(callback, None)
cache_key = location.lower()
current_time = time.time()
if cache_key in _weather_cache:
cached_data, timestamp = _weather_cache[cache_key]
if current_time - timestamp < WEATHER_CACHE_TIMEOUT:
return GLib.idle_add(callback, cached_data)
coords = get_coordinates(location)
if not coords:
return GLib.idle_add(callback, None)
lat, lon = coords
weather_data = get_weather_data(lat, lon)
if not weather_data:
return GLib.idle_add(callback, None)
formatted_data = format_weather_data(weather_data, location)
if formatted_data:
_weather_cache[cache_key] = (formatted_data, current_time)
GLib.idle_add(callback, formatted_data)
else:
GLib.idle_add(callback, None)
executor.submit(fetch_weather)
def update_weather(widget):
"""Update weather widget with new data."""
def perform_fetch():
get_weather(lambda weather_info: update_widget(widget, weather_info))
debounced_perform_fetch = sync_debounce(1000, immediate=True)(perform_fetch)
def fetch_and_update():
debounced_perform_fetch()
return True
GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update)
fetch_and_update()
def update_widget(widget, weather_info):
"""Update widget labels with weather information."""
if weather_info:
widget.weatherinfo = weather_info
widget.update_labels(weather_info)
class Weather(Box):
def __init__(self, parent, **kwargs):
super().__init__(
name="weather-widget",
h_expand=True,
v_expand=True,
justification="right",
orientation="v",
all_visible=False,
**kwargs,
)
self.parent = parent
self.weatherinfo = None
self._create_labels()
self._layout_labels()
update_weather(self)
def _create_labels(self):
self.city = Label(
name="city",
label="Loading...",
justification="right",
h_align="start",
max_chars_width=12,
ellipsization="end",
)
self.temperature = Label(name="temperature", label="--°", h_align="start")
self.condition_em = Label(name="condition-emoji", label="🌤️", h_align="start")
self.condition = Label(
name="condition",
label="Loading...",
max_chars_width=18,
ellipsization="end",
h_align="start",
)
self.feels_like = Label(name="feels-like", label="H:-- L:--", h_align="start")
def _layout_labels(self):
for label in [
self.city,
self.temperature,
self.condition_em,
self.condition,
self.feels_like,
]:
self.add(label)
def update_labels(self, weather_info: List[str]):
if not weather_info or len(weather_info) != 7:
return
emoji, temp, condition, location, maxtemp, mintemp, _ = weather_info
maxmin = f"H:{maxtemp} L:{mintemp}"
updates = [
(self.city, location),
(self.temperature, temp),
(self.condition_em, emoji),
(self.condition, condition),
(self.feels_like, maxmin),
]
for label, text in updates:
label.set_label(text)
self.parent.set_visible(True)
class WeatherContainer(Box):
def __init__(self, **kwargs):
super().__init__(
orientation="v",
name="weather-container",
v_expand=True,
v_align="center",
size=(170, 170),
visible=True,
h_align="center",
children=[Weather(self)],
**kwargs,
)
class Date(Box):
def __init__(self, **kwargs):
super().__init__(
name="date-widget",
h_expand=True,
v_expand=True,
justification="center",
h_align="center",
v_align="start",
orientation="v",
**kwargs,
)
self.top = Box(orientation="h", name="date-top", h_expand=True)
date_interval = 10000
self.dateone = DateTime(formatters=["%a"], interval=date_interval, name="day")
self.datetwo = DateTime(formatters=["%b"], interval=date_interval, name="month")
self.datethree = DateTime(
formatters=["%-d"], interval=date_interval, name="date"
)
self.top.add(self.dateone)
self.top.add(self.datetwo)
self.add(self.top)
self.add(self.datethree)
class DateContainer(Box):
def __init__(self, **kwargs):
super().__init__(
orientation="v",
name="date-container",
v_expand=True,
size=(170, 170),
v_align="center",
h_align="center",
children=[Date()],
**kwargs,
)
class Calendar(Box):
def __init__(self, **kwargs):
calendar.setfirstweekday(6)
super().__init__(
name="calendar-widget",
h_expand=True,
v_expand=True,
orientation="v",
**kwargs,
)
self._update_current_date()
self._create_header()
self._create_days_header()
self._create_calendar_grid()
self.add(self.month_label)
self.add(self.days_header)
self.add(self.calendar_grid)
invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed)
def _update_current_date(self):
now = datetime.datetime.now()
self.current_month, self.current_year, self.current_day = (
now.month,
now.year,
now.day,
)
def _create_header(self):
self.month_label = Label(
name="calendar-month",
label=calendar.month_name[self.current_month],
h_align="start",
justification="left",
)
def _create_days_header(self):
self.days_header = Box(
name="calendar-days-header", orientation="h", h_expand=True, spacing=2
)
for i, day_name in enumerate(["S", "M", "T", "W", "T", "F", "S"]):
self.days_header.add(
Label(
name="calendar-day-header-weekend"
if i in (0, 6)
else "calendar-day-header",
label=day_name,
h_align="center",
h_expand=True,
)
)
def _create_calendar_grid(self):
self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1)
self.update_calendar()
def update_calendar_if_needed(self) -> bool:
now = datetime.datetime.now()
if (now.month, now.year, now.day) != (
self.current_month,
self.current_year,
self.current_day,
):
self._update_current_date()
self.update_calendar()
return True
def update_calendar(self):
for child in self.calendar_grid.get_children():
self.calendar_grid.remove(child)
self.month_label.set_label(calendar.month_name[self.current_month])
cal = calendar.monthcalendar(self.current_year, self.current_month)
for week in cal:
week_box = Box(orientation="h", spacing=2, h_expand=True)
for i, day in enumerate(week):
if day == 0:
label = Label(
name="calendar-day-empty",
label="",
h_align="center",
h_expand=True,
)
else:
is_today = (
day == self.current_day
and self.current_month == datetime.datetime.now().month
)
name = (
"calendar-day-today"
if is_today
else ("calendar-day-weekend" if i in (0, 6) else "calendar-day")
)
label = Label(
name=name, label=str(day), h_align="center", h_expand=True
)
week_box.add(label)
self.calendar_grid.add(week_box)
class CalendarContainer(Box):
def __init__(self, **kwargs):
super().__init__(
orientation="v",
name="calendar-box-widget",
v_expand=True,
size=(170, 170),
v_align="center",
h_align="center",
children=[Calendar()],
**kwargs,
)
class SystemInfoBase(Box):
@staticmethod
def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs):
return CircularProgressBar(
name=name,
start_angle=270,
end_angle=630,
min_value=0,
max_value=100,
size=size,
**kwargs,
)
def __init__(self, name: str, **kwargs):
super().__init__(
layer="bottom",
title="sysinfo",
name=name,
visible=True,
size=(170, 170),
h_expand=True,
v_expand=True,
all_visible=True,
**kwargs,
)
self.progress = self.create_progress_bar(name="progress")
self.main_label = Label(
label="0%\nLoading", justification="center", name="progress-label"
)
self.info_container = Box(
name="info-container", orientation="v", spacing=2, h_align="center"
)
self.add(
Box(
name="progress-bar-container",
h_expand=True,
v_expand=True,
orientation="v",
spacing=12,
h_align="center",
v_align="center",
children=[
Box(
children=[
Overlay(
child=self.progress,
tooltip_text="",
overlays=self.main_label,
)
]
),
Box(
h_align="center",
justification="centre",
orientation="v",
children=[self.info_container],
),
],
)
)
def start_updates(self):
invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
def create_info_line(
self, indicator_name: str, info_text: str, value_text: str
) -> Box:
line = Box(
orientation="h",
spacing=4,
h_align="start",
children=[
Label(label="■", name=indicator_name),
Label(label=info_text, name="info-text"),
Label(label=value_text, name="info-value"),
],
)
line.value_label = line.get_children()[2]
return line
def update(self) -> bool:
raise NotImplementedError
class RamInfo(SystemInfoBase):
def __init__(self, **kwargs):
super().__init__("info-box-widget", **kwargs)
self.used_line = self.create_info_line("used-color-indicator", "Used", "0.0GB")
self.free_line = self.create_info_line("free-color-indicator", "Free", "0.0GB")
self.info_container.add(self.used_line)
self.info_container.add(self.free_line)
self.start_updates()
def update(self) -> bool:
try:
mem = psutil.virtual_memory()
self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM")
self.used_line.value_label.set_label(f"{round(mem.used / (1024**3), 1)}GB")
self.free_line.value_label.set_label(
f"{round(mem.available / (1024**3), 1)}GB"
)
GLib.idle_add(self.progress.set_value, mem.percent)
except Exception as e:
print(f"Error: {e}")
return True
class CpuInfo(SystemInfoBase):
def __init__(self, **kwargs):
super().__init__("info-box-widget", **kwargs)
self.temp_value = Label(label="0°C", name="info-value")
self.info_container.add(
Box(
orientation="h",
spacing=4,
h_align="start",
children=[Label(label="Temp", name="info-text"), self.temp_value],
)
)
self.start_updates()
def get_cpu_temp(self) -> Optional[float]:
try:
temps = psutil.sensors_temperatures()
for name, entries in temps.items():
if any(s in name.lower() for s in ["coretemp", "k10temp", "cpu"]):
for entry in entries:
if any(
p in (entry.label or "").lower()
for p in ["package id 0", "core 0", ""]
):
return round(entry.current, 1)
except:
pass
return None
def update(self) -> bool:
try:
cpu = psutil.cpu_percent()
self.main_label.set_label(f" {round(cpu):<2} %\nCPU")
temp = self.get_cpu_temp()
self.temp_value.set_label(f"{temp}°C" if temp else "N/A")
GLib.idle_add(self.progress.set_value, cpu)
except Exception as e:
print(f"Error: {e}")
return True
class Deskwidgets(Window):
"""Desktop widgets manager - handles all desktop widgets."""
config = load_config()
def __init__(self, **kwargs):
# Create the main invisible window that manages the widgets
super().__init__(
name="desktop-widget-manager",
layer="bottom",
title="modus-desktop-widget-manager",
visible=False, # This window is invisible - just manages the others
size=(1, 1), # Minimal size
anchor="top left",
**kwargs,
)
# Create separate independent windows as attributes
self.top_left = Window(
anchor="top left",
title="modus-widgets-topleft",
orientation="h",
layer="bottom",
visible=False, # Start hidden until content ready
child=Box(
name="desktop-widgets-container",
children=[
DateContainer(),
WeatherContainer(),
CalendarContainer(),
],
),
)
self.bottom_left = Window(
anchor="bottom right",
title="modus-widgets-bottomright",
orientation="h",
layer="bottom",
visible=False, # Start hidden until content ready
child=Box(
name="desktop-widgets-container",
children=[
CpuInfo(),
RamInfo(),
],
),
)
# Show widgets after initialization is complete
self.top_left.set_visible(True)
self.bottom_left.set_visible(True)