Repository: qtile/qtile

Issue:
No drag-and-drop functionality with Wayland backend
# Issue description

Drag-and-drop functionality needs explicit implementation (from my very limited look into it, some data handling IPC and some surface renderering is required), at least for dragging e.g. files from one client to another.

I also can't seem to drag and drop _within_ Firefox. This may be unrelated - I would have thought this would be simple button event-based activity within a web page but maybe it uses the desktop's DnD functionality too (e.g.: dragging text around a text input box).

# Qtile version

master (152a1012bef9afb76ba7b03d6ee211e78b1da80d)




Discussion:
Found this interesting article from Simon Ser : https://emersion.fr/blog/2020/wayland-clipboard-drag-and-drop/
But I'm struggling to gather the python equivalent of this puzzle like wl_data_source or wl_data_device that I was first hoping to find in pywlroots.wlroots.wlr_types.
Crazy, I am literally right now reading this!

>But I'm struggling to gather the python equivalent of this puzzle like wl_data_source or wl_data_device that I was first hoping to find in pywlroots.wlroots.wlr_types.

Yep, well once you've figured out consider submitting it ;) we have
wlr_data_device_manager implemented there and in Qtile already, which once added
allowed some clients' clipboard to work completely IIRC. I just submitted a PR
to pywlroots for wlr_data_control_v1 which also appears to be a way for clients
to provide data for the clipboard -- wl-copy appears to use a workaround when it
is absent that involves creating a window, and when wlr_data_control_v1 is
available then it can do it... better? /shrug. Will have to read this post more
carefully.


On 210610 02:33, Fabien-Bernard wrote:
>Found this interesting article from Simon Ser : https://emersion.fr/blog/2020/wayland-clipboard-drag-and-drop/
>But I'm struggling to gather the python equivalent of this puzzle like wl_data_source or wl_data_device that I was first hoping to find in pywlroots.wlroots.wlr_types.
>
>-- 
>You are receiving this because you authored the thread.
>Reply to this email directly or view it on GitHub:
>https://github.com/qtile/qtile/issues/2531#issuecomment-859089304


Resolving patch:
diff --git a/libqtile/backend/wayland/core.py b/libqtile/backend/wayland/core.py
--- a/libqtile/backend/wayland/core.py
+++ b/libqtile/backend/wayland/core.py
@@ -82,6 +82,7 @@
     from typing import List, Optional, Sequence, Tuple, Union
 
     from wlroots.wlr_types import Output as wlrOutput
+    from wlroots.wlr_types.data_device_manager import Drag
 
     from libqtile import config
     from libqtile.core.manager import Qtile
@@ -119,11 +120,14 @@ def __init__(self):
         self.grabbed_keys: List[Tuple[int, int]] = []
         self.grabbed_buttons: List[Tuple[int, int]] = []
         DataDeviceManager(self.display)
+        self.live_dnd: Optional[wlrq.Dnd] = None
         DataControlManagerV1(self.display)
         self.seat = seat.Seat(self.display, "seat0")
         self.add_listener(
             self.seat.request_set_selection_event, self._on_request_set_selection
         )
+        self.add_listener(self.seat.request_start_drag_event, self._on_request_start_drag)
+        self.add_listener(self.seat.start_drag_event, self._on_start_drag)
         self.add_listener(self.backend.new_input_event, self._on_new_input)
 
         # set up outputs
