# Standard library imports import psutil import requests import urllib.parse import datetime import time import subprocess import calendar from concurrent.futures import ThreadPoolExecutor from typing import Optional, Tuple, List, Dict, Any # Fabric imports from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from fabric.widgets.datetime import DateTime from fabric.widgets.circularprogressbar import CircularProgressBar from widgets.wayland import WaylandWindow as Window from fabric.utils import invoke_repeater from gi.repository import GLib # Local imports from config.data import load_config # Module-level constants WEATHER_UPDATE_INTERVAL = 600 # 10 minutes WEATHER_CACHE_TIMEOUT = 1800 # 30 minutes SYSTEM_UPDATE_INTERVAL = 1000 # 1 second CALENDAR_UPDATE_INTERVAL = 86400000 # 24 hours # Thread pool for async operations executor = ThreadPoolExecutor(max_workers=4) # 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", } # Global cache for weather data _weather_cache: Dict[str, Tuple[Any, float]] = {} _location_cache: Dict[str, Tuple[float, float, float]] = {} def get_location() -> str: """Get current location using IP geolocation API.""" try: response = requests.get("https://ipinfo.io/json", timeout=5) if response.status_code == 200: data = response.json() return data.get("city", "").replace(" ", "") except requests.RequestException as e: print(f"Error getting location: {e}") 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() # Check cache first (cache for 24 hours) if cache_key in _location_cache: lat, lon, timestamp = _location_cache[cache_key] if current_time - timestamp < 86400: # 24 hours return lat, lon try: encoded_city = urllib.parse.quote(city) url = f"https://nominatim.openstreetmap.org/search?q={encoded_city}&format=json&limit=1" response = requests.get(url, timeout=5, headers={'User-Agent': 'Modus-Desktop/1.0'}) if response.status_code == 200: data = response.json() if data: lat = float(data[0]['lat']) lon = float(data[0]['lon']) _location_cache[cache_key] = (lat, lon, current_time) return lat, lon except (requests.RequestException, ValueError, KeyError) as e: print(f"Error geocoding {city}: {e}") return None def get_weather_data(lat: float, lon: float) -> Optional[Dict[str, Any]]: """Fetch weather data from Open-Meteo API.""" try: url = ( f"https://api.open-meteo.com/v1/forecast?" f"latitude={lat}&longitude={lon}" f"¤t_weather=true" f"&daily=temperature_2m_max,temperature_2m_min" f"&timezone=auto" f"&forecast_days=1" ) response = requests.get(url, timeout=5) if response.status_code == 200: return response.json() except requests.RequestException as e: print(f"Error fetching weather data: {e}") return None 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'] # Get weather code and map to emoji and description weather_code = current['weathercode'] emoji = WEATHER_EMOJI_MAP.get(weather_code, "🌤️") condition = WEATHER_DESC_MAP.get(weather_code, "Unknown") # Temperature temp = f"{round(current['temperature'])}°" # Daily min/max temperatures 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] except (KeyError, IndexError, TypeError) as e: print(f"Error formatting weather data: {e}") return None def get_weather(callback): """Fetch weather data asynchronously using Open-Meteo API.""" def fetch_weather(): # Get location location = get_location() if not location: return GLib.idle_add(callback, None) # Check cache first 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) # Get coordinates for the location coords = get_coordinates(location) if not coords: return GLib.idle_add(callback, None) lat, lon = coords # Fetch weather data weather_data = get_weather_data(lat, lon) if not weather_data: return GLib.idle_add(callback, None) # Format data formatted_data = format_weather_data(weather_data, location) if formatted_data: # Cache the result _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 fetch_and_update(): get_weather(lambda weather_info: update_widget(widget, weather_info)) 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): """Weather widget displaying current conditions and forecast.""" 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 # Create labels with better organization self._create_labels() self._layout_labels() # Start weather updates update_weather(self) def _create_labels(self): """Create all weather labels.""" self.city = Label( name="city", label="Loading...", justification="right", h_align="start" ) 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...", h_align="start" ) self.feels_like = Label( name="feels-like", label="H:-- L:--", h_align="start" ) def _layout_labels(self): """Add labels to the widget in proper order.""" labels = [self.city, self.temperature, self.condition_em, self.condition, self.feels_like] for label in labels: self.add(label) def update_labels(self, weather_info: List[str]): """Update weather labels with new data.""" if not weather_info or len(weather_info) != 6: return emoji, temp, condition, location, maxtemp, mintemp = weather_info maxmin = f"H:{maxtemp} L:{mintemp}" # Batch update labels for better performance label_updates = [ (self.city, location), (self.temperature, temp), (self.condition_em, emoji), (self.condition, condition), (self.feels_like, maxmin) ] for label, text in label_updates: label.set_label(text) self.parent.set_visible(True) class WeatherContainer(Box): """Container for weather widget.""" 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): """Date widget displaying day, month, and date.""" 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, ) # Create date components self.top = Box(orientation="h", name="date-top", h_expand=True) # Use consistent interval for all date components date_interval = 10000 # 10 seconds 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") # Layout components self.top.add(self.dateone) self.top.add(self.datetwo) self.add(self.top) self.add(self.datethree) class DateContainer(Box): """Container for date widget.""" 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): """Calendar widget displaying current month.""" def __init__(self, **kwargs): super().__init__( name="calendar-widget", h_expand=True, v_expand=True, orientation="v", **kwargs, ) # Cache current date for efficiency self._update_current_date() # Create calendar components self._create_header() self._create_days_header() self._create_calendar_grid() # Layout components self.add(self.month_label) self.add(self.days_header) self.add(self.calendar_grid) # Schedule updates invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) def _update_current_date(self): """Update cached current date values.""" now = datetime.datetime.now() self.current_month = now.month self.current_year = now.year self.current_day = now.day def _create_header(self): """Create month header label.""" self.month_label = Label( name="calendar-month", label=calendar.month_name[self.current_month], h_align="start", justification="left", ) def _create_days_header(self): """Create day abbreviations header.""" self.days_header = Box( name="calendar-days-header", orientation="h", h_expand=True, spacing=2 ) day_names = ["S", "M", "T", "W", "T", "F", "S"] for i, day_name in enumerate(day_names): is_weekend = i in (0, 6) # Sunday or Saturday day_label = Label( name="calendar-day-header-weekend" if is_weekend else "calendar-day-header", label=day_name, h_align="center", h_expand=True, ) self.days_header.add(day_label) def _create_calendar_grid(self): """Create calendar grid container.""" self.calendar_grid = Box(name="calendar-grid", orientation="v", spacing=1) self.update_calendar() def update_calendar_if_needed(self) -> bool: """Check if date changed and update calendar if needed.""" now = datetime.datetime.now() if (now.month != self.current_month or now.year != self.current_year or now.day != self.current_day): self._update_current_date() self.update_calendar() return True def update_calendar(self): """Update the calendar grid with current month.""" # Clear existing calendar efficiently children = self.calendar_grid.get_children() for child in children: self.calendar_grid.remove(child) # Update month label self.month_label.set_label(calendar.month_name[self.current_month]) # Generate calendar cal = calendar.monthcalendar(self.current_year, self.current_month) current_date = datetime.datetime.now() for week in cal: week_box = Box(orientation="h", spacing=2, h_expand=True) for day_index, day in enumerate(week): if day == 0: # Empty day slot day_label = Label( name="calendar-day-empty", label="", h_align="center", h_expand=True, ) else: # Regular day is_today = (day == self.current_day and self.current_month == current_date.month and self.current_year == current_date.year) is_weekend = day_index in (0, 6) # Sunday or Saturday if is_today: name = "calendar-day-today" elif is_weekend: name = "calendar-day-weekend" else: name = "calendar-day" day_label = Label( name=name, label=str(day), h_align="center", h_expand=True ) week_box.add(day_label) self.calendar_grid.add(week_box) class CalendarContainer(Box): """Container for calendar widget.""" 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): """Base class for system information widgets.""" @staticmethod def create_progress_bar(name: str = "progress-bar", size: int = 80, **kwargs): """Create a standardized circular progress bar.""" 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, ) # Create progress bar and labels self.progress = self.create_progress_bar(name="progress") self.main_label = Label( label="0%\nLoading", justification="center", name="progress-label" ) # Create info container self.info_container = Box( name="info-container", orientation="v", spacing=2, h_align="center", ) # Create main layout self.progress_container = 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], ), ], ) self.add(self.progress_container) # Don't start updates here - let subclasses call start_updates() when ready def start_updates(self): """Start the update timer - call this after subclass initialization is complete.""" invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) def create_info_line(self, indicator_name: str, info_text: str, value_text: str) -> Box: """Create an information line with indicator, label, and value.""" indicator = Label(label="■", name=indicator_name) info_label = Label(label=info_text, name="info-text") value_label = Label(label=value_text, name="info-value") line = Box( orientation="h", spacing=4, h_align="start", children=[indicator, info_label, value_label], ) # Store references for easy updates line.indicator = indicator line.info_label = info_label line.value_label = value_label return line def update(self) -> bool: """Override in subclasses.""" raise NotImplementedError class RamInfo(SystemInfoBase): """RAM usage information widget.""" def __init__(self, **kwargs): super().__init__("info-box-widget", **kwargs) # Create info lines and store references 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" ) # Add to info container self.info_container.add(self.used_line) self.info_container.add(self.free_line) # Now that everything is set up, start updates self.start_updates() def update(self) -> bool: """Update RAM information.""" try: mem = psutil.virtual_memory() # Update main label self.main_label.set_label(f" {round(mem.percent):<2} %\nRAM") # Calculate values used_gb = mem.used / (1024**3) free_gb = mem.available / (1024**3) # Update info labels using stored references self.used_line.value_label.set_label(f"{round(used_gb, 1)}GB") self.free_line.value_label.set_label(f"{round(free_gb, 1)}GB") # Update progress bar (use GLib.idle_add for thread safety) GLib.idle_add(self.progress.set_value, mem.percent) except Exception as e: print(f"Error updating RAM info: {e}") return True class CpuInfo(SystemInfoBase): """CPU usage and temperature information widget.""" def __init__(self, **kwargs): super().__init__("info-box-widget", **kwargs) # Create temperature info components self.temp_info = Label(label="Temp", name="info-text") self.temp_value = Label(label="0°C", name="info-value") # Create temperature info line (no indicator) self.temp_line = Box( orientation="h", spacing=4, h_align="start", children=[self.temp_info, self.temp_value], ) # Add to info container self.info_container.add(self.temp_line) # Now that everything is set up, start updates self.start_updates() def get_cpu_temp(self) -> Optional[float]: """Get CPU temperature from system sensors.""" try: temps = psutil.sensors_temperatures() if not temps: return None # Search for CPU temperature sensors cpu_sensor_names = ["coretemp", "k10temp", "cpu"] cpu_label_patterns = ["package id 0", "core 0", ""] for name, entries in temps.items(): if any(sensor in name.lower() for sensor in cpu_sensor_names): for entry in entries: entry_label = (entry.label or "").lower() if any(pattern in entry_label for pattern in cpu_label_patterns): return round(entry.current, 1) except Exception as e: print(f"Error reading CPU temperature: {e}") return None def update(self) -> bool: """Update CPU information.""" try: # Get CPU usage cpu = psutil.cpu_percent() # Update main label self.main_label.set_label(f" {round(cpu):<2} %\nCPU") # Update temperature using stored reference temp = self.get_cpu_temp() temp_text = f"{temp}°C" if temp is not None else "N/A" self.temp_value.set_label(temp_text) # Update progress bar (use GLib.idle_add for thread safety) GLib.idle_add(self.progress.set_value, cpu) except Exception as e: print(f"Error updating CPU info: {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)