feat(desktop): now move widget with drag to any place of window #42
No files matched your search
@@ -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.
|
||||
|
||||
### 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,210 @@
|
||||
# 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))
|
||||
```
|
||||
|
||||
## 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,25 @@ 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
|
||||
|
||||
---
|
||||
|
||||
## Pro Tips
|
||||
@@ -56,6 +75,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),
|
||||
)
|
||||
@@ -73,9 +73,7 @@ class DesktopWidgetService(Service):
|
||||
return True
|
||||
|
||||
def remove(self, monitor_id: int, key: str) -> bool:
|
||||
removed = position_manager.remove(monitor_id, key)
|
||||
if not removed:
|
||||
return False
|
||||
position_manager.remove_override(monitor_id, key)
|
||||
win = self._windows.get(monitor_id)
|
||||
if win:
|
||||
win.remove_widget(key)
|
||||
|
||||
@@ -143,4 +143,6 @@ class DesktopCalendarContainer(Box):
|
||||
)
|
||||
|
||||
|
||||
DesktopWidgetRegistry.register("calendar", DesktopCalendarContainer, (174, 174))
|
||||
DesktopWidgetRegistry.register(
|
||||
"calendar", DesktopCalendarContainer, (174, 174), (0.19115, 0.00095)
|
||||
)
|
||||
@@ -49,4 +49,4 @@ class DesktopDateContainer(Box):
|
||||
)
|
||||
|
||||
|
||||
DesktopWidgetRegistry.register("date", DesktopDateContainer, (174, 174))
|
||||
DesktopWidgetRegistry.register("date", DesktopDateContainer, (174, 174), (0.0, 0.0))
|
||||
+138
-19
@@ -1,9 +1,10 @@
|
||||
import importlib
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
|
||||
import tomlkit
|
||||
from fabric.utils import Gdk, GLib, Gtk, logger
|
||||
from fabric.utils import Gdk, Gio, GLib, Gtk, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.wayland import WaylandWindow
|
||||
|
||||
@@ -22,19 +23,54 @@ for _, _name, _ in pkgutil.iter_modules([_dir]):
|
||||
|
||||
# Config-backed position manager (stores percentage-based positions) #
|
||||
|
||||
_DEFAULT_POSITIONS = [
|
||||
{"key": "date", "px": 0.0, "py": 0.0},
|
||||
{"key": "weather", "px": 0.0974, "py": 0.0},
|
||||
{"key": "calendar", "px": 0.19271, "py": 0.0},
|
||||
{"key": "cpu_info", "px": 0.81042, "py": 0.82732},
|
||||
{"key": "ram_info", "px": 0.90521, "py": 0.82732},
|
||||
]
|
||||
|
||||
_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:
|
||||
"""Manages widget positions stored in desktop.toml as fractional coords."""
|
||||
"""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")
|
||||
|
||||
@@ -61,36 +97,49 @@ class PositionManager:
|
||||
def get_widgets(cls, monitor_id: int) -> list[dict]:
|
||||
state = cls._read()
|
||||
mid_str = str(monitor_id)
|
||||
if mid_str not in state:
|
||||
state[mid_str] = list(_DEFAULT_POSITIONS)
|
||||
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)
|
||||
return state[mid_str]
|
||||
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] = list(_DEFAULT_POSITIONS)
|
||||
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": px, "py": py})
|
||||
state[mid_str].append({"key": key, "px": round(px, 5), "py": round(py, 5)})
|
||||
cls._write(state)
|
||||
|
||||
@classmethod
|
||||
def remove(cls, monitor_id: int, key: str) -> bool:
|
||||
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 False
|
||||
before = len(state[mid_str])
|
||||
return
|
||||
state[mid_str] = [e for e in state[mid_str] if e["key"] != key]
|
||||
cls._write(state)
|
||||
return len(state[mid_str]) < before
|
||||
|
||||
|
||||
position_manager = PositionManager()
|
||||
@@ -157,6 +206,9 @@ class DesktopWidgetWindow(WaylandWindow):
|
||||
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)
|
||||
|
||||
@@ -202,6 +254,71 @@ class DesktopWidgetWindow(WaylandWindow):
|
||||
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:
|
||||
@@ -212,11 +329,13 @@ class DesktopWidgetWindow(WaylandWindow):
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
class DesktopWidgetRegistry:
|
||||
_widgets = {}
|
||||
_sizes = {}
|
||||
_default_positions = {}
|
||||
_user_keys: set[str] = set()
|
||||
|
||||
@classmethod
|
||||
def register(cls, key: str, widget_cls, size: tuple[int, int]):
|
||||
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):
|
||||
@@ -14,3 +36,7 @@ class DesktopWidgetRegistry:
|
||||
@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)
|
||||
@@ -168,5 +168,9 @@ class DesktopCpuInfoWidget(SystemInfoBase):
|
||||
return True
|
||||
|
||||
|
||||
DesktopWidgetRegistry.register("ram_info", DesktopRamInfoWidget, (174, 174))
|
||||
DesktopWidgetRegistry.register("cpu_info", DesktopCpuInfoWidget, (174, 174))
|
||||
DesktopWidgetRegistry.register(
|
||||
"ram_info", DesktopRamInfoWidget, (174, 174), (0.90521, 0.82732)
|
||||
)
|
||||
DesktopWidgetRegistry.register(
|
||||
"cpu_info", DesktopCpuInfoWidget, (174, 174), (0.81042, 0.82732)
|
||||
)
|
||||
@@ -129,4 +129,6 @@ class DesktopWeatherWidget(Box):
|
||||
super().destroy()
|
||||
|
||||
|
||||
DesktopWidgetRegistry.register("weather", DesktopWeatherWidget, (174, 174))
|
||||
DesktopWidgetRegistry.register(
|
||||
"weather", DesktopWeatherWidget, (174, 174), (0.0974, 0.0)
|
||||
)
|
||||
Reference in new issue
Block a user