feat(desktop): now move widget with drag to any place of window

This commit is contained in:
sankalp committed 2026-07-26 17:00:02 +05:45
1 parent 65db4f75fe
commit 37b262270c
15 files changed
+1754 -888

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import calendar
import datetime
from fabric.utils import GLib, invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from utils.functions import clear_children
from window.desktop.registry import DesktopWidgetRegistry
CALENDAR_UPDATE_INTERVAL = int(
(
(
datetime.datetime.combine(
datetime.date.today() + datetime.timedelta(days=1), datetime.time.min
)
- datetime.datetime.now()
).total_seconds()
)
* 1000
)
class DesktopCalendarWidget(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)
self._calendar_timer_id = invoke_repeater(
CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed
)
def destroy(self):
"""Cleanup calendar update timer"""
if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id:
GLib.source_remove(self._calendar_timer_id)
self._calendar_timer_id = None
super().destroy()
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):
clear_children(self.calendar_grid)
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 DesktopCalendarContainer(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=[DesktopCalendarWidget()],
**kwargs,
)
DesktopWidgetRegistry.register("calendar", DesktopCalendarContainer, (174, 174))
-158
View File
@@ -1,158 +0,0 @@
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
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 to SVG icon mapping
WEATHER_ICON_MAP = {
0: "weather-clear",
1: "weather-few-clouds",
2: "weather-clouds",
3: "weather-overcast",
45: "weather-fog",
48: "weather-fog",
51: "weather-showers-scattered",
53: "weather-showers",
55: "weather-showers",
61: "weather-showers-scattered",
80: "weather-showers-scattered",
63: "weather-showers",
65: "weather-showers",
81: "weather-showers",
82: "weather-storm",
56: "weather-freezing-rain",
57: "weather-freezing-rain",
66: "weather-freezing-rain",
67: "weather-freezing-rain",
71: "weather-snow",
73: "weather-snow",
75: "weather-snow",
77: "weather-hail",
85: "weather-snow-scattered",
86: "weather-snow-scattered",
95: "weather-storm",
96: "weather-storm",
99: "weather-storm",
}
# 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
]
+52
View File
@@ -0,0 +1,52 @@
from fabric.widgets.box import Box
from fabric.widgets.datetime import DateTime
from window.desktop.registry import DesktopWidgetRegistry
class DesktopDateWidget(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)
# Using a highly frequent interval (e.g. 10000ms/10s) causes the DateTime widget
# (which inherits from Gtk.Button) to repeatedly call set_label.
# In GTK3, repeatedly replacing a button's label can cause rendering glitches
# where the layout engine drops the child label's visibility after some time.
# Since this is just a date widget, we increase the interval to 100000ms (100 seconds)
# which easily avoids the continuous redraw glitch while keeping the date accurate.
date_interval = 100000
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 DesktopDateContainer(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=[DesktopDateWidget()],
**kwargs,
)
DesktopWidgetRegistry.register("date", DesktopDateContainer, (174, 174))
+507
View File
@@ -0,0 +1,507 @@
import importlib
import os
import pkgutil
import tomlkit
from fabric.utils import Gdk, GLib, Gtk, logger
from fabric.widgets.box import Box
from fabric.widgets.wayland import WaylandWindow
from shared.widgets.animator import Animator
from utils.gtk_utils import toml_file
from window.desktop.registry import DesktopWidgetRegistry
# Dynamically import all modules in this package to register widgets
_dir = os.path.dirname(__file__)
for _, _name, _ in pkgutil.iter_modules([_dir]):
if _name not in ("main", "registry", "widgets"):
try:
importlib.import_module(f".{_name}", package=__package__)
except Exception as e:
logger.error(f"Failed to load desktop widget module {_name}: {e}")
# Config-backed position manager (stores percentage-based positions) #
_DEFAULT_POSITIONS = [
{"key": "date", "px": 0.0, "py": 0.0},
{"key": "weather", "px": 0.0974, "py": 0.0},
{"key": "calendar", "px": 0.19271, "py": 0.0},
{"key": "cpu_info", "px": 0.81042, "py": 0.82732},
{"key": "ram_info", "px": 0.90521, "py": 0.82732},
]
class PositionManager:
"""Manages widget positions stored in desktop.toml as fractional coords."""
_file = toml_file("desktop.toml")
@classmethod
def _read(cls):
try:
if os.path.exists(cls._file):
with open(cls._file) as f:
return tomlkit.load(f)
except Exception as e:
logger.error(f"Failed to read desktop.toml: {e}")
return {}
@classmethod
def _write(cls, data):
try:
os.makedirs(os.path.dirname(cls._file), exist_ok=True)
with open(cls._file, "w") as f:
tomlkit.dump(data, f)
except Exception as e:
logger.error(f"Failed to write desktop.toml: {e}")
@classmethod
def get_widgets(cls, monitor_id: int) -> list[dict]:
state = cls._read()
mid_str = str(monitor_id)
if mid_str not in state:
state[mid_str] = list(_DEFAULT_POSITIONS)
cls._write(state)
return state[mid_str]
@classmethod
def save_position(cls, monitor_id: int, key: str, px: float, py: float) -> None:
state = cls._read()
mid_str = str(monitor_id)
if mid_str not in state:
state[mid_str] = list(_DEFAULT_POSITIONS)
for entry in state[mid_str]:
if entry["key"] == key:
entry["px"] = round(max(0.0, min(1.0, px)), 5)
entry["py"] = round(max(0.0, min(1.0, py)), 5)
break
else:
state[mid_str].append({"key": key, "px": px, "py": py})
cls._write(state)
@classmethod
def remove(cls, monitor_id: int, key: str) -> bool:
state = cls._read()
mid_str = str(monitor_id)
if mid_str not in state:
return False
before = len(state[mid_str])
state[mid_str] = [e for e in state[mid_str] if e["key"] != key]
cls._write(state)
return len(state[mid_str]) < before
position_manager = PositionManager()
class DesktopWidgetWindow(WaylandWindow):
def __init__(self, monitor_id: int) -> None:
self._monitor_id = monitor_id
self._fixed = Gtk.Fixed()
self._children: dict[str, Gtk.EventBox] = {}
self._win_w = 1
self._win_h = 1
self._old_w = 0
self._old_h = 0
self._recalc_in_progress = False
self._recalc_timer: int | None = None
self._ready = False
self._in_size_allocate = False
self._edit_mode = False
# Free-drag state
self._dragging_key: str | None = None
self._drag_start_wx: int = 0 # widget top-left x when drag began
self._drag_start_wy: int = 0 # widget top-left y when drag began
self._drag_start_px: int = 0 # pointer x when drag began
self._drag_start_py: int = 0 # pointer y when drag began
self._root = Box(h_expand=True, v_expand=True)
self._overlay = Gtk.Overlay()
self._overlay.add(self._fixed)
self._stop_edit_box = Gtk.Box(
halign=Gtk.Align.CENTER, valign=Gtk.Align.START, margin_top=40
)
self._stop_edit_btn = Gtk.Button(label="Done Editing")
self._stop_edit_btn.get_style_context().add_class("suggested-action")
self._stop_edit_btn.connect("clicked", self._on_stop_edit_clicked)
self._stop_edit_box.add(self._stop_edit_btn)
self._overlay.add_overlay(self._stop_edit_box)
self._stop_edit_box.set_no_show_all(True)
self._stop_edit_box.set_visible(False)
self._root.add(self._overlay)
# Get initial dimensions from monitor so we don't spawn at 0,0
monitor = Gdk.Display.get_default().get_monitor(monitor_id)
if monitor:
geom = monitor.get_geometry()
self._win_w = max(1, geom.width)
self._win_h = max(1, geom.height)
# Fade animators
self._fade_animator = Animator(
bezier_curve=(0.4, 0.0, 0.2, 1.0),
duration=0.2,
min_value=0.0,
max_value=1.0,
tick_widget=self._root,
)
self._fade_animator.connect("notify::value", self._on_fade_value)
self._fade_animator.connect("finished", self._on_fade_finished)
self._fade_out_animator = Animator(
bezier_curve=(0.4, 0.0, 0.2, 1.0),
duration=0.2,
min_value=0.0,
max_value=1.0,
tick_widget=self._root,
)
self._fade_out_animator.connect("notify::value", self._on_fade_out_value)
self._fade_out_animator.connect("finished", self._on_fade_out_finished)
self._fixed.set_opacity(0.0)
super().__init__(
monitor=monitor_id,
anchor="left right top bottom",
exclusivity="ignore",
layer="background",
child=self._root,
visible=True,
name=f"desktop-widgets-{monitor_id}",
)
self.connect("size-allocate", self._on_size_allocate)
self.connect("button-press-event", self._on_window_button_press)
GLib.timeout_add(50, self._initial_build)
# lifecycle
def _initial_build(self) -> bool:
self._ready = True
alloc = self.get_allocation()
if alloc.width > 1:
self._win_w = alloc.width
self._win_h = alloc.height
self.rebuild()
self._fade_in()
return False
# fade
def _on_fade_value(self, animator, _) -> None:
self._fixed.set_opacity(animator.value)
def _on_fade_finished(self, animator) -> None:
self._fixed.set_opacity(1.0)
def _fade_in(self) -> None:
if self._fade_animator.playing:
return
self._fade_out_animator.pause()
self._fade_animator.value = self._fixed.get_opacity()
self._fade_animator.min_value = self._fixed.get_opacity()
self._fade_animator.max_value = 1.0
self._fade_animator.play()
def _fade_out(self) -> None:
self._fade_animator.pause()
self._fade_out_animator.min_value = 0.0
self._fade_out_animator.max_value = self._fixed.get_opacity()
self._fade_out_animator.value = self._fixed.get_opacity()
self._fade_out_animator.play()
def _on_fade_out_value(self, animator, _) -> None:
self._fixed.set_opacity(animator.value)
def _on_fade_out_finished(self, animator) -> None:
self._fixed.set_opacity(0.0)
# right-click context menu
def _on_window_button_press(self, widget, event: Gdk.EventButton) -> bool:
if event.button != 3:
return False
menu = Gtk.Menu()
edit_item = Gtk.CheckMenuItem(label="Edit Widgets")
edit_item.set_active(self._edit_mode)
edit_item.connect("toggled", self._on_edit_toggled)
menu.append(edit_item)
menu.show_all()
menu.popup_at_pointer(event)
return True
# edit mode
def _on_edit_toggled(self, item) -> None:
self._edit_mode = item.get_active()
if self._edit_mode:
self._stop_edit_box.set_visible(True)
self._stop_edit_btn.set_visible(True)
else:
self._stop_edit_box.set_visible(False)
self._stop_edit_btn.set_visible(False)
for key, eb in self._children.items():
self._configure_eb_for_edit(eb, key, self._edit_mode)
def _on_stop_edit_clicked(self, btn) -> None:
self._edit_mode = False
self._stop_edit_box.set_visible(False)
self._stop_edit_btn.set_visible(False)
for key, eb in self._children.items():
self._configure_eb_for_edit(eb, key, False)
def _configure_eb_for_edit(self, eb: Gtk.EventBox, key: str, edit: bool) -> None:
"""Wire / unwire the free-drag signals on an EventBox."""
if edit:
eb.get_style_context().add_class("edit-mode")
else:
eb.get_style_context().remove_class("edit-mode")
# We leave the signals connected; _on_eb_press guards on _edit_mode.
# free-drag implementation
def _is_overlapping(self, eb: Gtk.EventBox, new_x: int, new_y: int) -> bool:
aw = eb.get_allocation().width or eb.get_preferred_width()[1]
ah = eb.get_allocation().height or eb.get_preferred_height()[1]
for key, other_eb in self._children.items():
if other_eb == eb:
continue
# Read the position we actually set, fallback to allocation
ox = getattr(other_eb, "_target_x", other_eb.get_allocation().x)
oy = getattr(other_eb, "_target_y", other_eb.get_allocation().y)
ow = other_eb.get_allocation().width or other_eb.get_preferred_width()[1]
oh = other_eb.get_allocation().height or other_eb.get_preferred_height()[1]
# Simple rectangle intersection check
if not (
new_x + aw <= ox
or new_x >= ox + ow
or new_y + ah <= oy
or new_y >= oy + oh
):
return True
return False
def _on_eb_press(self, eb: Gtk.EventBox, event: Gdk.EventButton, key: str) -> bool:
if not self._edit_mode or event.button != 1:
return False
# Bring to front when dragging
if eb.get_window():
eb.get_window().raise_()
alloc = eb.get_allocation()
self._dragging_key = key
self._drag_start_wx = alloc.x
self._drag_start_wy = alloc.y
self._drag_start_px = int(event.x_root)
self._drag_start_py = int(event.y_root)
eb.set_opacity(0.7)
eb.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "grabbing")
)
return True
def _on_eb_motion(self, eb: Gtk.EventBox, event: Gdk.EventMotion, key: str) -> bool:
if self._dragging_key != key:
return False
dx = int(event.x_root) - self._drag_start_px
dy = int(event.y_root) - self._drag_start_py
new_x = max(
0, min(self._win_w - eb.get_allocation().width, self._drag_start_wx + dx)
)
new_y = max(
0, min(self._win_h - eb.get_allocation().height, self._drag_start_wy + dy)
)
curr_x = getattr(eb, "_target_x", eb.get_allocation().x)
curr_y = getattr(eb, "_target_y", eb.get_allocation().y)
if not self._is_overlapping(eb, new_x, new_y):
self._fixed.move(eb, new_x, new_y)
eb._target_x = new_x
eb._target_y = new_y
else:
# Try horizontal only
if not self._is_overlapping(eb, new_x, curr_y):
self._fixed.move(eb, new_x, curr_y)
eb._target_x = new_x
# Try vertical only
elif not self._is_overlapping(eb, curr_x, new_y):
self._fixed.move(eb, curr_x, new_y)
eb._target_y = new_y
return True
def _on_eb_release(
self, eb: Gtk.EventBox, event: Gdk.EventButton, key: str
) -> bool:
if self._dragging_key != key or event.button != 1:
return False
self._dragging_key = None
eb.set_opacity(1.0)
eb.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "grab")
)
# Persist the new position as fractions of screen size
alloc = eb.get_allocation()
if self._win_w > 1 and self._win_h > 1:
position_manager.save_position(
self._monitor_id,
key,
alloc.x / self._win_w,
alloc.y / self._win_h,
)
return True
# size allocate
def _on_size_allocate(self, widget, alloc: Gdk.Rectangle) -> None:
if not self._ready or self._in_size_allocate:
return
w, h = alloc.width, alloc.height
if w < 1 or h < 1:
return
if w != self._old_w or h != self._old_h:
is_first_real_size = self._old_w <= 1
self._old_w = w
self._old_h = h
self._win_w = w
self._win_h = h
self._reposition_all()
if is_first_real_size:
self._fixed.set_opacity(0.0)
self._fixed.show()
self._fade_in()
# positioning
def _reposition_all(self) -> None:
entries = position_manager.get_widgets(self._monitor_id)
for entry in entries:
key = entry["key"]
eb = self._children.get(key)
if eb is None:
continue
px = int(entry["px"] * self._win_w)
py = int(entry["py"] * self._win_h)
# Clamp so widget never goes off-screen
aw = eb.get_allocation().width or eb.get_preferred_width()[1]
ah = eb.get_allocation().height or eb.get_preferred_height()[1]
px = max(0, min(self._win_w - aw, px))
py = max(0, min(self._win_h - ah, py))
# During initial load, ensure no overlap even if config overlaps
if self._is_overlapping(eb, px, py):
# Try to nudge it slightly until it fits
for step_y in range(0, self._win_h, 50):
for step_x in range(0, self._win_w, 50):
if not self._is_overlapping(eb, step_x, step_y):
px, py = step_x, step_y
break
else:
continue
break
eb._target_x = px
eb._target_y = py
self._fixed.move(eb, px, py)
# widget management
def rebuild(self) -> None:
for widget in self._children.values():
self._fixed.remove(widget)
widget.destroy()
self._children.clear()
entries = position_manager.get_widgets(self._monitor_id)
for entry in entries:
key = entry["key"]
cls = DesktopWidgetRegistry.get_widget_class(key)
if cls is None:
logger.warning(f"[DesktopWidgetService] unknown widget key {key!r}")
continue
try:
widget = cls()
w_px, h_px = DesktopWidgetRegistry.get_widget_size(key)
widget.set_size_request(w_px, h_px)
eb = Gtk.EventBox()
eb.set_size_request(w_px, h_px)
eb.set_above_child(True)
eb.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.BUTTON1_MOTION_MASK
)
eb.connect("button-press-event", self._on_eb_press, key)
eb.connect("button-release-event", self._on_eb_release, key)
eb.connect("motion-notify-event", self._on_eb_motion, key)
eb.add(widget)
eb.show_all()
self._fixed.put(eb, 0, 0)
self._children[key] = eb
if self._edit_mode:
self._configure_eb_for_edit(eb, key, True)
except Exception as e:
logger.error(f"[DesktopWidgetService] failed to build {key!r}: {e}")
self._reposition_all()
self._force_refresh()
def add_widget(self, key: str, px: float = 0.02, py: float = 0.04) -> None:
if key in self._children:
return
cls = DesktopWidgetRegistry.get_widget_class(key)
if cls is None:
return
try:
widget = cls()
w_px, h_px = DesktopWidgetRegistry.get_widget_size(key)
widget.set_size_request(w_px, h_px)
eb = Gtk.EventBox()
eb.set_size_request(w_px, h_px)
eb.set_above_child(True)
eb.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.BUTTON1_MOTION_MASK
)
eb.connect("button-press-event", self._on_eb_press, key)
eb.connect("button-release-event", self._on_eb_release, key)
eb.connect("motion-notify-event", self._on_eb_motion, key)
eb.add(widget)
eb.show_all()
self._fixed.put(eb, int(px * self._win_w), int(py * self._win_h))
self._children[key] = eb
if self._edit_mode:
self._configure_eb_for_edit(eb, key, True)
self._force_refresh()
except Exception as e:
logger.error(f"[DesktopWidgetService] failed to build {key!r}: {e}")
def remove_widget(self, key: str) -> None:
widget = self._children.pop(key, None)
if widget:
self._fixed.remove(widget)
widget.destroy()
def _force_refresh(self):
# Niri needs a hide/show cycle; show_all() is safe now (no canvas to hide).
self.hide()
self.show_all()
return False
+16
View File
@@ -0,0 +1,16 @@
class DesktopWidgetRegistry:
_widgets = {}
_sizes = {}
@classmethod
def register(cls, key: str, widget_cls, size: tuple[int, int]):
cls._widgets[key] = widget_cls
cls._sizes[key] = size
@classmethod
def get_widget_class(cls, key: str):
return cls._widgets.get(key)
@classmethod
def get_widget_size(cls, key: str) -> tuple[int, int]:
return cls._sizes.get(key, (174, 174))
+172
View File
@@ -0,0 +1,172 @@
from typing import Optional
import psutil
from fabric.utils import GLib, invoke_repeater, logger
from fabric.widgets.box import Box
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from window.desktop.registry import DesktopWidgetRegistry
SYSTEM_UPDATE_INTERVAL = 2000
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):
self.update()
self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
def destroy(self):
"""Cleanup system info update timer"""
if hasattr(self, "_system_timer_id") and self._system_timer_id:
GLib.source_remove(self._system_timer_id)
self._system_timer_id = None
super().destroy()
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 DesktopRamInfoWidget(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:
logger.error(f"Error: {e}")
return True
class DesktopCpuInfoWidget(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 Exception as e:
logger.error(f"An error occurred: {e}")
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:
logger.error(f"Error: {e}")
return True
DesktopWidgetRegistry.register("ram_info", DesktopRamInfoWidget, (174, 174))
DesktopWidgetRegistry.register("cpu_info", DesktopCpuInfoWidget, (174, 174))
+132
View File
@@ -0,0 +1,132 @@
from fabric.utils import logger
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from services.weather import Weather as WeatherServiceBackend
from utils.gtk_utils import svg_file
from window.desktop.registry import DesktopWidgetRegistry
class DesktopWeatherWidget(Box):
def __init__(self, parent=None, **kwargs):
super().__init__(
name="weather-container",
h_expand=True,
v_expand=True,
visible=True,
all_visible=True,
justification="right",
orientation="v",
**kwargs,
)
self.inner = Box(
name="weather-widget",
orientation="v",
h_expand=True,
v_expand=True,
visible=True,
all_visible=True,
)
self.add(self.inner)
self.weatherinfo = None
self._create_labels()
self._layout_labels()
self._service = WeatherServiceBackend.get_default()
self._service.connect("notify::is-loading", self._on_service_update)
self._service.connect("notify::temperature", self._on_service_update)
self._service.connect("notify::weather-icon", self._on_service_update)
self._service.connect("notify::weather-description", self._on_service_update)
self._service.connect("notify::location", self._on_service_update)
self._service.connect("notify::daily-forecast", self._on_service_update)
self._service.connect("notify::weather-gradient", self._on_service_update)
self._on_service_update(self._service, None)
def _create_labels(self):
self.header = Box(orientation="h", h_expand=True)
self.body = Box(orientation="v", v_expand=True, valign="end")
self.city = Label(
name="city",
label="Loading...",
justification="left",
h_align="start",
max_chars_width=15,
ellipsization="end",
)
self.temperature = Label(name="temperature", label="--°", h_align="start")
self.condition_em = svg_file(
"weather/weather-none-available.svg", size=(35, 35), name="condition-emoji"
)
self.condition = Label(
name="condition",
label="Loading...",
max_chars_width=18,
ellipsization="end",
h_align="start",
)
self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start")
def _layout_labels(self):
self.header.add(self.city)
self.header.pack_end(self.condition_em, False, False, 0)
self.body.add(self.temperature)
self.body.add(self.condition)
self.body.add(self.feels_like)
self.inner.add(self.header)
self.inner.add(self.body)
def _on_service_update(self, *args):
if self._service.is_loading:
return
if self._service.has_error:
self.city.set_label("Error")
self.condition.set_label("Unavailable")
self.add_style_class("weather-clear")
return
try:
icon_name = self._service.weather_icon or "weather-none-available"
temp_val = self._service.temperature
temp = f"{round(float(temp_val))}°" if temp_val is not None else "--°"
condition = self._service.weather_description or "Unknown"
location = self._service.location or "Unknown"
gradient_class = self._service.weather_gradient or "weather-clear"
daily = self._service.daily_forecast
if daily and len(daily) > 0:
maxtemp = f"{round(float(daily[0].get('temperature_max', 0)))}°"
mintemp = f"{round(float(daily[0].get('temperature_min', 0)))}°"
maxmin = f"L:{mintemp} H:{maxtemp}"
else:
maxmin = "L:-- H:--"
self.city.set_label(location)
self.temperature.set_label(temp)
self.condition_em.dynamic_file(f"weather/{icon_name}.svg")
self.condition.set_label(condition)
self.feels_like.set_label(maxmin)
# Apply gradient class directly to the widget like other widgets do
for cls in self.get_style_context().list_classes():
if cls.startswith("weather-"):
self.remove_style_class(cls)
self.add_style_class(gradient_class)
except Exception as e:
logger.error(f"[DesktopWeatherWidget] Error updating weather UI: {e}")
self.city.set_label("Error")
self.condition.set_label("UI Error")
def destroy(self):
try:
self._service.disconnect_by_func(self._on_service_update)
except ValueError as e:
logger.warning(
f"[widget] self._service.disconnect_by_func(self._on_service_update) failed: {e}"
)
super().destroy()
DesktopWidgetRegistry.register("weather", DesktopWeatherWidget, (174, 174))
-724
View File
@@ -1,724 +0,0 @@
import calendar
import datetime
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Tuple
import httpx
import psutil
from fabric.core.service import Service, Signal
from fabric.utils import GLib, invoke_repeater, logger, time
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 services.config import on_config_change
from shared.data import load_config
from utils.gtk_utils import svg_file
from window.desktop.constants import (
CALENDAR_UPDATE_INTERVAL,
LOCATION_APIS,
SYSTEM_UPDATE_INTERVAL,
WEATHER_CACHE_TIMEOUT,
WEATHER_DESC_MAP,
WEATHER_GRADIENT_MAP,
WEATHER_ICON_MAP,
WEATHER_UPDATE_INTERVAL,
)
# Icons that have a dedicated -night SVG variant.
_NIGHT_VARIANT_ICONS = frozenset(
(
"weather-clear",
"weather-clouds",
"weather-few-clouds",
"weather-overcast",
"weather-showers",
"weather-showers-scattered",
"weather-snow",
"weather-snow-scattered",
"weather-storm",
)
)
_HTTP_HEADERS = {"User-Agent": "Modus-Desktop/1.0"}
class WeatherService(Service):
"""Owns HTTP, caching, coordinate lookup and update scheduling for the
weather widget. Networking runs on a single background worker; results are
delivered back to the GLib main loop through the ``updated`` signal.
This is an app-lifetime singleton: its executor, client and timer are never
torn down. Widgets observe it by connecting to ``updated`` and disconnect on
destroy, so the service survives widget rebuilds (config reloads, etc.)."""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
@staticmethod
def get_initial():
if WeatherService._instance is None:
WeatherService._instance = WeatherService()
return WeatherService._instance
@Signal
def updated(self, weather_info: object) -> None:
"""Emitted on the main loop with fresh weather data (or None on failure)."""
def __init__(self, **kwargs):
if getattr(self, "_initialized", False):
return
super().__init__(**kwargs)
self._initialized = True
self._http = httpx.Client(
timeout=3.0, follow_redirects=True, headers=_HTTP_HEADERS
)
self._executor = ThreadPoolExecutor(max_workers=1)
# This shell shows one location at a time, so caches hold single values.
self._weather: Optional[List[str]] = None
self._weather_ts: float = 0.0
self._location: Optional[str] = None
self._coords: Optional[Tuple[float, float]] = None
self._coords_location: Optional[str] = None
# Periodic refresh only; widgets request the first fetch after
# connecting (see Weather.__init__) to avoid a race with subscription.
GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, self._refresh)
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if new_config.get("weather_location") != old_config.get("weather_location"):
self._weather = None
self._location = None
self._coords = None
self._coords_location = None
self._executor.submit(self._fetch)
# -- scheduling -------------------------------------------------------
def refresh(self) -> None:
"""Trigger an immediate fetch (e.g. when a widget subscribes)."""
self._executor.submit(self._fetch)
def _refresh(self) -> bool:
self._executor.submit(self._fetch)
return True
# -- networking -------------------------------------------------------
def _get_json(self, url: str, timeout: float) -> Optional[Dict[str, Any]]:
try:
resp = self._http.get(url, timeout=timeout)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.error(f"HTTP request to {url} failed: {e}")
return None
def _get_location(self) -> str:
try:
manual_location = load_config().get("weather_location")
if manual_location:
return manual_location
except Exception as e:
logger.error(f"An error occurred: {e}")
for api_url in LOCATION_APIS:
data = self._get_json(api_url, 2.0)
if data:
city = data.get("city", "")
if city:
return city
logger.warning("All location APIs failed")
return ""
def _get_coordinates(self, city: str) -> Optional[Tuple[float, float]]:
# Only query Nominatim when the location is new or unknown.
if self._coords is not None and self._coords_location == city:
return self._coords
encoded_city = urllib.parse.quote(city)
url = (
"https://nominatim.openstreetmap.org/search?"
f"q={encoded_city}&format=json&limit=1"
)
data = self._get_json(url, 3.0)
if isinstance(data, list) and data:
try:
coords = (float(data[0]["lat"]), float(data[0]["lon"]))
except (ValueError, KeyError) as e:
logger.warning(
f"[widget] coords = (float(data[0]['lat']), float(data[0]['lon'])) failed: {e}"
)
return None
self._coords = coords
self._coords_location = city
return coords
return None
def _get_weather_data(self, lat: float, lon: float) -> Optional[Dict[str, Any]]:
url = (
"https://api.open-meteo.com/v1/forecast?"
f"latitude={lat}&longitude={lon}"
"&current_weather=true"
"&daily=temperature_2m_max,temperature_2m_min"
"&timezone=auto"
"&forecast_days=1"
)
return self._get_json(url, 3.0)
# -- background fetch (runs on the worker thread) ---------------------
def _fetch(self) -> None:
location = self._get_location()
if not location:
return GLib.idle_add(self._deliver, None)
cache_key = location.lower()
now = time.time()
if (
self._weather is not None
and self._location == cache_key
and now - self._weather_ts < WEATHER_CACHE_TIMEOUT
):
return GLib.idle_add(self._deliver, self._weather)
coords = self._get_coordinates(location)
if not coords:
return GLib.idle_add(self._deliver, None)
weather_data = self._get_weather_data(*coords)
if not weather_data:
return GLib.idle_add(self._deliver, None)
formatted = _format_weather_data(weather_data, location)
if formatted:
self._weather = formatted
self._location = cache_key
self._weather_ts = now
GLib.idle_add(self._deliver, formatted)
else:
GLib.idle_add(self._deliver, None)
return None
def _deliver(self, weather_info: Optional[List[str]]) -> None:
self.emit("updated", weather_info)
def _format_weather_data(
weather_data: Dict[str, Any], city: str
) -> Optional[List[str]]:
"""Format weather data into the list consumed by the widget."""
try:
current = weather_data["current_weather"]
daily = weather_data["daily"]
weather_code = current["weathercode"]
is_day = current.get("is_day", 1) # Default to day if missing
base_icon = WEATHER_ICON_MAP.get(weather_code, "weather-none-available")
icon_name = base_icon
if not is_day and base_icon in _NIGHT_VARIANT_ICONS:
icon_name = f"{base_icon}-night"
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 [icon_name, temp, condition, city, max_temp, min_temp, gradient_class]
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Error formatting weather data: {e}")
return None
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()
self._service = WeatherService.get_initial()
self._service.connect("updated", self._on_service_update)
self._service.refresh()
def _create_labels(self):
self.header = Box(orientation="h", h_expand=True)
self.body = Box(orientation="v", v_expand=True, valign="end")
self.city = Label(
name="city",
label="Loading...",
justification="left",
h_align="start",
max_chars_width=15,
ellipsization="end",
)
self.temperature = Label(name="temperature", label="--°", h_align="start")
self.condition_em = svg_file(
"weather/weather-none-available.svg", size=(35, 35), name="condition-emoji"
)
self.condition = Label(
name="condition",
label="Loading...",
max_chars_width=18,
ellipsization="end",
h_align="start",
)
self.feels_like = Label(name="feels-like", label="L:-- H:--", h_align="start")
def _layout_labels(self):
# Header: City (left) and Icon (right)
self.header.add(self.city)
self.header.pack_end(self.condition_em, False, False, 0)
# Body: Temp, Condition, High/Low
self.body.add(self.temperature)
self.body.add(self.condition)
self.body.add(self.feels_like)
self.add(self.header)
self.add(self.body)
def update_labels(self, weather_info: List[str]):
if not weather_info or len(weather_info) != 7:
return
icon_name, temp, condition, location, maxtemp, mintemp, gradient_class = (
weather_info
)
maxmin = f"L:{mintemp} H:{maxtemp}"
self.city.set_label(location)
self.temperature.set_label(temp)
# update SVG File
self.condition_em.dynamic_file(f"weather/{icon_name}.svg")
self.condition.set_label(condition)
self.feels_like.set_label(maxmin)
# Apply gradient class to container
if hasattr(self, "parent") and self.parent:
# Remove old weather classes
for cls in self.parent.get_style_context().list_classes():
if cls.startswith("weather-"):
self.parent.remove_style_class(cls)
# Add new one
self.parent.add_style_class(gradient_class)
self.parent.set_visible(True)
def _on_service_update(self, _service, weather_info):
if weather_info:
self.weatherinfo = weather_info
self.update_labels(weather_info)
def destroy(self):
"""Disconnect from the weather service (the service is app-lifetime)."""
try:
self._service.disconnect_by_func(self._on_service_update)
except ValueError as e:
logger.warning(
f"[widget] self._service.disconnect_by_func(self._on_service_update) failed: {e}"
)
super().destroy()
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)
self._calendar_timer_id = invoke_repeater(
CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed
)
def destroy(self):
"""Cleanup calendar update timer"""
if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id:
GLib.source_remove(self._calendar_timer_id)
self._calendar_timer_id = None
super().destroy()
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):
from utils.functions import clear_children
clear_children(self.calendar_grid)
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):
self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update)
def destroy(self):
"""Cleanup system info update timer"""
if hasattr(self, "_system_timer_id") and self._system_timer_id:
GLib.source_remove(self._system_timer_id)
self._system_timer_id = None
super().destroy()
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:
logger.error(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 Exception as e:
logger.error(f"An error occurred: {e}")
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:
logger.error(f"Error: {e}")
return True
class Deskwidgets:
"""Desktop widgets manager - handles all desktop widgets."""
config = load_config()
def __init__(self):
# This class now just manages other windows instead of being one itself
# Create separate independent windows as attributes
self.top_left = Window(
anchor="top left",
title="modus-widgets",
exclusivity="none",
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",
orientation="h",
layer="bottom",
exclusivity="none",
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)