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

Merged
S4NKALP merged 11 commits from feat/desktop into macos 2026-07-27 12:38:39 +00:00
20 changed files with 2250 additions and 893 deletions

No files matched your search

+1
View File
@@ -12,3 +12,4 @@ src/styles/colors.css
.venv/*
.venv/
config/plugins/
config/desktop.toml
+18
View File
@@ -122,6 +122,23 @@ You can also toggle most settings from the **Settings window** (`Super + I`).
See [Configuration Guide](docs/configuration.md) for full reference.
## Desktop Widgets
Drop a `.py` file in `config/desktop/` and it appears on your desktop instantly:
```bash
cp examples/desktop/example.py config/desktop/example.py
```
Edit positions in `config/desktop.toml` or drag widgets in edit mode (right-click → Edit Widgets).
```bash
nvim config/desktop/example.py # live reload, no restart needed
rm config/desktop/example.py # widget disappears within 2s
```
See [Desktop Widgets Guide](docs/desktop_widgets.md) for full docs and examples.
## Documentation
| Doc | What's inside |
@@ -131,6 +148,7 @@ See [Configuration Guide](docs/configuration.md) for full reference.
| [Styling Guide](docs/styling.md) | Matugen colors, CSS customization |
| [Architecture Overview](docs/architecture.md) | App Switcher C-backend, Spotlight engine, Services |
| [Spotlight Plugins](docs/plugins.md) | Install/customize plugins, write your own |
| [Desktop Widgets](docs/desktop_widgets.md) | Custom desktop widgets, user widget system |
| [FAQs & Tips](docs/faqs_tips.md) | Troubleshooting, shortcuts, live reload |
## Custom Mods
+30 -1
View File
@@ -19,6 +19,7 @@ Entry point is `start.py`, delegates to `src/main.py`. `main.py` instantiates
- **OSD**: Volume/brightness overlays (`src/window/osd/`)
- **Settings**: Configuration GUI (`src/window/settings/`)
- **Wallpaper Picker**: Wallpaper browser and management (`src/window/wallpaper/`)
- **Desktop Widgets**: User-defined widgets on the desktop background (`src/window/desktop/`)
## 2. Spotlight Search Engine
@@ -87,7 +88,35 @@ macOS-style menu bar in the top panel.
- Python daemon (`src/window/globalmenu/service.py`) listens for exported menus and
renders them as Fabric widgets in the panel
## 5. State Management & Services
## 5. Desktop Widgets
Desktop widgets render GTK widgets directly on the wallpaper background.
### Widget System
- **Registry** (`src/window/desktop/registry.py`): Stores widget classes, sizes, and default positions keyed by string id.
- **Built-in widgets** (`src/window/desktop/*.py`): Date, weather, calendar, CPU/RAM info. Each module registers itself at import time.
- **User widgets** (`config/desktop/*.py`): Drop-in `.py` files that call `DesktopWidgetRegistry.register()`. Loaded dynamically at startup and watched for live reload.
- **Position Manager** (`src/window/desktop/main.py`): Reads widget positions from `config/desktop.toml` (per-monitor, fractional coords). Merges with defaults from registry. Drag/drop saves overrides.
- **Desktop Window** (`src/window/desktop/main.py`): `DesktopWidgetWindow` uses a `Gtk.Fixed` container to position widgets at fractional screen coordinates. Handles edit mode (drag and drop), fade animations, and file monitoring. Uses a **partial input region** (via `cairo.Region`) so only widget areas receive pointer input — empty desktop space passes through to windows below, preventing interference with Hyprland keybinds like `Super+Q`.
### Lifecycle
```
Startup:
1. Import built-in widget modules → register widgets
2. Scan config/desktop/*.py → import & register user widgets
3. Read config/desktop.toml → get positions (or populate defaults)
4. Create DesktopWidgetWindow per monitor → render widgets
Runtime:
File monitor on config/desktop/ → detect add/delete
→ unregister stale widgets → re-import new widgets → rebuild
2-second poll fallback for missed file monitor events
Drag/drop in edit mode → save position to desktop.toml
```
## 6. State Management & Services
Services in `src/services/` monitor system state asynchronously and emit
signals:
+58
View File
@@ -9,6 +9,8 @@ Modus is configured via TOML files in the `config/` directory, plus a graphical
| `config.toml` | Main settings (features, widgets, paths) | Restart |
| `mods.toml` | Custom panel buttons (menus, shell commands) | Instant |
| `dock.toml` | Pinned dock apps | Restart |
| `desktop.toml` | Desktop widget positions (per-monitor) | Restart |
| `config/desktop/*.py` | User desktop widgets | Instant (live reload) |
## Settings Window
@@ -156,3 +158,59 @@ pinned = [
```
Use the exact `.desktop` file name or application ID.
---
## Desktop Widgets (`config/desktop/` + `desktop.toml`)
Desktop widgets render directly on your wallpaper. Drop a `.py` file in `config/desktop/` and it appears live.
### Adding a Widget
```bash
cp examples/desktop/example.py config/desktop/example.py
```
The widget file calls `DesktopWidgetRegistry.register()` with a key, class, size, and default position:
```python
DesktopWidgetRegistry.register("my_widget", MyWidget, (174, 174), (0.5, 0.5))
```
### Positioning
Positions live in `config/desktop.toml`, one entry per monitor:
```toml
[[0]]
key = "my_widget"
px = 0.5
py = 0.5
```
- `0` = monitor ID
- `px`, `py` = 0.0–1.0 fractions of screen
Drag widgets in edit mode (right-click desktop → Edit Widgets) to reposition.
### Removing a Widget
Delete the `.py` file from `config/desktop/`:
```bash
rm config/desktop/example.py
```
Widget disappears within 2 seconds. The entry is also removed from `desktop.toml`.
### Built-in Widgets
| Key | Description |
|-----|-------------|
| `date` | Date display |
| `weather` | Weather info |
| `calendar` | Monthly calendar |
| `cpu_info` | CPU usage |
| `ram_info` | RAM usage |
See [Desktop Widgets Guide](desktop_widgets.md) for full documentation and examples.
+221
View File
@@ -0,0 +1,221 @@
# Desktop Widgets Guide
Modus displays widgets directly on your desktop background. Drop a `.py` file in `config/desktop/` and it appears live — no restart needed.
## Quick Start
Copy the example widget:
```bash
cp examples/desktop/example.py config/desktop/example.py
```
The widget appears on your desktop immediately. Edit positions by dragging in edit mode (right-click desktop → Edit Widgets) or by editing `config/desktop.toml`.
## How It Works
1. On startup, Modus scans `config/desktop/` for `.py` files.
2. Each file is imported. It calls `DesktopWidgetRegistry.register()` to register a widget class with a key, size, and default position.
3. `config/desktop.toml` stores per-monitor positions (`px`, `py` as 0.0–1.0 fractions).
4. A file monitor + 2-second poll watches `config/desktop/` — adding or removing files live-reloads widgets.
## Writing a Desktop Widget
### Minimal Example
File: `config/desktop/example.py`
```python
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from window.desktop.registry import DesktopWidgetRegistry
class ExampleClockWidget(Box):
def __init__(self, **kwargs):
super().__init__(
name="example-clock-widget",
orientation="v",
h_expand=True,
v_expand=True,
h_align="center",
v_align="center",
**kwargs,
)
self.label = Label(
name="example-clock-label",
label="Hello Desktop",
justification="center",
)
self.add(self.label)
DesktopWidgetRegistry.register(
"example", # unique key (used in desktop.toml)
ExampleClockWidget, # GTK widget class
(174, 174), # size (width, height) in pixels
(0.45, 0.45), # default position (px, py) as 0.0–1.0 fractions
)
```
### `DesktopWidgetRegistry.register()` Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `key` | `str` | Yes | Unique identifier (e.g., `"my_widget"`). Used in `desktop.toml`. |
| `widget_cls` | `class` | Yes | GTK widget class (must inherit from a Fabric/GTK widget). |
| `size` | `tuple[int, int]` | Yes | `(width, height)` in pixels. |
| `default_position` | `tuple[float, float]` | No | `(px, py)` as 0.0–1.0 screen fractions. Default `(0.0, 0.0)`. |
### Widget Rules
- Widget must be a GTK widget (inherit from `fabric.widgets.box.Box`, `fabric.widgets.label.Label`, etc.)
- Use `name=` for CSS styling (maps to `#example-clock-widget` in CSS)
- Set `h_expand=True` / `v_expand=True` for flexible sizing
- Call `DesktopWidgetRegistry.register()` at module level (not inside a function)
- The `key` must be unique across all widgets
## Built-in Widgets
| Key | Widget | Default Position |
|-----|--------|-----------------|
| `date` | Date display | Top-left `(0.0, 0.0)` |
| `weather` | Weather info | Top-left `(0.097, 0.0)` |
| `calendar` | Monthly calendar | Top-left `(0.191, 0.001)` |
| `cpu_info` | CPU usage | Bottom-right `(0.810, 0.827)` |
| `ram_info` | RAM usage | Bottom-right `(0.905, 0.827)` |
## Positioning
### Edit Mode (Drag and Drop)
1. Right-click desktop → **Edit Widgets**
2. Drag widgets to new positions
3. Right-click → **Done Edit**
4. Positions save to `config/desktop.toml`
### Config File
Edit `config/desktop.toml` directly:
```toml
[[0]]
key = "my_widget"
px = 0.5
py = 0.5
```
- `0` = monitor ID (0 for primary)
- `px`, `py` = position as 0.0–1.0 fractions of screen
### Default Position
Set in your widget file's `register()` call. Used when no entry exists in `desktop.toml`:
```python
DesktopWidgetRegistry.register("my_widget", MyWidget, (174, 174), (0.3, 0.7))
```
## Lifecycle
| Action | What Happens |
|--------|-------------|
| Add `.py` file to `config/desktop/` | Widget appears on desktop (live) |
| Delete `.py` file from `config/desktop/` | Widget disappears (within 2s) |
| Edit `.py` file in `config/desktop/` | Widget rebuilds on next file change |
| Drag widget in edit mode | Position saves to `desktop.toml` |
| Edit `desktop.toml` manually | Position updates on restart |
## Styling Widgets
Use the widget's `name` parameter for CSS targeting:
```python
self.label = Label(name="my-widget-label", label="Hello")
```
In your CSS (`src/styles/`), target with:
```css
#my-widget-label {
font-size: 24px;
color: @theme_text_color;
}
#my-widget-label:selected {
color: @accent_color;
}
```
## Advanced: Using Services
Widgets can access Modus services for live data:
```python
from fabric.utils import GLib, invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from window.desktop.registry import DesktopWidgetRegistry
class LiveClockWidget(Box):
def __init__(self, **kwargs):
super().__init__(
name="live-clock",
orientation="v",
h_align="center",
v_align="center",
**kwargs,
)
self.label = Label(name="clock-label", label="--:--")
self.add(self.label)
self._timer_id = invoke_repeater(1000, self._update)
def _update(self) -> bool:
import datetime
now = datetime.datetime.now()
self.label.set_label(now.strftime("%H:%M"))
return True
def destroy(self):
if hasattr(self, "_timer_id") and self._timer_id:
GLib.source_remove(self._timer_id)
super().destroy()
DesktopWidgetRegistry.register("live_clock", LiveClockWidget, (174, 174), (0.5, 0.0))
```
## Input Regions & Keybind Compatibility
The desktop widget window uses a **partial input region** — only the rectangular areas occupied by widgets receive pointer and keyboard input. Empty desktop space passes through to windows below.
This means:
- Hyprland keybinds (e.g., `Super+Q` minimize) work normally even with widgets visible
- Right-clicking on widget areas opens the edit context menu; right-clicking on empty desktop passes through
- Drag and drop works within widget areas
No configuration needed — this is automatic. If widgets ever interfere with keybinds, check that the widget's `size` in `register()` matches its actual rendered size.
## Troubleshooting
### Widget not appearing?
- Check the file is in `config/desktop/` (not a subdirectory)
- Check `journalctl --user -f -t modus` for `[DesktopWidgets]` errors
- Ensure the file calls `DesktopWidgetRegistry.register()` at module level
- Ensure the `key` is unique (not conflicting with built-in widgets)
### Widget still showing after deleting the file?
The file monitor + poll should clean up within 2 seconds. If not:
1. Check `journalctl --user -f -t modus` for `[DesktopWidgets] poll found stale:` messages
2. Restart Modus: `uv run start`
### Position resets after restart?
- `desktop.toml` stores your custom positions
- If you delete `desktop.toml`, positions revert to defaults from `register()` calls
- Ensure `config/desktop.toml` is writable
+32
View File
@@ -49,6 +49,34 @@ Update `weather_location` in `config.toml` (e.g., `"London, UK"` or
- The venv lives at `config/plugins/.venvs/<plugin_name>/`
- Delete that directory to force re-install on next load
### 8. Desktop widget not appearing?
- Check the file is in `config/desktop/` (not a subdirectory)
- Ensure it calls `DesktopWidgetRegistry.register()` at module level
- Check `journalctl --user -f -t modus` for `[DesktopWidgets]` errors
- Ensure the widget `key` is unique (not `"date"`, `"weather"`, etc.)
### 9. Desktop widget still showing after deleting the file?
The cleanup runs within 2 seconds via file monitor + poll fallback. If it persists:
- Check `journalctl --user -f -t modus` for stale widget messages
- Restart Modus: `uv run start`
### 10. Desktop widget position resets after restart?
- Positions are stored in `config/desktop.toml`
- If you delete `desktop.toml`, positions revert to defaults from `register()` calls
- Ensure `config/desktop.toml` is writable
### 11. Desktop widget interfering with window keybinds (e.g., Super+Q)?
The desktop window is a full-screen layer shell surface, which can intercept pointer events and make Hyprland treat it as the focused window. Modus handles this by using a **partial input region** — only widget areas receive input; empty desktop space passes through.
If keybinds still don't work:
- Check the widget's `size` in `register()` matches its actual rendered size
- Ensure no widget is expanding beyond its expected bounds (e.g., `h_expand=True` without fixed width)
- Restart Modus: `uv run start`
---
## Pro Tips
@@ -56,6 +84,10 @@ Update `weather_location` in `config.toml` (e.g., `"London, UK"` or
- **Live Reload for Mods**: `config/mods.toml` is monitored for changes. Edits
apply instantly — no restart needed
- **Desktop Widgets**: Drop `.py` files in `config/desktop/` for instant widgets.
Edit `config/desktop.toml` or drag in edit mode to reposition.
Remove by deleting the `.py` file — disappears within 2 seconds.
- **Pinning Apps**: Use exact `.desktop` filename in `dock.toml` (e.g.,
`"org.gnome.Nautilus"`, not `"Files"`)
+31
View File
@@ -0,0 +1,31 @@
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from window.desktop.registry import DesktopWidgetRegistry
class ExampleClockWidget(Box):
def __init__(self, **kwargs):
super().__init__(
name="example-widget",
orientation="v",
h_expand=True,
v_expand=True,
h_align="center",
v_align="center",
**kwargs,
)
self.label = Label(
name="examplelabel",
label="Hello Desktop",
justification="center",
)
self.add(self.label)
DesktopWidgetRegistry.register(
"example",
ExampleClockWidget,
(174, 174),
(0.45, 0.45),
)
+2 -5
View File
@@ -16,12 +16,12 @@ from fabric import Application
from fabric.utils import get_relative_path, logger, monitor_file
from services.config import config
from services.desktop_widget import DesktopWidgetService
from services.keyboard_layout import KeyboardLayout
from services.sysauth import SysauthService
from shared.data import APP_NAME, load_config
from utils.functions import set_process_name
from utils.gtk_utils import generate_colors_from_wallpaper
from window.desktop.widget import Deskwidgets
from window.dock import Dock
from window.lock import LockScreenWrapper
from window.notification.notification import ModusNoti
@@ -106,7 +106,6 @@ def main():
modusnoti = ModusNoti()
spotlight = SpotlightWindow()
panel.set_spotlight_toggle(spotlight.toggle)
deskwidget = Deskwidgets()
dock = Dock()
osd = OSDWindow()
screencapture = ScreenCaptureWindow()
@@ -116,8 +115,6 @@ def main():
for w in [
panel,
modusnoti,
deskwidget.top_left,
deskwidget.bottom_left,
osd,
switcher,
dock,
@@ -135,7 +132,6 @@ def main():
__main__.panel = panel
__main__.modusnoti = modusnoti
__main__.spotlight = spotlight
__main__.deskwidget = deskwidget
__main__.dock = dock
__main__.osd = osd
__main__.osd_show_audio = osd.osd_show_audio
@@ -144,6 +140,7 @@ def main():
__main__.lock_screen = LockScreenWrapper()
__main__.switch_keyboard_layout = KeyboardLayout.switch_keyboard_layout
__main__.settings = settings
__main__.desktop_widgets = DesktopWidgetService.get_instance()
app.run()
+84
View File
@@ -0,0 +1,84 @@
from fabric.core.service import Service, Signal
from fabric.utils import Gdk, logger
from window.desktop.main import DesktopWidgetWindow, position_manager
class DesktopWidgetService(Service):
_instance: "DesktopWidgetService | None" = None
@staticmethod
def get_instance() -> "DesktopWidgetService":
if DesktopWidgetService._instance is None:
DesktopWidgetService._instance = DesktopWidgetService()
return DesktopWidgetService._instance
@Signal
def widgets_changed(self, monitor_id: int) -> None: ...
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._windows: dict[int, DesktopWidgetWindow] = {}
display = Gdk.Display.get_default()
if display:
display.connect("monitor-added", self._on_monitor_added)
display.connect("monitor-removed", self._on_monitor_removed)
self._sync_monitors()
for mid, win in self._windows.items():
win.rebuild()
def _sync_monitors(self) -> None:
display = Gdk.Display.get_default()
current = set(range(display.get_n_monitors()))
existing = set(self._windows.keys())
for mid in existing - current:
self._remove_window(mid)
for mid in current - existing:
self._add_window(mid)
def _add_window(self, monitor_id: int) -> None:
if monitor_id in self._windows:
return
win = DesktopWidgetWindow(monitor_id)
self._windows[monitor_id] = win
logger.info(f"[DesktopWidgetService] window created for monitor {monitor_id}")
def _remove_window(self, monitor_id: int) -> None:
win = self._windows.pop(monitor_id, None)
if win:
win.destroy()
logger.info(
f"[DesktopWidgetService] window removed for monitor {monitor_id}"
)
def _on_monitor_added(self, _display, _monitor) -> None:
logger.info("[DesktopWidgetService] monitor added, resyncing...")
self._sync_monitors()
def _on_monitor_removed(self, _display, _monitor) -> None:
logger.info("[DesktopWidgetService] monitor removed, resyncing...")
self._sync_monitors()
def place(
self, monitor_id: int, key: str, px: float = 0.02, py: float = 0.04
) -> bool:
position_manager.save_position(monitor_id, key, px, py)
win = self._windows.get(monitor_id)
if win:
win.add_widget(key, px, py)
self.widgets_changed(monitor_id)
return True
def remove(self, monitor_id: int, key: str) -> bool:
position_manager.remove_override(monitor_id, key)
win = self._windows.get(monitor_id)
if win:
win.remove_widget(key)
self.widgets_changed(monitor_id)
return True
def get_window(self, monitor_id: int) -> "DesktopWidgetWindow | None":
return self._windows.get(monitor_id)
+592
View File
@@ -0,0 +1,592 @@
import datetime
import json
import threading
import time
import urllib.request
from pathlib import Path
from typing import Optional, Tuple
from fabric.core.service import Property, Service, Signal
from fabric.utils import GLib, logger
from shared.data import CACHE_DIR
CACHE_DURATION = 600
STALE_CACHE_MAX = 1800
UPDATE_INTERVAL = 600
IP_LOCATION_API = "http://ip-api.com/json/"
WEATHER_API_BASE = "https://api.open-meteo.com/v1/forecast"
_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",
)
)
WEATHER_EMOJI_MAP = {
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: "⛈️",
}
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_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",
}
WEATHER_GRADIENT_MAP = {
0: "weather-clear",
1: "weather-mostly-clear",
2: "weather-partly-cloudy",
3: "weather-overcast",
45: "weather-fog",
48: "weather-fog",
51: "weather-light-rain",
53: "weather-rain",
55: "weather-rain",
61: "weather-light-rain",
80: "weather-light-rain",
63: "weather-heavy-rain",
65: "weather-heavy-rain",
81: "weather-heavy-rain",
82: "weather-storm",
56: "weather-snow",
57: "weather-snow",
66: "weather-snow",
67: "weather-snow",
71: "weather-snow",
73: "weather-heavy-snow",
75: "weather-heavy-snow",
77: "weather-snow",
85: "weather-snow",
86: "weather-heavy-snow",
95: "weather-storm",
96: "weather-storm",
99: "weather-storm",
}
class Cache:
def __init__(self, cache_file: Path, max_age: int):
self._cache_file = cache_file
self._max_age = max_age
self._data = None
self._time = 0
self._lock = threading.Lock()
self._load_cache()
def _load_cache(self):
try:
if self._cache_file.exists():
with self._lock:
self._time = self._cache_file.stat().st_mtime
self._data = json.loads(self._cache_file.read_text())
except Exception:
pass
def is_fresh(self) -> bool:
with self._lock:
return bool(self._data and time.time() - self._time < CACHE_DURATION)
def is_usable(self) -> bool:
with self._lock:
return bool(self._data and time.time() - self._time < self._max_age)
def get(self, allow_stale=False) -> Optional[dict]:
if self.is_fresh() or (allow_stale and self.is_usable()):
with self._lock:
return self._data.copy() if self._data else None
return None
def set(self, data: dict):
try:
tmp = self._cache_file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, separators=(",", ":")))
tmp.rename(self._cache_file)
with self._lock:
self._data = data
self._time = time.time()
except Exception:
pass
def fetch_api(url: str, timeout: int = 10) -> Optional[dict]:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return (
json.loads(response.read().decode()) if response.status == 200 else None
)
except Exception:
return None
def get_weather_info(code: int, is_day: int = 1) -> Tuple[str, str, str, str]:
emoji = WEATHER_EMOJI_MAP.get(code, "🌡️")
icon = WEATHER_ICON_MAP.get(code, "weather-none-available")
if not is_day and icon in _NIGHT_VARIANT_ICONS:
icon = f"{icon}-night"
description = WEATHER_DESC_MAP.get(code, "Unknown")
gradient = WEATHER_GRADIENT_MAP.get(code, "weather-clear")
return emoji, icon, description, gradient
def get_wind_direction(degrees: float) -> str:
dirs = [
"N",
"NNE",
"NE",
"ENE",
"E",
"ESE",
"SE",
"SSE",
"S",
"SSW",
"SW",
"WSW",
"W",
"WNW",
"NW",
"NNW",
]
return dirs[round(degrees / 22.5) % 16]
class Weather(Service):
_instance = None
@classmethod
def get_default(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
@Signal
def ready(self): ...
@Property(float, "readable", default_value=0.0)
def temperature(self) -> float:
return self._property_helper_temperature
@Property(float, "readable", default_value=0.0)
def feels_like(self) -> float:
return self._property_helper_feels_like
@Property(int, "readable", default_value=0)
def humidity(self) -> int:
return self._property_helper_humidity
@Property(int, "readable", default_value=0)
def pressure(self) -> int:
return self._property_helper_pressure
@Property(float, "readable", default_value=0.0)
def wind_speed(self) -> float:
return self._property_helper_wind_speed
@Property(str, "readable", default_value="")
def wind_direction(self) -> str:
return self._property_helper_wind_direction
@Property(float, "readable", default_value=0.0)
def precipitation(self) -> float:
return self._property_helper_precipitation
@Property(int, "readable", default_value=0)
def weather_code(self) -> int:
return self._property_helper_weather_code
@Property(str, "readable", default_value="🌡️")
def weather_emoji(self) -> str:
return self._property_helper_weather_emoji
@Property(str, "readable", default_value="cloud-sun-duotone")
def weather_icon(self) -> str:
return self._property_helper_weather_icon
@Property(str, "readable", default_value="weather-clear")
def weather_gradient(self) -> str:
return self._property_helper_weather_gradient
@Property(str, "readable", default_value="Unknown")
def weather_description(self) -> str:
return self._property_helper_weather_description
@Property(str, "readable", default_value="Loading...")
def location(self) -> str:
return self._property_helper_location
@Property(bool, "readable", default_value=True)
def is_loading(self) -> bool:
return self._property_helper_is_loading
@Property(bool, "readable", default_value=False)
def has_error(self) -> bool:
return self._property_helper_has_error
@Property(str, "readable", default_value="")
def error_message(self) -> str:
return self._property_helper_error_message
@Property(object, "readable", default_value=None)
def hourly_forecast(self) -> list:
return self._property_helper_hourly_forecast
@Property(object, "readable", default_value=None)
def daily_forecast(self) -> list:
return self._property_helper_daily_forecast
def __init__(self, **kwargs):
self._property_helper_temperature = 0.0
self._property_helper_feels_like = 0.0
self._property_helper_humidity = 0
self._property_helper_pressure = 0
self._property_helper_wind_speed = 0.0
self._property_helper_wind_direction = ""
self._property_helper_precipitation = 0.0
self._property_helper_weather_code = 0
self._property_helper_weather_emoji = "🌡️"
self._property_helper_weather_icon = "cloud-sun-duotone"
self._property_helper_weather_description = "Unknown"
self._property_helper_weather_gradient = "weather-clear"
self._property_helper_location = "Loading..."
self._property_helper_is_loading = True
self._property_helper_has_error = False
self._property_helper_error_message = ""
self._property_helper_hourly_forecast = []
self._property_helper_daily_forecast = []
self._weather_cache = Cache(
Path(CACHE_DIR) / "weather_cache.json", STALE_CACHE_MAX
)
self._location_cache = Cache(Path(CACHE_DIR) / "location_cache.json", 24 * 3600)
super().__init__(**kwargs)
cached_weather = self._weather_cache.get(allow_stale=True)
cached_location = self._location_cache.get(allow_stale=True)
if cached_weather and cached_location:
self._update_properties(cached_weather, cached_location)
self._start_fetch_thread()
GLib.timeout_add_seconds(UPDATE_INTERVAL, self._on_periodic_update)
from services.config import on_config_change
on_config_change(self._on_config_changed)
self.emit("ready")
def _on_config_changed(self, new_config: dict, old_config: dict) -> None:
if new_config.get("weather_location") != old_config.get("weather_location"):
self.refresh()
def _start_fetch_thread(self) -> None:
thread = threading.Thread(target=self._fetch_weather_data, daemon=True)
thread.start()
def _fetch_weather_data(self) -> None:
try:
GLib.idle_add(self._set_loading, True)
location_data = self._location_cache.get()
from services.config import config
target_loc = config().get("weather_location", "").strip()
# Invalidate location cache if the config target changed
if location_data and location_data.get("config_location", "") != target_loc:
location_data = None
with self._weather_cache._lock:
self._weather_cache._data = None # Invalidate weather cache
if not location_data:
if target_loc:
import urllib.parse
encoded_loc = urllib.parse.quote(target_loc)
geocode_url = f"https://geocoding-api.open-meteo.com/v1/search?name={encoded_loc}&count=1"
raw = fetch_api(geocode_url, 10)
if not raw or not raw.get("results"):
GLib.idle_add(self._set_error, "Location not found")
return
result = raw["results"][0]
location_data = {
"lat": result["latitude"],
"lon": result["longitude"],
"city": result.get("name", "Unknown"),
"country": result.get("country", ""),
"config_location": target_loc,
}
else:
raw = fetch_api(IP_LOCATION_API, 10)
if not raw or raw.get("status") != "success":
GLib.idle_add(self._set_error, "Location unavailable")
return
location_data = {
"lat": raw["lat"],
"lon": raw["lon"],
"city": raw.get("city", "Unknown"),
"country": raw.get("country", ""),
"config_location": "",
}
self._location_cache.set(location_data)
weather_data = self._weather_cache.get()
if not weather_data:
lat, lon = location_data["lat"], location_data["lon"]
params = "&".join(
[
f"latitude={lat}",
f"longitude={lon}",
"current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,surface_pressure,wind_speed_10m,wind_direction_10m,is_day",
"hourly=temperature_2m,weather_code,precipitation_probability",
"daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum",
"timezone=auto",
"forecast_days=7",
]
)
weather_data = fetch_api(f"{WEATHER_API_BASE}?{params}", 15)
if not weather_data:
weather_data = self._weather_cache.get(allow_stale=True)
if not weather_data:
GLib.idle_add(self._set_error, "Weather unavailable")
return
else:
self._weather_cache.set(weather_data)
GLib.idle_add(self._update_properties, weather_data, location_data)
except Exception as e:
logger.error(f"[WeatherService] Fetch error: {e}")
GLib.idle_add(self._set_error, str(e))
def _update_properties(self, weather_data: dict, location_data: dict) -> None:
try:
current = weather_data["current"]
self._property_helper_temperature = current["temperature_2m"]
self._property_helper_feels_like = current.get(
"apparent_temperature", current["temperature_2m"]
)
self._property_helper_humidity = round(current["relative_humidity_2m"])
self._property_helper_pressure = round(
current.get("surface_pressure", 1013)
)
self._property_helper_wind_speed = current.get("wind_speed_10m", 0)
self._property_helper_wind_direction = get_wind_direction(
current.get("wind_direction_10m", 0)
)
self._property_helper_precipitation = current.get("precipitation", 0)
self._property_helper_weather_code = current["weather_code"]
is_day = current.get("is_day", 1)
emoji, icon, description, gradient = get_weather_info(
self._property_helper_weather_code, is_day
)
self._property_helper_weather_emoji = emoji
self._property_helper_weather_icon = icon
self._property_helper_weather_description = description
self._property_helper_weather_gradient = gradient
city = location_data.get("city", "Unknown")
country = location_data.get("country", "")
self._property_helper_location = f"{city}, {country}" if country else city
self._property_helper_hourly_forecast = self._process_hourly_forecast(
weather_data.get("hourly", {})
)
self._property_helper_daily_forecast = self._process_daily_forecast(
weather_data.get("daily", {})
)
self._property_helper_has_error = False
self._property_helper_error_message = ""
self._property_helper_is_loading = False
for prop in [
"temperature",
"feels-like",
"humidity",
"pressure",
"wind-speed",
"wind-direction",
"precipitation",
"weather-code",
"weather-emoji",
"weather-icon",
"weather-description",
"weather-gradient",
"location",
"is-loading",
"has-error",
"error-message",
"hourly-forecast",
"daily-forecast",
]:
self.notify(prop)
except Exception as e:
logger.error(f"[WeatherService] Update error: {e}")
self._set_error(str(e))
def _process_hourly_forecast(self, hourly: dict) -> list:
if not hourly or not hourly.get("time"):
return []
forecast = []
current_hour = datetime.datetime.now().hour
for i in range(current_hour, min(current_hour + 24, len(hourly["time"]))):
try:
forecast.append(
{
"time": hourly["time"][i],
"temperature": hourly["temperature_2m"][i],
"weather_code": hourly["weather_code"][i],
"precipitation_probability": hourly.get(
"precipitation_probability", [0] * len(hourly["time"])
)[i],
}
)
except (IndexError, KeyError):
continue
return forecast
def _process_daily_forecast(self, daily: dict) -> list:
if not daily or not daily.get("time"):
return []
forecast = []
for i in range(min(7, len(daily["time"]))):
try:
forecast.append(
{
"date": daily["time"][i],
"temperature_max": daily["temperature_2m_max"][i],
"temperature_min": daily["temperature_2m_min"][i],
"weather_code": daily["weather_code"][i],
"precipitation": daily.get(
"precipitation_sum", [0] * len(daily["time"])
)[i],
}
)
except (IndexError, KeyError):
continue
return forecast
def _set_loading(self, value: bool) -> None:
self._property_helper_is_loading = value
self.notify("is-loading")
def _set_error(self, message: str) -> None:
self._property_helper_has_error = True
self._property_helper_error_message = message
self._property_helper_is_loading = False
self.notify("has-error")
self.notify("error-message")
self.notify("is-loading")
def _on_periodic_update(self) -> bool:
self._start_fetch_thread()
return True
def refresh(self) -> None:
self._start_fetch_thread()
def get_weather_info_for_code(
self, code: int, is_day: int = 1
) -> Tuple[str, str, str, str]:
return get_weather_info(code, is_day)
+16 -4
View File
@@ -1,5 +1,5 @@
#weather-container {
margin: 5px 5px 20px 5px;
margin: 0px;
min-width: 170px;
min-height: 170px;
border-radius: apply(radius-lg);
@@ -88,7 +88,7 @@
/* Calendar Widget */
#calendar-box-widget {
background: @glass-widget;
margin: 5px 20px 20px 10px;
margin: 0px;
min-width: 170px;
min-height: 170px;
border-radius: apply(radius-lg);
@@ -192,7 +192,7 @@
#date-container {
background: @glass-widget;
margin: 5px 10px 20px 5px;
margin: 0px;
min-width: 170px;
min-height: 170px;
border-radius: apply(radius-lg);
@@ -202,7 +202,7 @@
/* System Info Widget CSS */
#info-box-widget {
background: @glass-widget;
margin: 5px 5px 5px 5px;
margin: 0px;
min-width: 170px;
min-height: 170px;
border-radius: apply(radius-lg);
@@ -245,3 +245,15 @@
font-size: apply(font-base);
font-weight: 600;
}
/* Desktop widget edit mode & DnD */
.edit-mode {
border: 2px dashed @glass-accent;
border-radius: apply(radius-lg);
transition: all apply(transition-fast);
}
.edit-mode:hover {
border-color: @glass-text;
background-color: alpha(@glass-accent, 0.08);
}
+148
View File
@@ -0,0 +1,148 @@
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), (0.19115, 0.00095)
)
-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), (0.0, 0.0))
+609
View File
@@ -0,0 +1,609 @@
import importlib
import os
import pkgutil
import sys
import cairo
import tomlkit
from fabric.utils import Gdk, Gio, 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) #
_CONFIG_DESKTOP_DIR = os.path.join(
os.path.dirname(toml_file("desktop.toml")), "desktop"
)
_TARGET = Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)
_user_widget_modules: dict[str, str] = {}
_user_key_to_file: dict[str, str] = {}
def _load_user_widgets() -> set[str]:
"""Import every .py in config/desktop/. Returns set of loaded keys."""
if not os.path.isdir(_CONFIG_DESKTOP_DIR):
return set()
loaded_keys: set[str] = set()
for fname in os.listdir(_CONFIG_DESKTOP_DIR):
if not fname.endswith(".py") or fname.startswith("_"):
continue
module_name = fname[:-3]
full_path = os.path.join(_CONFIG_DESKTOP_DIR, fname)
try:
before_keys = set(DesktopWidgetRegistry._widgets)
spec = importlib.util.spec_from_file_location(
f"user_widget_{module_name}", full_path
)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
_user_widget_modules[module_name] = spec.name
new_keys = set(DesktopWidgetRegistry._widgets) - before_keys
for k in new_keys:
_user_key_to_file[k] = module_name
DesktopWidgetRegistry.register_user(k)
loaded_keys.update(new_keys)
except Exception as e:
logger.error(f"[DesktopWidgets] failed to load user widget {fname}: {e}")
return loaded_keys
_load_user_widgets()
class PositionManager:
"""Merges widget defaults from registry with user overrides in desktop.toml.
config/desktop/*.py widgets register themselves with a default position.
desktop.toml stores drag-and-drop overrides per monitor.
"""
_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 or not state[mid_str]:
state[mid_str] = []
for key in DesktopWidgetRegistry._widgets:
pos = DesktopWidgetRegistry.get_default_position(key)
px, py = pos if pos else (0.0, 0.0)
state[mid_str].append({"key": key, "px": px, "py": py})
cls._write(state)
else:
registered = set(DesktopWidgetRegistry._widgets)
before = len(state[mid_str])
state[mid_str] = [e for e in state[mid_str] if e["key"] in registered]
for key in registered - {e["key"] for e in state[mid_str]}:
pos = DesktopWidgetRegistry.get_default_position(key)
px, py = pos if pos else (0.0, 0.0)
state[mid_str].append({"key": key, "px": px, "py": py})
if len(state[mid_str]) != before:
cls._write(state)
return list(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] = []
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": round(px, 5), "py": round(py, 5)})
cls._write(state)
@classmethod
def remove_override(cls, monitor_id: int, key: str) -> None:
"""Remove a drag/drop override so the widget reverts to its default position."""
state = cls._read()
mid_str = str(monitor_id)
if mid_str not in state:
return
state[mid_str] = [e for e in state[mid_str] if e["key"] != key]
cls._write(state)
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._ready = False
self._in_size_allocate = False
self._edit_mode = False
self._dragging_key: str | None = None
self._dragging_eb: Gtk.EventBox | None = None
self._drag_drop_success: bool = False
self._input_rebuild_pending: bool = False
self._root = Box(h_expand=True, v_expand=True)
self._root.add(self._fixed)
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)
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="none",
layer="bottom",
child=self._root,
keyboard_mode="none",
visible=True,
name=f"desktop-widgets-{monitor_id}",
)
self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.connect("size-allocate", self._on_size_allocate)
self.connect("button-press-event", self._on_window_button_press)
self._setup_drag_dest()
self._setup_file_monitor()
self._last_user_files: set[str] = set()
GLib.timeout_add(2000, self._poll_user_widgets)
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)
# fallback poll — catches deletions that Gio file monitor misses
def _poll_user_widgets(self) -> bool:
if not os.path.isdir(_CONFIG_DESKTOP_DIR):
return True
current = {
f[:-3]
for f in os.listdir(_CONFIG_DESKTOP_DIR)
if f.endswith(".py") and not f.startswith("_")
}
if current != self._last_user_files:
self._last_user_files = current
stale = {k for k, f in _user_key_to_file.items() if f not in current}
if stale:
logger.info(f"[DesktopWidgets] poll found stale: {stale}")
for key in stale:
mod_name = _user_key_to_file.pop(key, None)
if mod_name:
sys.modules.pop(f"user_widget_{mod_name}", None)
_user_widget_modules.pop(mod_name, None)
DesktopWidgetRegistry.unregister_user_keys(stale)
_load_user_widgets()
self.rebuild()
return True
# file monitor — live-rebuild when config/desktop/ changes
def _setup_file_monitor(self) -> None:
if not os.path.isdir(_CONFIG_DESKTOP_DIR):
os.makedirs(_CONFIG_DESKTOP_DIR, exist_ok=True)
config_dir = Gio.File.new_for_path(_CONFIG_DESKTOP_DIR)
monitor = config_dir.monitor_directory(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self._on_config_changed)
self._config_monitor = monitor
def _on_config_changed(self, _monitor, _file, _other, event_type) -> None:
logger.info(f"[DesktopWidgets] file monitor event: {event_type}")
if event_type in (
Gio.FileMonitorEvent.CREATED,
Gio.FileMonitorEvent.DELETED,
Gio.FileMonitorEvent.CHANGED,
):
GLib.idle_add(self._on_widget_dir_changed)
def _on_widget_dir_changed(self) -> bool:
current_files = {
f[:-3]
for f in os.listdir(_CONFIG_DESKTOP_DIR)
if f.endswith(".py") and not f.startswith("_")
}
stale_keys = {k for k, f in _user_key_to_file.items() if f not in current_files}
logger.info(
f"[DesktopWidgets] dir changed: current={current_files}, "
f"stale={stale_keys}, tracked={set(_user_key_to_file.keys())}"
)
for key in stale_keys:
mod_name = _user_key_to_file.pop(key, None)
if mod_name:
sys.modules.pop(f"user_widget_{mod_name}", None)
_user_widget_modules.pop(mod_name, None)
DesktopWidgetRegistry.unregister_user_keys(stale_keys)
_load_user_widgets()
self.rebuild()
return False
# right-click context menu
def _on_window_button_press(self, widget, event: Gdk.EventButton) -> bool:
if event.button == 3:
self._show_context_menu(event)
return True
return False
def _show_context_menu(self, event: Gdk.EventButton) -> None:
menu = Gtk.Menu()
label_text = "Done Edit" if self._edit_mode else "Edit Widgets"
edit_item = Gtk.CheckMenuItem(label=label_text)
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)
# edit mode
def _on_edit_toggled(self, item) -> None:
self._edit_mode = item.get_active()
for key, eb in self._children.items():
sc = eb.get_style_context()
if self._edit_mode:
sc.add_class("edit-mode")
self._setup_applet_drag(eb, key)
else:
sc.remove_class("edit-mode")
eb.drag_source_unset()
# GTK DnD
def _setup_drag_dest(self) -> None:
self.drag_dest_set(
Gtk.DestDefaults.ALL,
[_TARGET],
Gdk.DragAction.MOVE,
)
target_list = self.drag_dest_get_target_list()
if target_list:
target_list.add_text_targets(0)
self.connect("drag-motion", self._on_drag_motion)
self.connect("drag-leave", self._on_drag_leave)
self.connect("drag-data-received", self._on_drag_data_received)
def _setup_applet_drag(self, eb: Gtk.EventBox, key: str) -> None:
eb.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK,
[_TARGET],
Gdk.DragAction.MOVE,
)
target_list = eb.drag_source_get_target_list()
if target_list:
target_list.add_text_targets(0)
eb.connect("drag-begin", self._on_applet_drag_begin, key)
eb.connect("drag-data-get", self._on_applet_drag_data_get, key)
eb.connect("drag-end", self._on_applet_drag_end, key)
def _on_applet_drag_begin(self, eb, ctx, key: str) -> None:
self._dragging_key = key
self._dragging_eb = eb
self._drag_drop_success = False
def _on_applet_drag_data_get(self, eb, ctx, data_obj, info, time, key: str) -> None:
data_obj.set_text(f"widget:{key}", -1)
def _on_applet_drag_end(self, eb, ctx, key: str) -> None:
if not self._drag_drop_success:
self._fixed.remove(eb)
self._fixed.put(
eb,
getattr(eb, "_target_x", 0),
getattr(eb, "_target_y", 0),
)
self._dragging_key = None
self._dragging_eb = None
self._schedule_input_rebuild()
def _on_drag_motion(self, widget, ctx, x, y, time) -> bool:
targets = [t.name() for t in ctx.list_targets()]
if "text/plain" not in targets or self._dragging_key is None:
Gdk.drag_status(ctx, 0, time)
return True
eb = self._dragging_eb
if eb is not None:
aw = eb.get_allocation().width or eb.get_preferred_width()[1]
ah = eb.get_allocation().height or eb.get_preferred_height()[1]
nx = max(0, min(self._win_w - aw, int(x - aw / 2)))
ny = max(0, min(self._win_h - ah, int(y - ah / 2)))
self._fixed.move(eb, nx, ny)
Gdk.drag_status(ctx, Gdk.DragAction.MOVE, time)
return True
def _on_drag_leave(self, widget, ctx, time) -> None:
pass
def _on_drag_data_received(self, widget, ctx, x, y, data, info, time) -> None:
payload = (data.get_text() or "") if data else ""
if not payload.startswith("widget:"):
Gtk.drag_finish(ctx, False, False, time)
return
key = payload.split(":", 1)[1]
eb = self._children.get(key)
if eb is None:
Gtk.drag_finish(ctx, False, False, time)
return
aw = eb.get_allocation().width or eb.get_preferred_width()[1]
ah = eb.get_allocation().height or eb.get_preferred_height()[1]
new_x = max(0, min(self._win_w - aw, int(x - aw / 2)))
new_y = max(0, min(self._win_h - ah, int(y - ah / 2)))
eb._target_x = new_x
eb._target_y = new_y
if self._win_w > 1 and self._win_h > 1:
position_manager.save_position(
self._monitor_id,
key,
new_x / self._win_w,
new_y / self._win_h,
)
self._fixed.remove(eb)
self._fixed.put(eb, new_x, new_y)
eb.show()
self._drag_drop_success = True
Gtk.drag_finish(ctx, True, False, time)
self._schedule_input_rebuild()
# size allocate
def do_size_allocate(self, alloc):
Gtk.Window.do_size_allocate(self, alloc)
self._schedule_input_rebuild()
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()
self._force_refresh()
self._schedule_input_rebuild()
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)
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))
eb._target_x = px
eb._target_y = py
self._fixed.remove(eb)
self._fixed.put(eb, px, py)
# widget management
def rebuild(self) -> None:
self._dragging_eb = None
self._dragging_key = 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.add(widget)
eb.show_all()
self._fixed.put(eb, 0, 0)
self._children[key] = eb
except Exception as e:
logger.error(f"[DesktopWidgetService] failed to build {key!r}: {e}")
self._reposition_all()
self._force_refresh()
self._schedule_input_rebuild()
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.add(widget)
eb.show_all()
self._fixed.put(eb, int(px * self._win_w), int(py * self._win_h))
self._children[key] = eb
self._schedule_input_rebuild()
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()
self._schedule_input_rebuild()
def _force_refresh(self):
self._in_size_allocate = True
self.hide()
self.show_all()
self._in_size_allocate = False
return False
# partial input region — only widget areas capture events, empty desktop passes through
def _schedule_input_rebuild(self) -> None:
if self._input_rebuild_pending:
return
self._input_rebuild_pending = True
GLib.idle_add(self._do_input_rebuild)
def _do_input_rebuild(self) -> bool:
self._input_rebuild_pending = False
self._rebuild_input_region()
return False
def _rebuild_input_region(self) -> None:
rects = []
for eb in self._children.values():
a = eb.get_allocation()
if a.width > 0 and a.height > 0:
rects.append(cairo.RectangleInt(a.x, a.y, a.width, a.height))
if not rects:
self.input_shape_combine_region(
cairo.Region(cairo.RectangleInt(0, 0, 0, 0))
)
return
region = cairo.Region(rects[0])
for rect in rects[1:]:
region.union(cairo.Region(rect))
self.input_shape_combine_region(region)
+42
View File
@@ -0,0 +1,42 @@
class DesktopWidgetRegistry:
_widgets = {}
_sizes = {}
_default_positions = {}
_user_keys: set[str] = set()
@classmethod
def register(
cls,
key: str,
widget_cls,
size: tuple[int, int],
default_position: tuple[float, float] | None = None,
):
cls._widgets[key] = widget_cls
cls._sizes[key] = size
if default_position is not None:
cls._default_positions[key] = default_position
@classmethod
def register_user(cls, key: str) -> None:
cls._user_keys.add(key)
@classmethod
def unregister_user_keys(cls, keys: set[str]) -> None:
for key in keys:
cls._widgets.pop(key, None)
cls._sizes.pop(key, None)
cls._default_positions.pop(key, None)
cls._user_keys.discard(key)
@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))
@classmethod
def get_default_position(cls, key: str) -> tuple[float, float] | None:
return cls._default_positions.get(key)
+176
View File
@@ -0,0 +1,176 @@
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), (0.90521, 0.82732)
)
DesktopWidgetRegistry.register(
"cpu_info", DesktopCpuInfoWidget, (174, 174), (0.81042, 0.82732)
)
+134
View File
@@ -0,0 +1,134 @@
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), (0.0974, 0.0)
)
-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)
+4 -1
View File
@@ -906,6 +906,7 @@ class NotificationRevealer(SlideRevealer):
self._swipe_in_progress = False
self._current_offset = 0
self._last_drag_time = 0
self._last_drag_x = 0
self._drag_velocity = 0
self._spring_back_duration = 200 # Duration for spring-back animation
self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width
@@ -1005,6 +1006,7 @@ class NotificationRevealer(SlideRevealer):
self._drag_start_y = event.y_root
self._current_offset = 0
self._last_drag_time = GLib.get_monotonic_time() / 1000
self._last_drag_x = event.x_root
self._drag_velocity = 0
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
@@ -1108,10 +1110,11 @@ class NotificationRevealer(SlideRevealer):
if self._last_drag_time > 0:
time_diff = current_time - self._last_drag_time
if time_diff > 0:
distance_diff = current_x - self._drag_start_x - self._current_offset
distance_diff = current_x - self._last_drag_x
self._drag_velocity = abs(distance_diff / time_diff)
self._last_drag_time = current_time
self._last_drag_x = current_x
def _on_animation_complete(self, is_hiding=False):
if is_hiding: