Files
Modus/src/window/desktop/main.py
T

508 lines
18 KiB
Python

import importlib
import os
import pkgutil
import tomlkit
from fabric.utils import Gdk, 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) #
_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},
]
class PositionManager:
"""Manages widget positions stored in desktop.toml as fractional coords."""
_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:
state[mid_str] = list(_DEFAULT_POSITIONS)
cls._write(state)
return 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)
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})
cls._write(state)
@classmethod
def remove(cls, monitor_id: int, key: str) -> bool:
state = cls._read()
mid_str = str(monitor_id)
if mid_str not in state:
return False
before = len(state[mid_str])
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()
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._recalc_in_progress = False
self._recalc_timer: int | None = None
self._ready = False
self._in_size_allocate = False
self._edit_mode = False
# Free-drag state
self._dragging_key: str | None = None
self._drag_start_wx: int = 0 # widget top-left x when drag began
self._drag_start_wy: int = 0 # widget top-left y when drag began
self._drag_start_px: int = 0 # pointer x when drag began
self._drag_start_py: int = 0 # pointer y when drag began
self._root = Box(h_expand=True, v_expand=True)
self._overlay = Gtk.Overlay()
self._overlay.add(self._fixed)
self._stop_edit_box = Gtk.Box(
halign=Gtk.Align.CENTER, valign=Gtk.Align.START, margin_top=40
)
self._stop_edit_btn = Gtk.Button(label="Done Editing")
self._stop_edit_btn.get_style_context().add_class("suggested-action")
self._stop_edit_btn.connect("clicked", self._on_stop_edit_clicked)
self._stop_edit_box.add(self._stop_edit_btn)
self._overlay.add_overlay(self._stop_edit_box)
self._stop_edit_box.set_no_show_all(True)
self._stop_edit_box.set_visible(False)
self._root.add(self._overlay)
# Get initial dimensions from monitor so we don't spawn at 0,0
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)
# Fade animators
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="ignore",
layer="background",
child=self._root,
visible=True,
name=f"desktop-widgets-{monitor_id}",
)
self.connect("size-allocate", self._on_size_allocate)
self.connect("button-press-event", self._on_window_button_press)
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)
# right-click context menu
def _on_window_button_press(self, widget, event: Gdk.EventButton) -> bool:
if event.button != 3:
return False
menu = Gtk.Menu()
edit_item = Gtk.CheckMenuItem(label="Edit Widgets")
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)
return True
# edit mode
def _on_edit_toggled(self, item) -> None:
self._edit_mode = item.get_active()
if self._edit_mode:
self._stop_edit_box.set_visible(True)
self._stop_edit_btn.set_visible(True)
else:
self._stop_edit_box.set_visible(False)
self._stop_edit_btn.set_visible(False)
for key, eb in self._children.items():
self._configure_eb_for_edit(eb, key, self._edit_mode)
def _on_stop_edit_clicked(self, btn) -> None:
self._edit_mode = False
self._stop_edit_box.set_visible(False)
self._stop_edit_btn.set_visible(False)
for key, eb in self._children.items():
self._configure_eb_for_edit(eb, key, False)
def _configure_eb_for_edit(self, eb: Gtk.EventBox, key: str, edit: bool) -> None:
"""Wire / unwire the free-drag signals on an EventBox."""
if edit:
eb.get_style_context().add_class("edit-mode")
else:
eb.get_style_context().remove_class("edit-mode")
# We leave the signals connected; _on_eb_press guards on _edit_mode.
# free-drag implementation
def _is_overlapping(self, eb: Gtk.EventBox, new_x: int, new_y: int) -> bool:
aw = eb.get_allocation().width or eb.get_preferred_width()[1]
ah = eb.get_allocation().height or eb.get_preferred_height()[1]
for key, other_eb in self._children.items():
if other_eb == eb:
continue
# Read the position we actually set, fallback to allocation
ox = getattr(other_eb, "_target_x", other_eb.get_allocation().x)
oy = getattr(other_eb, "_target_y", other_eb.get_allocation().y)
ow = other_eb.get_allocation().width or other_eb.get_preferred_width()[1]
oh = other_eb.get_allocation().height or other_eb.get_preferred_height()[1]
# Simple rectangle intersection check
if not (
new_x + aw <= ox
or new_x >= ox + ow
or new_y + ah <= oy
or new_y >= oy + oh
):
return True
return False
def _on_eb_press(self, eb: Gtk.EventBox, event: Gdk.EventButton, key: str) -> bool:
if not self._edit_mode or event.button != 1:
return False
# Bring to front when dragging
if eb.get_window():
eb.get_window().raise_()
alloc = eb.get_allocation()
self._dragging_key = key
self._drag_start_wx = alloc.x
self._drag_start_wy = alloc.y
self._drag_start_px = int(event.x_root)
self._drag_start_py = int(event.y_root)
eb.set_opacity(0.7)
eb.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "grabbing")
)
return True
def _on_eb_motion(self, eb: Gtk.EventBox, event: Gdk.EventMotion, key: str) -> bool:
if self._dragging_key != key:
return False
dx = int(event.x_root) - self._drag_start_px
dy = int(event.y_root) - self._drag_start_py
new_x = max(
0, min(self._win_w - eb.get_allocation().width, self._drag_start_wx + dx)
)
new_y = max(
0, min(self._win_h - eb.get_allocation().height, self._drag_start_wy + dy)
)
curr_x = getattr(eb, "_target_x", eb.get_allocation().x)
curr_y = getattr(eb, "_target_y", eb.get_allocation().y)
if not self._is_overlapping(eb, new_x, new_y):
self._fixed.move(eb, new_x, new_y)
eb._target_x = new_x
eb._target_y = new_y
else:
# Try horizontal only
if not self._is_overlapping(eb, new_x, curr_y):
self._fixed.move(eb, new_x, curr_y)
eb._target_x = new_x
# Try vertical only
elif not self._is_overlapping(eb, curr_x, new_y):
self._fixed.move(eb, curr_x, new_y)
eb._target_y = new_y
return True
def _on_eb_release(
self, eb: Gtk.EventBox, event: Gdk.EventButton, key: str
) -> bool:
if self._dragging_key != key or event.button != 1:
return False
self._dragging_key = None
eb.set_opacity(1.0)
eb.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "grab")
)
# Persist the new position as fractions of screen size
alloc = eb.get_allocation()
if self._win_w > 1 and self._win_h > 1:
position_manager.save_position(
self._monitor_id,
key,
alloc.x / self._win_w,
alloc.y / self._win_h,
)
return True
# size allocate
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()
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)
# Clamp so widget never goes off-screen
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))
# During initial load, ensure no overlap even if config overlaps
if self._is_overlapping(eb, px, py):
# Try to nudge it slightly until it fits
for step_y in range(0, self._win_h, 50):
for step_x in range(0, self._win_w, 50):
if not self._is_overlapping(eb, step_x, step_y):
px, py = step_x, step_y
break
else:
continue
break
eb._target_x = px
eb._target_y = py
self._fixed.move(eb, px, py)
# widget management
def rebuild(self) -> 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.set_above_child(True)
eb.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.BUTTON1_MOTION_MASK
)
eb.connect("button-press-event", self._on_eb_press, key)
eb.connect("button-release-event", self._on_eb_release, key)
eb.connect("motion-notify-event", self._on_eb_motion, key)
eb.add(widget)
eb.show_all()
self._fixed.put(eb, 0, 0)
self._children[key] = eb
if self._edit_mode:
self._configure_eb_for_edit(eb, key, True)
except Exception as e:
logger.error(f"[DesktopWidgetService] failed to build {key!r}: {e}")
self._reposition_all()
self._force_refresh()
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.set_above_child(True)
eb.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.BUTTON1_MOTION_MASK
)
eb.connect("button-press-event", self._on_eb_press, key)
eb.connect("button-release-event", self._on_eb_release, key)
eb.connect("motion-notify-event", self._on_eb_motion, key)
eb.add(widget)
eb.show_all()
self._fixed.put(eb, int(px * self._win_w), int(py * self._win_h))
self._children[key] = eb
if self._edit_mode:
self._configure_eb_for_edit(eb, key, True)
self._force_refresh()
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()
def _force_refresh(self):
# Niri needs a hide/show cycle; show_all() is safe now (no canvas to hide).
self.hide()
self.show_all()
return False