@@ -222,6 +226,18 @@ def _on_request_set_selection(
         self.seat.set_selection(event._ptr.source, event.serial)
         logger.debug("Signal: seat request_set_selection")
 
+    def _on_request_start_drag(self, _listener, event: seat.RequestStartDragEvent):
+        logger.debug("Signal: seat request_start_drag")
+
+        if not self.live_dnd and self.seat.validate_pointer_grab_serial(event.origin, event.serial):
+            self.seat.start_pointer_drag(event.drag, event.serial)
+        else:
+            event.drag.source.destroy()
+
+    def _on_start_drag(self, _listener, event: Drag):
+        logger.debug("Signal: seat start_drag")
+        self.live_dnd = wlrq.Dnd(self, event)
+
     def _on_new_input(self, _listener, device: input_device.InputDevice):
         logger.debug("Signal: backend new_input_event")
         if device.device_type == input_device.InputDeviceType.POINTER:
@@ -350,7 +366,7 @@ def _on_cursor_motion(self, _listener, event: pointer.PointerEventMotion):
                 return
 
         self.cursor.move(dx, dy, input_device=event.device)
-        self._process_cursor_motion(event.time_msec)
+        self._process_cursor_motion(event.time_msec, self.cursor.x, self.cursor.y)
 
     def _on_cursor_motion_absolute(
         self, _listener, event: pointer.PointerEventMotionAbsolute
@@ -362,7 +378,7 @@ def _on_cursor_motion_absolute(
             event.y,
             input_device=event.device,
         )
-        self._process_cursor_motion(event.time_msec)
+        self._process_cursor_motion(event.time_msec, self.cursor.x, self.cursor.y)
 
     def _on_new_pointer_constraint(self, _listener, wlr_constraint: PointerConstraintV1):
         logger.debug("Signal: pointer_constraints new_constraint")
@@ -440,17 +456,18 @@ def _output_manager_reconfigure(
         hook.fire("screen_change", None)
         hook.fire("screens_reconfigured")
 
-    def _process_cursor_motion(self, time):
-        self.qtile.process_button_motion(self.cursor.x, self.cursor.y)
+    def _process_cursor_motion(self, time_msec: int, cx: float, cy: float):
+        self.qtile.process_button_motion(int(cx), int(cy))
 
         if len(self.outputs) > 1:
-            current_output = self.output_layout.output_at(
-                self.cursor.x, self.cursor.y
-            ).data
+            current_output = self.output_layout.output_at(cx, cy).data
             if self._current_output is not current_output:
                 self._current_output = current_output
                 self.stack_windows()
 
+        if self.live_dnd:
+            self.live_dnd.position(cx, cy)
+
         found = self._under_pointer()
 
         if found:
@@ -458,27 +475,26 @@ def _process_cursor_motion(self, time):
             if isinstance(win, window.Internal):
                 if self._hovered_internal is win:
                     win.process_pointer_motion(
-                        self.cursor.x - self._hovered_internal.x,
-                        self.cursor.y - self._hovered_internal.y,
+                        cx - self._hovered_internal.x,
+                        cy - self._hovered_internal.y,
                     )
                 else:
                     if self._hovered_internal:
                         self._hovered_internal.process_pointer_leave(
-                            self.cursor.x - self._hovered_internal.x,
-                            self.cursor.y - self._hovered_internal.y,
+                            cx - self._hovered_internal.x,
+                            cy - self._hovered_internal.y,
                         )
                     self.cursor_manager.set_cursor_image("left_ptr", self.cursor)
-                    self.seat.pointer_clear_focus()
-                    win.process_pointer_enter(self.cursor.x, self.cursor.y)
+                    self.seat.pointer_notify_clear_focus()
+                    win.process_pointer_enter(cx, cy)
                     self._hovered_internal = win
                 return
 
-            if surface is not None:
+            if surface:
                 self.seat.pointer_notify_enter(surface, sx, sy)
-                if self.seat.pointer_state.focused_surface == surface:
-                    self.seat.pointer_notify_motion(time, sx, sy)
+                self.seat.pointer_notify_motion(time_msec, sx, sy)
             else:
-                self.seat.pointer_clear_focus()
+                self.seat.pointer_notify_clear_focus()
 
             if win is not self.qtile.current_window:
                 hook.fire("client_mouse_enter", win)
@@ -501,11 +517,11 @@ def _process_cursor_motion(self, time):
 
         else:
             self.cursor_manager.set_cursor_image("left_ptr", self.cursor)
-            self.seat.pointer_clear_focus()
+            self.seat.pointer_notify_clear_focus()
             if self._hovered_internal:
                 self._hovered_internal.process_pointer_leave(
-                    self.cursor.x - self._hovered_internal.x,
-                    self.cursor.y - self._hovered_internal.y,
+                    cx - self._hovered_internal.x,
+                    cy - self._hovered_internal.y,
                 )
                 self._hovered_internal = None
 
@@ -514,7 +530,8 @@ def _process_cursor_button(self, button: int, pressed: bool) -> bool:
 
         if pressed:
             handled = self.qtile.process_button_click(
-                button, self.seat.keyboard.modifier, self.cursor.x, self.cursor.y
+                button, self.seat.keyboard.modifier,
+                int(self.cursor.x), int(self.cursor.y)
             )
 
             if self._hovered_internal:
diff --git a/libqtile/backend/wayland/output.py b/libqtile/backend/wayland/output.py
--- a/libqtile/backend/wayland/output.py
+++ b/libqtile/backend/wayland/output.py
@@ -46,6 +46,7 @@
 
     from libqtile.backend.wayland.core import Core
     from libqtile.backend.wayland.window import WindowType
+    from libqtile.backend.wayland.wlrq import Dnd
     from libqtile.config import Screen
 
 
@@ -154,6 +155,9 @@ def _on_frame(self, _listener, _data):
                         )
                         window.surface.for_each_surface(self._render_surface, rdata)
 
+                if self.core.live_dnd:
+                    self._render_dnd_icon(now)
+
                 wlr_output.render_software_cursors(damage=damage)
                 renderer.end()
 
@@ -213,6 +217,25 @@ def _render_surface(self, surface: Surface, sx: int, sy: int, rdata: Tuple) -> N
         self.renderer.render_texture_with_matrix(texture, matrix, opacity)
         surface.send_frame_done(now)
 
+    def _render_dnd_icon(self, now: Timespec) -> None:
+        """Render the drag-n-drop icon if there is one."""
+        dnd = self.core.live_dnd
+        icon = dnd.wlr_drag.icon
+        if icon.mapped:
+            texture = icon.surface.get_texture()
+            if texture:
+                scale = self.wlr_output.scale
+                box = Box(
+                    int((dnd.x - self.x) * scale),
+                    int((dnd.y - self.y) * scale),
+                    int(icon.surface.current.width * scale),
+                    int(icon.surface.current.height * scale),
+                )
+                inverse = wlrOutput.transform_invert(icon.surface.current.transform)
+                matrix = Matrix.project_box(box, inverse, 0, self.transform_matrix)
+                self.renderer.render_texture_with_matrix(texture, matrix, 1)
+                icon.surface.send_frame_done(now)
+
     def get_geometry(self) -> Tuple[int, int, int, int]:
         width, height = self.wlr_output.effective_resolution()
         return int(self.x), int(self.y), width, height
@@ -294,17 +317,17 @@ def organise_layers(self) -> None:
 
         self.core.stack_windows()
 
-    def contains(self, win: WindowType) -> bool:
+    def contains(self, rect: Union[WindowType, Dnd]) -> bool:
         """Returns whether the given window is visible on this output."""
-        if win.x + win.width < self.x:
+        if rect.x + rect.width < self.x:
             return False
-        if win.y + win.height < self.y:
+        if rect.y + rect.height < self.y:
             return False
 
         ow, oh = self.wlr_output.effective_resolution()
-        if self.x + ow < win.x:
+        if self.x + ow < rect.x:
             return False
-        if self.y + oh < win.y:
+        if self.y + oh < rect.y:
             return False
 
         return True
diff --git a/libqtile/backend/wayland/wlrq.py b/libqtile/backend/wayland/wlrq.py
--- a/libqtile/backend/wayland/wlrq.py
+++ b/libqtile/backend/wayland/wlrq.py
@@ -39,9 +39,10 @@
     from typing import Callable, List, Optional
 
     from pywayland.server import Signal
-    from wlroots.wlr_types import Box
+    from wlroots.wlr_types import data_device_manager, Box
 
     from libqtile.backend.wayland.core import Core
+    from libqtile.backend.wayland.output import Output
     from libqtile.backend.wayland.window import WindowType
 
 
@@ -250,3 +251,55 @@ def disable(self):
 
         self.core.active_pointer_constraint = None
         self.wlr_constraint.send_deactivated()
+
+
+class Dnd(HasListeners):
+    """A helper for drag and drop functionality."""
+    def __init__(self, core: Core, wlr_drag: data_device_manager.Drag):
+        self.core = core
+        self.wlr_drag = wlr_drag
+        self._outputs: Set[Output] = set()
+
+        self.x: float = core.cursor.x
+        self.y: float = core.cursor.y
+        self.width: int = 0  # Set upon surface commit
+        self.height: int = 0
+
+        self.add_listener(wlr_drag.destroy_event, self._on_destroy)
+        self.add_listener(wlr_drag.icon.map_event, self._on_icon_map)
+        self.add_listener(wlr_drag.icon.unmap_event, self._on_icon_unmap)
+        self.add_listener(wlr_drag.icon.destroy_event, self._on_icon_destroy)
+        self.add_listener(wlr_drag.icon.surface.commit_event, self._on_icon_commit)
+
+    def finalize(self) -> None:
+        self.finalize_listeners()
+        self.core.live_dnd = None
+
+    def _on_destroy(self, _listener, _event) -> None:
+        logger.debug("Signal: wlr_drag destroy")
+        self.finalize()
+
+    def _on_icon_map(self, _listener, _event) -> None:
+        logger.debug("Signal: wlr_drag_icon map")
+        for output in self._outputs:
+            output.damage()
+
+    def _on_icon_unmap(self, _listener, _event) -> None:
+        logger.debug("Signal: wlr_drag_icon unmap")
+        for output in self._outputs:
+            output.damage()
+
+    def _on_icon_destroy(self, _listener, _event) -> None:
+        logger.debug("Signal: wlr_drag_icon destroy")
+
+    def _on_icon_commit(self, _listener, _event) -> None:
+        self.width = self.wlr_drag.icon.surface.current.width
+        self.height = self.wlr_drag.icon.surface.current.height
+        self.position(self.core.cursor.x, self.core.cursor.y)
+
+    def position(self, cx: float, cy: float) -> None:
+        self.x = cx
+        self.y = cy
+        self._outputs = {o for o in self.core.outputs if o.contains(self)}
+        for output in self._outputs:
+            output.damage()


Test patch: