Skip to content

Commit f928310

Browse files
authored
Linux: added mouse support (partially fixes #55) (#232)
Original patch by @zorvios on #188.
1 parent 85c191e commit f928310

File tree

8 files changed

+163
-7
lines changed

8 files changed

+163
-7
lines changed

CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ History:
33
<see Git checking messages for history>
44

55
8.0.0 2023/0x/xx
6+
- Linux: added mouse support (partially fixes #55)
67
- Linux: removed get_error_details(), use the ScreenShotError details attribute instead
78
- dev: removed pre-commit
89
- tests: removed tox

CONTRIBUTORS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ Alexander 'thehesiod' Mohr [https://github.com/thehesiod]
1212
Andreas Buhr [https://www.andreasbuhr.de]
1313
- Bugfix for multi-monitor detection
1414

15+
Boutallaka 'zorvios' Yassir [https://github.com/zorvios]
16+
- GNU/Linux: Mouse support
17+
1518
bubulle [http://indexerror.net/user/bubulle]
1619
- Windows: efficiency of MSS.get_pixels()
1720

docs/source/api.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ GNU/Linux
2525

2626
.. class:: MSS
2727

28-
.. method:: __init__([display=None])
28+
.. method:: __init__([display=None, with_cursor=False])
2929

3030
:type display: str or None
3131
:param display: The display to use.
32+
:param with_cursor: Include the mouse cursor in screenshots.
3233

3334
GNU/Linux initializations.
3435

36+
.. versionadded:: 8.0.0
37+
`with_cursor` keyword argument.
38+
3539
.. method:: grab(monitor)
3640

3741
:rtype: :class:`~mss.base.ScreenShot`
@@ -76,6 +80,12 @@ Methods
7680

7781
The parent's class for every OS implementation.
7882

83+
.. attribute:: compression_level
84+
85+
PNG compression level used when saving the screenshot data into a file (see :py:func:`zlib.compress()` for details).
86+
87+
.. versionadded:: 3.2.0
88+
7989
.. method:: close()
8090

8191
Clean-up method. Does nothing by default.

mss/base.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
class MSSBase(metaclass=ABCMeta):
1919
"""This class will be overloaded by a system specific one."""
2020

21-
__slots__ = {"_monitors", "cls_image", "compression_level"}
21+
__slots__ = {"_monitors", "cls_image", "compression_level", "with_cursor"}
2222

2323
def __init__(self) -> None:
2424
self.cls_image: Type[ScreenShot] = ScreenShot
2525
self.compression_level = 6
26+
self.with_cursor = False
2627
self._monitors: Monitors = []
2728

2829
def __enter__(self) -> "MSSBase":
@@ -35,6 +36,10 @@ def __exit__(self, *_: Any) -> None:
3536

3637
self.close()
3738

39+
@abstractmethod
40+
def _cursor_impl(self) -> Optional[ScreenShot]:
41+
"""Retrieve all cursor data. Pixels have to be RGB."""
42+
3843
@abstractmethod
3944
def _grab_impl(self, monitor: Monitor) -> ScreenShot:
4045
"""
@@ -73,7 +78,11 @@ def grab(self, monitor: Union[Monitor, Tuple[int, int, int, int]]) -> ScreenShot
7378
}
7479

7580
with lock:
76-
return self._grab_impl(monitor)
81+
screenshot = self._grab_impl(monitor)
82+
if self.with_cursor:
83+
cursor = self._cursor_impl()
84+
screenshot = self._merge(screenshot, cursor) # type: ignore[arg-type]
85+
return screenshot
7786

7887
@property
7988
def monitors(self) -> Monitors:
@@ -174,6 +183,57 @@ def shot(self, **kwargs: Any) -> str:
174183
kwargs["mon"] = kwargs.get("mon", 1)
175184
return next(self.save(**kwargs))
176185

186+
@staticmethod
187+
def _merge(screenshot: ScreenShot, cursor: ScreenShot) -> ScreenShot:
188+
"""Create composite image by blending screenshot and mouse cursor."""
189+
190+
# pylint: disable=too-many-locals,invalid-name
191+
192+
(cx, cy), (cw, ch) = cursor.pos, cursor.size
193+
(x, y), (w, h) = screenshot.pos, screenshot.size
194+
195+
cx2, cy2 = cx + cw, cy + ch
196+
x2, y2 = x + w, y + h
197+
198+
overlap = cx < x2 and cx2 > x and cy < y2 and cy2 > y
199+
if not overlap:
200+
return screenshot
201+
202+
screen_data = screenshot.raw
203+
cursor_data = cursor.raw
204+
205+
cy, cy2 = (cy - y) * 4, (cy2 - y2) * 4
206+
cx, cx2 = (cx - x) * 4, (cx2 - x2) * 4
207+
start_count_y = -cy if cy < 0 else 0
208+
start_count_x = -cx if cx < 0 else 0
209+
stop_count_y = ch * 4 - max(cy2, 0)
210+
stop_count_x = cw * 4 - max(cx2, 0)
211+
rgb = range(3)
212+
213+
for count_y in range(start_count_y, stop_count_y, 4):
214+
pos_s = (count_y + cy) * w + cx
215+
pos_c = count_y * cw
216+
217+
for count_x in range(start_count_x, stop_count_x, 4):
218+
spos = pos_s + count_x
219+
cpos = pos_c + count_x
220+
alpha = cursor_data[cpos + 3]
221+
222+
if not alpha:
223+
continue
224+
225+
if alpha == 255:
226+
screen_data[spos : spos + 3] = cursor_data[cpos : cpos + 3]
227+
else:
228+
alpha = alpha / 255
229+
for i in rgb:
230+
screen_data[spos + i] = int(
231+
cursor_data[cpos + i] * alpha
232+
+ screen_data[spos + i] * (1 - alpha)
233+
)
234+
235+
return screenshot
236+
177237
@staticmethod
178238
def _cfactory(
179239
attr: Any,

mss/darwin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
c_void_p,
1818
)
1919
from platform import mac_ver
20-
from typing import Any, Type, Union
20+
from typing import Any, Optional, Type, Union
2121

2222
from .base import MSSBase
2323
from .exception import ScreenShotError
@@ -232,3 +232,7 @@ def _grab_impl(self, monitor: Monitor) -> ScreenShot:
232232
core.CFRelease(copy_data)
233233

234234
return self.cls_image(data, monitor, size=Size(width, height))
235+
236+
def _cursor_impl(self) -> Optional[ScreenShot]:
237+
"""Retrieve all cursor data. Pixels have to be RGB."""
238+
return None

mss/linux.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
c_int,
1616
c_int32,
1717
c_long,
18+
c_short,
1819
c_ubyte,
1920
c_uint,
2021
c_uint32,
2122
c_ulong,
2223
c_ushort,
2324
c_void_p,
25+
cast,
2426
)
2527
from typing import Any, Dict, Optional, Tuple, Union
2628

@@ -60,6 +62,26 @@ class Event(Structure):
6062
]
6163

6264

65+
class XFixesCursorImage(Structure):
66+
"""
67+
XFixes is an X Window System extension.
68+
See /usr/include/X11/extensions/Xfixes.h
69+
"""
70+
71+
_fields_ = [
72+
("x", c_short),
73+
("y", c_short),
74+
("width", c_ushort),
75+
("height", c_ushort),
76+
("xhot", c_ushort),
77+
("yhot", c_ushort),
78+
("cursor_serial", c_ulong),
79+
("pixels", POINTER(c_ulong)),
80+
("atom", c_ulong),
81+
("name", c_char_p),
82+
]
83+
84+
6385
class XWindowAttributes(Structure):
6486
"""Attributes for the specified window."""
6587

@@ -211,6 +233,7 @@ def validate(retval: int, func: Any, args: Tuple[Any, Any]) -> Tuple[Any, Any]:
211233
CFUNCTIONS: CFunctions = {
212234
"XDefaultRootWindow": ("xlib", [POINTER(Display)], POINTER(XWindowAttributes)),
213235
"XDestroyImage": ("xlib", [POINTER(XImage)], c_void_p),
236+
"XFixesGetCursorImage": ("xfixes", [POINTER(Display)], POINTER(XFixesCursorImage)),
214237
"XGetImage": (
215238
"xlib",
216239
[
@@ -269,15 +292,18 @@ class MSS(MSSBase):
269292
It uses intensively the Xlib and its Xrandr extension.
270293
"""
271294

272-
__slots__ = {"drawable", "root", "xlib", "xrandr"}
295+
__slots__ = {"drawable", "root", "xlib", "xrandr", "xfixes", "__with_cursor"}
273296

274297
# A dict to maintain *display* values created by multiple threads.
275298
_display_dict: Dict[threading.Thread, int] = {}
276299

277-
def __init__(self, display: Optional[Union[bytes, str]] = None) -> None:
300+
def __init__(
301+
self, display: Optional[Union[bytes, str]] = None, with_cursor: bool = False
302+
) -> None:
278303
"""GNU/Linux initialisations."""
279304

280305
super().__init__()
306+
self.with_cursor = with_cursor
281307

282308
if not display:
283309
try:
@@ -306,6 +332,13 @@ def __init__(self, display: Optional[Union[bytes, str]] = None) -> None:
306332
raise ScreenShotError("No Xrandr extension found.")
307333
self.xrandr = ctypes.cdll.LoadLibrary(xrandr)
308334

335+
if self.with_cursor:
336+
xfixes = ctypes.util.find_library("Xfixes")
337+
if xfixes:
338+
self.xfixes = ctypes.cdll.LoadLibrary(xfixes)
339+
else:
340+
self.with_cursor = False
341+
309342
self._set_cfunctions()
310343

311344
self.root = self.xlib.XDefaultRootWindow(self._get_display(display))
@@ -361,6 +394,7 @@ def _set_cfunctions(self) -> None:
361394
attrs = {
362395
"xlib": self.xlib,
363396
"xrandr": self.xrandr,
397+
"xfixes": getattr(self, "xfixes", None),
364398
}
365399
for func, (attr, argtypes, restype) in CFUNCTIONS.items():
366400
with contextlib.suppress(AttributeError):
@@ -450,3 +484,32 @@ def _grab_impl(self, monitor: Monitor) -> ScreenShot:
450484
self.xlib.XDestroyImage(ximage)
451485

452486
return self.cls_image(data, monitor)
487+
488+
def _cursor_impl(self) -> ScreenShot:
489+
"""Retrieve all cursor data. Pixels have to be RGB."""
490+
491+
# Read data of cursor/mouse-pointer
492+
cursor_data = self.xfixes.XFixesGetCursorImage(self._get_display())
493+
if not (cursor_data and cursor_data.contents):
494+
raise ScreenShotError("Cannot read XFixesGetCursorImage()")
495+
496+
ximage: XFixesCursorImage = cursor_data.contents
497+
monitor = {
498+
"left": ximage.x - ximage.xhot,
499+
"top": ximage.y - ximage.yhot,
500+
"width": ximage.width,
501+
"height": ximage.height,
502+
}
503+
504+
raw_data = cast(
505+
ximage.pixels, POINTER(c_ulong * monitor["height"] * monitor["width"])
506+
)
507+
raw = bytearray(raw_data.contents)
508+
509+
data = bytearray(monitor["height"] * monitor["width"] * 4)
510+
data[3::4] = raw[3::8]
511+
data[2::4] = raw[2::8]
512+
data[1::4] = raw[1::8]
513+
data[::4] = raw[::8]
514+
515+
return self.cls_image(data, monitor)

mss/tests/test_gnu_linux.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,14 @@ def test_has_extension():
132132
with mss.mss(display=display) as sct:
133133
assert sct.has_extension("RANDR")
134134
assert not sct.has_extension("NOEXT")
135+
136+
137+
def test_with_cursor():
138+
display = os.getenv("DISPLAY")
139+
with mss.mss(display=display, with_cursor=True) as sct:
140+
assert sct.xfixes
141+
assert sct.with_cursor
142+
sct.grab(sct.monitors[1])
143+
144+
# Not really sure how to test the cursor presence ...
145+
# Also need to test when the cursor it outside of the screenshot

mss/windows.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
UINT,
2323
WORD,
2424
)
25-
from typing import Any, Dict
25+
from typing import Any, Dict, Optional
2626

2727
from .base import MSSBase
2828
from .exception import ScreenShotError
@@ -282,3 +282,7 @@ def _grab_impl(self, monitor: Monitor) -> ScreenShot:
282282
raise ScreenShotError("gdi32.GetDIBits() failed.")
283283

284284
return self.cls_image(bytearray(self._data), monitor)
285+
286+
def _cursor_impl(self) -> Optional[ScreenShot]:
287+
"""Retrieve all cursor data. Pixels have to be RGB."""
288+
return None

0 commit comments

Comments
 (0)