feat(desktop): now move widget with drag to any place of window

This commit is contained in:
sankalp committed 2026-07-26 17:00:02 +05:45
1 parent 65db4f75fe
commit 37b262270c
15 files changed
+1754 -888

No files matched your search

+507
View File
@@ -0,0 +1,507 @@
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