feat(desktop): updates
This commit is contained in:
1 parent
fc7f2851c9
commit
29f3519965
13 files changed
+549
-29
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.
|
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
|
## Documentation
|
||||||
|
|
||||||
| Doc | What's inside |
|
| 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 |
|
| [Styling Guide](docs/styling.md) | Matugen colors, CSS customization |
|
||||||
| [Architecture Overview](docs/architecture.md) | App Switcher C-backend, Spotlight engine, Services |
|
| [Architecture Overview](docs/architecture.md) | App Switcher C-backend, Spotlight engine, Services |
|
||||||
| [Spotlight Plugins](docs/plugins.md) | Install/customize plugins, write your own |
|
| [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 |
|
| [FAQs & Tips](docs/faqs_tips.md) | Troubleshooting, shortcuts, live reload |
|
||||||
|
|
||||||
## Custom Mods
|
## 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/`)
|
- **OSD**: Volume/brightness overlays (`src/window/osd/`)
|
||||||
- **Settings**: Configuration GUI (`src/window/settings/`)
|
- **Settings**: Configuration GUI (`src/window/settings/`)
|
||||||
- **Wallpaper Picker**: Wallpaper browser and management (`src/window/wallpaper/`)
|
- **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
|
## 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
|
- Python daemon (`src/window/globalmenu/service.py`) listens for exported menus and
|
||||||
renders them as Fabric widgets in the panel
|
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
|
Services in `src/services/` monitor system state asynchronously and emit
|
||||||
signals:
|
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 |
|
| `config.toml` | Main settings (features, widgets, paths) | Restart |
|
||||||
| `mods.toml` | Custom panel buttons (menus, shell commands) | Instant |
|
| `mods.toml` | Custom panel buttons (menus, shell commands) | Instant |
|
||||||
| `dock.toml` | Pinned dock apps | Restart |
|
| `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
|
## Settings Window
|
||||||
|
|
||||||
@@ -156,3 +158,59 @@ pinned = [
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use the exact `.desktop` file name or application ID.
|
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>/`
|
- The venv lives at `config/plugins/.venvs/<plugin_name>/`
|
||||||
- Delete that directory to force re-install on next load
|
- 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
|
## 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
|
- **Live Reload for Mods**: `config/mods.toml` is monitored for changes. Edits
|
||||||
apply instantly — no restart needed
|
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.,
|
- **Pinning Apps**: Use exact `.desktop` filename in `dock.toml` (e.g.,
|
||||||
`"org.gnome.Nautilus"`, not `"Files"`)
|
`"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
|
return True
|
||||||
|
|
||||||
def remove(self, monitor_id: int, key: str) -> bool:
|
def remove(self, monitor_id: int, key: str) -> bool:
|
||||||
removed = position_manager.remove(monitor_id, key)
|
position_manager.remove_override(monitor_id, key)
|
||||||
if not removed:
|
|
||||||
return False
|
|
||||||
win = self._windows.get(monitor_id)
|
win = self._windows.get(monitor_id)
|
||||||
if win:
|
if win:
|
||||||
win.remove_widget(key)
|
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 importlib
|
||||||
import os
|
import os
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
import sys
|
||||||
|
|
||||||
import tomlkit
|
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.box import Box
|
||||||
from fabric.widgets.wayland import WaylandWindow
|
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) #
|
# Config-backed position manager (stores percentage-based positions) #
|
||||||
|
|
||||||
_DEFAULT_POSITIONS = [
|
_CONFIG_DESKTOP_DIR = os.path.join(
|
||||||
{"key": "date", "px": 0.0, "py": 0.0},
|
os.path.dirname(toml_file("desktop.toml")), "desktop"
|
||||||
{"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},
|
|
||||||
]
|
|
||||||
|
|
||||||
_TARGET = Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)
|
_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:
|
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")
|
_file = toml_file("desktop.toml")
|
||||||
|
|
||||||
@@ -61,36 +97,49 @@ class PositionManager:
|
|||||||
def get_widgets(cls, monitor_id: int) -> list[dict]:
|
def get_widgets(cls, monitor_id: int) -> list[dict]:
|
||||||
state = cls._read()
|
state = cls._read()
|
||||||
mid_str = str(monitor_id)
|
mid_str = str(monitor_id)
|
||||||
if mid_str not in state:
|
if mid_str not in state or not state[mid_str]:
|
||||||
state[mid_str] = list(_DEFAULT_POSITIONS)
|
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)
|
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
|
@classmethod
|
||||||
def save_position(cls, monitor_id: int, key: str, px: float, py: float) -> None:
|
def save_position(cls, monitor_id: int, key: str, px: float, py: float) -> None:
|
||||||
state = cls._read()
|
state = cls._read()
|
||||||
mid_str = str(monitor_id)
|
mid_str = str(monitor_id)
|
||||||
if mid_str not in state:
|
if mid_str not in state:
|
||||||
state[mid_str] = list(_DEFAULT_POSITIONS)
|
state[mid_str] = []
|
||||||
for entry in state[mid_str]:
|
for entry in state[mid_str]:
|
||||||
if entry["key"] == key:
|
if entry["key"] == key:
|
||||||
entry["px"] = round(max(0.0, min(1.0, px)), 5)
|
entry["px"] = round(max(0.0, min(1.0, px)), 5)
|
||||||
entry["py"] = round(max(0.0, min(1.0, py)), 5)
|
entry["py"] = round(max(0.0, min(1.0, py)), 5)
|
||||||
break
|
break
|
||||||
else:
|
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)
|
cls._write(state)
|
||||||
|
|
||||||
@classmethod
|
@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()
|
state = cls._read()
|
||||||
mid_str = str(monitor_id)
|
mid_str = str(monitor_id)
|
||||||
if mid_str not in state:
|
if mid_str not in state:
|
||||||
return False
|
return
|
||||||
before = len(state[mid_str])
|
|
||||||
state[mid_str] = [e for e in state[mid_str] if e["key"] != key]
|
state[mid_str] = [e for e in state[mid_str] if e["key"] != key]
|
||||||
cls._write(state)
|
cls._write(state)
|
||||||
return len(state[mid_str]) < before
|
|
||||||
|
|
||||||
|
|
||||||
position_manager = PositionManager()
|
position_manager = PositionManager()
|
||||||
@@ -157,6 +206,9 @@ class DesktopWidgetWindow(WaylandWindow):
|
|||||||
self.connect("size-allocate", self._on_size_allocate)
|
self.connect("size-allocate", self._on_size_allocate)
|
||||||
self.connect("button-press-event", self._on_window_button_press)
|
self.connect("button-press-event", self._on_window_button_press)
|
||||||
self._setup_drag_dest()
|
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)
|
GLib.timeout_add(50, self._initial_build)
|
||||||
|
|
||||||
@@ -202,6 +254,71 @@ class DesktopWidgetWindow(WaylandWindow):
|
|||||||
def _on_fade_out_finished(self, animator) -> None:
|
def _on_fade_out_finished(self, animator) -> None:
|
||||||
self._fixed.set_opacity(0.0)
|
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
|
# right-click context menu
|
||||||
|
|
||||||
def _on_window_button_press(self, widget, event: Gdk.EventButton) -> bool:
|
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:
|
def _show_context_menu(self, event: Gdk.EventButton) -> None:
|
||||||
menu = Gtk.Menu()
|
menu = Gtk.Menu()
|
||||||
|
|
||||||
label_text = "Done Edit" if self._edit_mode else "Edit Widgets"
|
label_text = "Done Edit" if self._edit_mode else "Edit Widgets"
|
||||||
edit_item = Gtk.CheckMenuItem(label=label_text)
|
edit_item = Gtk.CheckMenuItem(label=label_text)
|
||||||
edit_item.set_active(self._edit_mode)
|
edit_item.set_active(self._edit_mode)
|
||||||
edit_item.connect("toggled", self._on_edit_toggled)
|
edit_item.connect("toggled", self._on_edit_toggled)
|
||||||
menu.append(edit_item)
|
menu.append(edit_item)
|
||||||
|
|
||||||
menu.show_all()
|
menu.show_all()
|
||||||
menu.popup_at_pointer(event)
|
menu.popup_at_pointer(event)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
class DesktopWidgetRegistry:
|
class DesktopWidgetRegistry:
|
||||||
_widgets = {}
|
_widgets = {}
|
||||||
_sizes = {}
|
_sizes = {}
|
||||||
|
_default_positions = {}
|
||||||
|
_user_keys: set[str] = set()
|
||||||
|
|
||||||
@classmethod
|
@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._widgets[key] = widget_cls
|
||||||
cls._sizes[key] = size
|
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
|
@classmethod
|
||||||
def get_widget_class(cls, key: str):
|
def get_widget_class(cls, key: str):
|
||||||
@@ -14,3 +36,7 @@ class DesktopWidgetRegistry:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_widget_size(cls, key: str) -> tuple[int, int]:
|
def get_widget_size(cls, key: str) -> tuple[int, int]:
|
||||||
return cls._sizes.get(key, (174, 174))
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
DesktopWidgetRegistry.register("ram_info", DesktopRamInfoWidget, (174, 174))
|
DesktopWidgetRegistry.register(
|
||||||
DesktopWidgetRegistry.register("cpu_info", DesktopCpuInfoWidget, (174, 174))
|
"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()
|
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