feat(desktop): now move widget with drag to any place of window #42
No files matched your search
@@ -12,3 +12,4 @@ src/styles/colors.css
|
||||
.venv/*
|
||||
.venv/
|
||||
config/plugins/
|
||||
config/desktop.toml
|
||||
@@ -122,6 +122,23 @@ You can also toggle most settings from the **Settings window** (`Super + I`).
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for full reference.
|
||||
|
||||
## Desktop Widgets
|
||||
|
||||
Drop a `.py` file in `config/desktop/` and it appears on your desktop instantly:
|
||||
|
||||
```bash
|
||||
cp examples/desktop/example.py config/desktop/example.py
|
||||
```
|
||||
|
||||
Edit positions in `config/desktop.toml` or drag widgets in edit mode (right-click → Edit Widgets).
|
||||
|
||||
```bash
|
||||
nvim config/desktop/example.py # live reload, no restart needed
|
||||
rm config/desktop/example.py # widget disappears within 2s
|
||||
```
|
||||
|
||||
See [Desktop Widgets Guide](docs/desktop_widgets.md) for full docs and examples.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | What's inside |
|
||||
@@ -131,6 +148,7 @@ See [Configuration Guide](docs/configuration.md) for full reference.
|
||||
| [Styling Guide](docs/styling.md) | Matugen colors, CSS customization |
|
||||
| [Architecture Overview](docs/architecture.md) | App Switcher C-backend, Spotlight engine, Services |
|
||||
| [Spotlight Plugins](docs/plugins.md) | Install/customize plugins, write your own |
|
||||
| [Desktop Widgets](docs/desktop_widgets.md) | Custom desktop widgets, user widget system |
|
||||
| [FAQs & Tips](docs/faqs_tips.md) | Troubleshooting, shortcuts, live reload |
|
||||
|
||||
## Custom Mods
|
||||
|
||||
+30
-1
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -49,6 +49,34 @@ Update `weather_location` in `config.toml` (e.g., `"London, UK"` or
|
||||
- The venv lives at `config/plugins/.venvs/<plugin_name>/`
|
||||
- Delete that directory to force re-install on next load
|
||||
|
||||
### 8. Desktop widget not appearing?
|
||||
|
||||
- Check the file is in `config/desktop/` (not a subdirectory)
|
||||
- Ensure it calls `DesktopWidgetRegistry.register()` at module level
|
||||
- Check `journalctl --user -f -t modus` for `[DesktopWidgets]` errors
|
||||
- Ensure the widget `key` is unique (not `"date"`, `"weather"`, etc.)
|
||||
|
||||
### 9. Desktop widget still showing after deleting the file?
|
||||
|
||||
The cleanup runs within 2 seconds via file monitor + poll fallback. If it persists:
|
||||
- Check `journalctl --user -f -t modus` for stale widget messages
|
||||
- Restart Modus: `uv run start`
|
||||
|
||||
### 10. Desktop widget position resets after restart?
|
||||
|
||||
- Positions are stored in `config/desktop.toml`
|
||||
- If you delete `desktop.toml`, positions revert to defaults from `register()` calls
|
||||
- Ensure `config/desktop.toml` is writable
|
||||
|
||||
### 11. Desktop widget interfering with window keybinds (e.g., Super+Q)?
|
||||
|
||||
The desktop window is a full-screen layer shell surface, which can intercept pointer events and make Hyprland treat it as the focused window. Modus handles this by using a **partial input region** — only widget areas receive input; empty desktop space passes through.
|
||||
|
||||
If keybinds still don't work:
|
||||
- Check the widget's `size` in `register()` matches its actual rendered size
|
||||
- Ensure no widget is expanding beyond its expected bounds (e.g., `h_expand=True` without fixed width)
|
||||
- Restart Modus: `uv run start`
|
||||
|
||||
---
|
||||
|
||||
## Pro Tips
|
||||
@@ -56,6 +84,10 @@ Update `weather_location` in `config.toml` (e.g., `"London, UK"` or
|
||||
- **Live Reload for Mods**: `config/mods.toml` is monitored for changes. Edits
|
||||
apply instantly — no restart needed
|
||||
|
||||
- **Desktop Widgets**: Drop `.py` files in `config/desktop/` for instant widgets.
|
||||
Edit `config/desktop.toml` or drag in edit mode to reposition.
|
||||
Remove by deleting the `.py` file — disappears within 2 seconds.
|
||||
|
||||
- **Pinning Apps**: Use exact `.desktop` filename in `dock.toml` (e.g.,
|
||||
`"org.gnome.Nautilus"`, not `"Files"`)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
|
||||
from window.desktop.registry import DesktopWidgetRegistry
|
||||
|
||||
|
||||
class ExampleClockWidget(Box):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="example-widget",
|
||||
orientation="v",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
**kwargs,
|
||||
)
|
||||
self.label = Label(
|
||||
name="examplelabel",
|
||||
label="Hello Desktop",
|
||||
justification="center",
|
||||
)
|
||||
self.add(self.label)
|
||||
|
||||
|
||||
DesktopWidgetRegistry.register(
|
||||
"example",
|
||||
ExampleClockWidget,
|
||||
(174, 174),
|
||||
(0.45, 0.45),
|
||||
)
|
||||
+2
-5
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user