508 lines
18 KiB
Python
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
|