diff --git a/.gitignore b/.gitignore index e2e1fa0..67eca1a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ src/styles/colors.css .venv/* .venv/ config/plugins/ +config/desktop.toml diff --git a/README.md b/README.md index 379a9b4..45bb8c3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index d83eec8..fd2491d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/configuration.md b/docs/configuration.md index afeffb8..cb8fe78 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/desktop_widgets.md b/docs/desktop_widgets.md new file mode 100644 index 0000000..5d62879 --- /dev/null +++ b/docs/desktop_widgets.md @@ -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 diff --git a/docs/faqs_tips.md b/docs/faqs_tips.md index 5eb914b..7181632 100644 --- a/docs/faqs_tips.md +++ b/docs/faqs_tips.md @@ -49,6 +49,34 @@ Update `weather_location` in `config.toml` (e.g., `"London, UK"` or - The venv lives at `config/plugins/.venvs//` - 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"`) diff --git a/examples/desktop/example.py b/examples/desktop/example.py new file mode 100644 index 0000000..81b330f --- /dev/null +++ b/examples/desktop/example.py @@ -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), +) diff --git a/src/main.py b/src/main.py index c9544b1..c5fab38 100644 --- a/src/main.py +++ b/src/main.py @@ -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() diff --git a/src/services/desktop_widget.py b/src/services/desktop_widget.py new file mode 100644 index 0000000..36c5a2d --- /dev/null +++ b/src/services/desktop_widget.py @@ -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) diff --git a/src/services/weather.py b/src/services/weather.py new file mode 100644 index 0000000..6d049cf --- /dev/null +++ b/src/services/weather.py @@ -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) diff --git a/src/styles/widgets/desktop-widgets.css b/src/styles/widgets/desktop-widgets.css index 75d7266..b771cf5 100644 --- a/src/styles/widgets/desktop-widgets.css +++ b/src/styles/widgets/desktop-widgets.css @@ -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); +} diff --git a/src/window/desktop/calendar.py b/src/window/desktop/calendar.py new file mode 100644 index 0000000..7ffc097 --- /dev/null +++ b/src/window/desktop/calendar.py @@ -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) +) diff --git a/src/window/desktop/constants.py b/src/window/desktop/constants.py deleted file mode 100644 index f79a878..0000000 --- a/src/window/desktop/constants.py +++ /dev/null @@ -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 -] diff --git a/src/window/desktop/date.py b/src/window/desktop/date.py new file mode 100644 index 0000000..631a859 --- /dev/null +++ b/src/window/desktop/date.py @@ -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)) diff --git a/src/window/desktop/main.py b/src/window/desktop/main.py new file mode 100644 index 0000000..cabd850 --- /dev/null +++ b/src/window/desktop/main.py @@ -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) diff --git a/src/window/desktop/registry.py b/src/window/desktop/registry.py new file mode 100644 index 0000000..25bf9ef --- /dev/null +++ b/src/window/desktop/registry.py @@ -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) diff --git a/src/window/desktop/system.py b/src/window/desktop/system.py new file mode 100644 index 0000000..5b13ae9 --- /dev/null +++ b/src/window/desktop/system.py @@ -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) +) diff --git a/src/window/desktop/weather.py b/src/window/desktop/weather.py new file mode 100644 index 0000000..917766c --- /dev/null +++ b/src/window/desktop/weather.py @@ -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) +) diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py deleted file mode 100644 index 46f8051..0000000 --- a/src/window/desktop/widget.py +++ /dev/null @@ -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}" - "¤t_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) diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index 2334069..6acc116 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -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: