From 6969a82d210ca22710f0204da299c15d687d1af0 Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Wed, 28 Jan 2026 23:28:11 +0100 Subject: [PATCH 1/8] Add Monitor class and primary monitor support Currently the Windows implementation is completed but other platforms just use a fallback to monitor in slot 1. Also added Monitor name for Windows. --- CHANGELOG.md | 5 +- src/mss/base.py | 45 ++++++++++++--- src/mss/darwin.py | 29 +++++----- src/mss/linux/base.py | 30 +++++----- src/mss/linux/xlib.py | 35 ++++++------ src/mss/models.py | 65 +++++++++++++++++++++- src/mss/screenshot.py | 4 +- src/mss/windows.py | 92 ++++++++++++++++++++++++++----- src/tests/test_cls_image.py | 2 +- src/tests/test_implementation.py | 4 +- src/tests/test_primary_monitor.py | 49 ++++++++++++++++ src/tests/test_setup.py | 1 + 12 files changed, 285 insertions(+), 76 deletions(-) create mode 100644 src/tests/test_primary_monitor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8397f6..13d7a479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,16 @@ See Git commit messages for full history. ## 10.2.0.dev0 (2026-xx-xx) +- Add `Monitor` class to replace `dict[str, int]` with `is_primary` and `name` attributes (#153) +- Add `primary_monitor` property to MSS base class for easy access to the primary monitor (#153) +- Windows: add primary monitor detection using `GetMonitorInfoW` API (#153) +- Windows: add monitor device name extraction using `EnumDisplayDevicesW` API (#153) - Windows: switch from `GetDIBits` to more memory efficient `CreateDIBSection` for `MSS.grab` implementation (#449) - Windows: fix gdi32.GetDIBits() failed after a couple of minutes of recording (#268) - Linux: check the server for Xrandr support version (#417) - Linux: improve typing and error messages for X libraries (#418) - Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425) - Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431) -- Windows: improve error checking and messages for Win32 API calls (#448) - Mac: fix memory leak (#450, #453) - improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452) - Add full demos for different ways to use MSS (#444, #456, #465) diff --git a/src/mss/base.py b/src/mss/base.py index b84d2ce4..ae2fa3be 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -167,7 +167,7 @@ def close(self) -> None: self._close_impl() self._closed = True - def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: + def grab(self, monitor: Monitor | dict[str, int] | tuple[int, int, int, int], /) -> ScreenShot: """Retrieve screen pixels for a given monitor. Note: ``monitor`` can be a tuple like the one @@ -177,14 +177,23 @@ def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: See :meth:`monitors ` for object details. :returns: Screenshot of the requested region. """ - # Convert PIL bbox style + from mss.models import Monitor as MonitorCls # noqa: PLC0415 + + # Convert PIL bbox style or dict to Monitor if isinstance(monitor, tuple): - monitor = { - "left": monitor[0], - "top": monitor[1], - "width": monitor[2] - monitor[0], - "height": monitor[3] - monitor[1], - } + monitor = MonitorCls( + monitor[0], + monitor[1], + monitor[2] - monitor[0], + monitor[3] - monitor[1], + ) + elif isinstance(monitor, dict): + monitor = MonitorCls( + monitor["left"], + monitor["top"], + monitor["width"], + monitor["height"], + ) if monitor["width"] <= 0 or monitor["height"] <= 0: msg = f"Region has zero or negative size: {monitor!r}" @@ -220,6 +229,26 @@ def monitors(self) -> Monitors: self._monitors_impl() return self._monitors + @property + def primary_monitor(self) -> Monitor | None: + """Get the primary monitor. + + Returns the monitor marked as primary. If no monitor is marked as primary + (or the platform doesn't support primary monitor detection), returns the + first monitor (at index 1). Returns None if no monitors are available. + + .. versionadded:: 10.2.0 + """ + monitors = self.monitors + if len(monitors) <= 1: # Only the "all monitors" entry or empty + return None + + for monitor in monitors[1:]: # Skip the "all monitors" entry at index 0 + if monitor.is_primary: + return monitor + # Fallback to the first monitor if no primary is found + return monitors[1] + def save( self, /, diff --git a/src/mss/darwin.py b/src/mss/darwin.py index 8f95e6d5..c41666c7 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -26,10 +26,11 @@ from mss.base import MSSBase from mss.exception import ScreenShotError +from mss.models import Monitor from mss.screenshot import ScreenShot, Size if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctions, Monitor + from mss.models import CFunctions __all__ = ("IMAGE_OPTIONS", "MSS") @@ -158,7 +159,7 @@ def _monitors_impl(self) -> None: # We need to update the value with every single monitor found # using CGRectUnion. Else we will end with infinite values. all_monitors = CGRect() - self._monitors.append({}) + self._monitors.append(Monitor(0, 0, 0, 0)) # Placeholder, updated later # Each monitor display_count = c_uint32(0) @@ -177,24 +178,24 @@ def _monitors_impl(self) -> None: width, height = height, width self._monitors.append( - { - "left": int_(rect.origin.x), - "top": int_(rect.origin.y), - "width": int_(width), - "height": int_(height), - }, + Monitor( + int_(rect.origin.x), + int_(rect.origin.y), + int_(width), + int_(height), + ), ) # Update AiO monitor's values all_monitors = core.CGRectUnion(all_monitors, rect) # Set the AiO monitor's values - self._monitors[0] = { - "left": int_(all_monitors.origin.x), - "top": int_(all_monitors.origin.y), - "width": int_(all_monitors.size.width), - "height": int_(all_monitors.size.height), - } + self._monitors[0] = Monitor( + int_(all_monitors.origin.x), + int_(all_monitors.origin.y), + int_(all_monitors.size.width), + int_(all_monitors.size.height), + ) def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index f9921d53..bb0e730f 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -4,12 +4,12 @@ from mss.base import MSSBase from mss.exception import ScreenShotError +from mss.models import Monitor from . import xcb from .xcb import LIB if TYPE_CHECKING: - from mss.models import Monitor from mss.screenshot import ScreenShot SUPPORTED_DEPTHS = {24, 32} @@ -144,12 +144,12 @@ def _monitors_impl(self) -> None: # monitors. root_geom = xcb.get_geometry(self.conn, self.root) self._monitors.append( - { - "left": root_geom.x, - "top": root_geom.y, - "width": root_geom.width, - "height": root_geom.height, - } + Monitor( + root_geom.x, + root_geom.y, + root_geom.width, + root_geom.height, + ) ) # After that, we have one for each monitor on that X11 screen. For decades, that's been handled by @@ -186,9 +186,7 @@ def _monitors_impl(self) -> None: crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp) if crtc_info.num_outputs == 0: continue - self._monitors.append( - {"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height} - ) + self._monitors.append(Monitor(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height)) # Extra credit would be to enumerate the virtual desktops; see # https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that @@ -232,12 +230,12 @@ def _cursor_impl(self) -> ScreenShot: raise ScreenShotError(msg) cursor_img = xcb.xfixes_get_cursor_image(self.conn) - region = { - "left": cursor_img.x - cursor_img.xhot, - "top": cursor_img.y - cursor_img.yhot, - "width": cursor_img.width, - "height": cursor_img.height, - } + region = Monitor( + cursor_img.x - cursor_img.xhot, + cursor_img.y - cursor_img.yhot, + cursor_img.width, + cursor_img.height, + ) data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img) data = bytearray(data_arr) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index 26708fef..b43802cf 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -37,9 +37,10 @@ from mss.base import MSSBase from mss.exception import ScreenShotError +from mss.models import Monitor if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctions, Monitor + from mss.models import CFunctions from mss.screenshot import ScreenShot __all__ = ("MSS",) @@ -542,7 +543,7 @@ def _monitors_impl(self) -> None: gwa = XWindowAttributes() self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) self._monitors.append( - {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, + Monitor(int_(gwa.x), int_(gwa.y), int_(gwa.width), int_(gwa.height)), ) # Each monitor @@ -565,12 +566,12 @@ def _monitors_impl(self) -> None: continue self._monitors.append( - { - "left": int_(crtc.x), - "top": int_(crtc.y), - "width": int_(crtc.width), - "height": int_(crtc.height), - }, + Monitor( + int_(crtc.x), + int_(crtc.y), + int_(crtc.width), + int_(crtc.height), + ), ) xrandr.XRRFreeCrtcInfo(crtc) xrandr.XRRFreeScreenResources(mon) @@ -618,17 +619,17 @@ def _cursor_impl(self) -> ScreenShot: raise ScreenShotError(msg) cursor_img: XFixesCursorImage = ximage.contents - region = { - "left": cursor_img.x - cursor_img.xhot, - "top": cursor_img.y - cursor_img.yhot, - "width": cursor_img.width, - "height": cursor_img.height, - } - - raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"])) + region = Monitor( + cursor_img.x - cursor_img.xhot, + cursor_img.y - cursor_img.yhot, + cursor_img.width, + cursor_img.height, + ) + + raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region.height * region.width)) raw = bytearray(raw_data.contents) - data = bytearray(region["height"] * region["width"] * 4) + data = bytearray(region.height * region.width * 4) data[3::4] = raw[3::8] data[2::4] = raw[2::8] data[1::4] = raw[1::8] diff --git a/src/mss/models.py b/src/mss/models.py index 2bddc9de..0f2a7911 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -1,14 +1,73 @@ # This is part of the MSS Python's module. # Source: https://github.com/BoboTiG/python-mss. +from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable, NamedTuple -Monitor = dict[str, int] -Monitors = list[Monitor] - Pixel = tuple[int, int, int] Pixels = list[tuple[Pixel, ...]] + +class Monitor: + """Represents a display monitor with its position and dimensions. + + :param left: The x-coordinate of the upper-left corner. + :param top: The y-coordinate of the upper-left corner. + :param width: The width of the monitor. + :param height: The height of the monitor. + :param is_primary: Whether this is the primary monitor. + :param name: The device name of the monitor (platform-specific). + """ + + __slots__ = ("height", "is_primary", "left", "name", "top", "width") + + def __init__( # noqa: PLR0913 + self, + left: int, + top: int, + width: int, + height: int, + *, + is_primary: bool = False, + name: str = "", + ) -> None: + self.left = left + self.top = top + self.width = width + self.height = height + self.is_primary = is_primary + self.name = name + + def __repr__(self) -> str: + return ( + f"Monitor(left={self.left}, top={self.top}, width={self.width}, " + f"height={self.height}, is_primary={self.is_primary}, name={self.name!r})" + ) + + def __getitem__(self, key: str) -> int | bool: + """Provide dict-like access for backward compatibility.""" + try: + return getattr(self, key) + except AttributeError as exc: + raise KeyError(key) from exc + + def __setitem__(self, key: str, value: int | bool) -> None: + """Provide dict-like setitem for backward compatibility.""" + if not hasattr(self, key): + raise KeyError(key) + setattr(self, key, value) + + def keys(self) -> list[str]: + """Provide dict-like keys() for backward compatibility.""" + return list(self.__slots__) + + def __contains__(self, key: str) -> bool: + """Provide dict-like 'in' operator for backward compatibility.""" + return hasattr(self, key) and key in self.__slots__ + + +Monitors = list[Monitor] + if TYPE_CHECKING: CFunctions = dict[str, tuple[str, list[Any], Any]] CFunctionsErrChecked = dict[str, tuple[str, list[Any], Any, Callable | None]] diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py index 9443aba4..a931c35c 100644 --- a/src/mss/screenshot.py +++ b/src/mss/screenshot.py @@ -68,7 +68,9 @@ def __array_interface__(self) -> dict[str, Any]: @classmethod def from_size(cls: type[ScreenShot], data: bytearray, width: int, height: int, /) -> ScreenShot: """Instantiate a new class given only screenshot's data and size.""" - monitor = {"left": 0, "top": 0, "width": width, "height": height} + from mss.models import Monitor # noqa: PLC0415 + + monitor = Monitor(0, 0, width, height) return cls(data, monitor) @property diff --git a/src/mss/windows.py b/src/mss/windows.py index 6726862e..162716ec 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -32,9 +32,10 @@ from mss.base import MSSBase from mss.exception import ScreenShotError +from mss.models import Monitor if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctionsErrChecked, Monitor + from mss.models import CFunctionsErrChecked from mss.screenshot import ScreenShot __all__ = ("MSS",) @@ -46,6 +47,9 @@ CAPTUREBLT = 0x40000000 DIB_RGB_COLORS = 0 SRCCOPY = 0x00CC0020 +CCHDEVICENAME = 32 +MONITORINFOF_PRIMARY = 0x01 +EDD_GET_DEVICE_INTERFACE_NAME = 0x00000001 class BITMAPINFOHEADER(Structure): @@ -74,6 +78,35 @@ class BITMAPINFO(Structure): _fields_ = (("bmiHeader", BITMAPINFOHEADER), ("bmiColors", BYTE * 4)) +class MONITORINFOEXW(Structure): + """Extended monitor information structure. + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-monitorinfoexw + """ + + _fields_ = ( + ("cbSize", DWORD), + ("rcMonitor", RECT), + ("rcWork", RECT), + ("dwFlags", DWORD), + ("szDevice", WORD * CCHDEVICENAME), + ) + + +class DISPLAY_DEVICEW(Structure): # noqa: N801 + """Display device information structure. + https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-display_devicew + """ + + _fields_ = ( + ("cb", DWORD), + ("DeviceName", WORD * 32), + ("DeviceString", WORD * 128), + ("StateFlags", DWORD), + ("DeviceID", WORD * 128), + ("DeviceKey", WORD * 128), + ) + + MONITORNUMPROC = WINFUNCTYPE(BOOL, HMONITOR, HDC, POINTER(RECT), LPARAM) @@ -113,6 +146,7 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl "CreateDIBSection": ("gdi32", [HDC, POINTER(BITMAPINFO), UINT, POINTER(LPVOID), HANDLE, DWORD], HBITMAP, _errcheck), "DeleteDC": ("gdi32", [HDC], HDC, _errcheck), "DeleteObject": ("gdi32", [HGDIOBJ], BOOL, _errcheck), + "EnumDisplayDevicesW": ("user32", [POINTER(WORD), DWORD, POINTER(DISPLAY_DEVICEW), DWORD], BOOL, None), "EnumDisplayMonitors": ("user32", [HDC, LPCRECT, MONITORNUMPROC, LPARAM], BOOL, _errcheck), # GdiFlush flushes the calling thread's current batch of GDI operations. # This ensures DIB memory is fully updated before reading. @@ -121,6 +155,7 @@ def _errcheck(result: BOOL | _Pointer, func: Callable, arguments: tuple) -> tupl # parameter is valid but the value is actually 0 (e.g., SM_CLEANBOOT on a normal boot). Thus, we do not attach an # errcheck function here. "GetSystemMetrics": ("user32", [INT], INT, None), + "GetMonitorInfoW": ("user32", [HMONITOR, POINTER(MONITORINFOEXW)], BOOL, _errcheck), "GetWindowDC": ("user32", [HWND], HDC, _errcheck), "ReleaseDC": ("user32", [HWND, HDC], INT, _errcheck), # SelectObject returns NULL on error the way we call it. If it's called to select a region, it returns HGDI_ERROR @@ -232,28 +267,57 @@ def _monitors_impl(self) -> None: # All monitors self._monitors.append( - { - "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN - "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN - "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN - "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN - }, + Monitor( + int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN (left) + int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN (top) + int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN (width) + int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN (height) + ), ) # Each monitor @MONITORNUMPROC - def callback(_monitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: + def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: """Callback for monitorenumproc() function, it will return a RECT with appropriate values. """ + # Get monitor info to check if it's the primary monitor and get device name + info = MONITORINFOEXW() + info.cbSize = ctypes.sizeof(MONITORINFOEXW) + user32.GetMonitorInfoW(hmonitor, ctypes.byref(info)) + rct = rect.contents + left = int_(rct.left) + top = int_(rct.top) + # Check the dwFlags field for MONITORINFOF_PRIMARY + is_primary = bool(info.dwFlags & MONITORINFOF_PRIMARY) + # Extract device name (null-terminated wide string) + device_name = ctypes.wstring_at(ctypes.addressof(info.szDevice)) + + # Get friendly device string (manufacturer/model info) + display_device = DISPLAY_DEVICEW() + display_device.cb = ctypes.sizeof(DISPLAY_DEVICEW) + device_string = device_name + + # EnumDisplayDevicesW can get more detailed info about the device + if user32.EnumDisplayDevicesW( + ctypes.cast(ctypes.addressof(info.szDevice), POINTER(WORD)), + 0, + ctypes.byref(display_device), + 0, + ): + # DeviceString contains the friendly name like "Generic PnP Monitor" or manufacturer name + device_string = ctypes.wstring_at(ctypes.addressof(display_device.DeviceString)) + self._monitors.append( - { - "left": int_(rct.left), - "top": int_(rct.top), - "width": int_(rct.right) - int_(rct.left), - "height": int_(rct.bottom) - int_(rct.top), - }, + Monitor( + left, + top, + int_(rct.right) - left, + int_(rct.bottom) - top, + is_primary=is_primary, + name=device_string, + ), ) return True diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py index cbf02aed..e07f2672 100644 --- a/src/tests/test_cls_image.py +++ b/src/tests/test_cls_image.py @@ -22,4 +22,4 @@ def test_custom_cls_image(mss_impl: Callable[..., MSSBase]) -> None: image = sct.grab(mon1) assert isinstance(image, SimpleScreenShot) assert isinstance(image.raw, bytes) - assert isinstance(image.monitor, dict) + assert isinstance(image.monitor, Monitor) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index ecd91eef..51faae7d 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -68,8 +68,10 @@ def test_bad_monitor(mss_impl: Callable[..., MSSBase]) -> None: def test_repr(mss_impl: Callable[..., MSSBase]) -> None: + from mss.models import Monitor # noqa: PLC0415 + box = {"top": 0, "left": 0, "width": 10, "height": 10} - expected_box = {"top": 0, "left": 0, "width": 10, "height": 10} + expected_box = Monitor(0, 0, 10, 10) with mss_impl() as sct: img = sct.grab(box) ref = ScreenShot(bytearray(b"42"), expected_box) diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py new file mode 100644 index 00000000..71c3d64e --- /dev/null +++ b/src/tests/test_primary_monitor.py @@ -0,0 +1,49 @@ +"""This is part of the MSS Python's module. +Source: https://github.com/BoboTiG/python-mss. +""" + +from collections.abc import Callable + +import pytest + +from mss.base import MSSBase + + +def test_primary_monitor_is_in_monitors_list(mss_impl: Callable[..., MSSBase]) -> None: + """Test that the primary monitor is in the monitors list.""" + with mss_impl() as sct: + primary = sct.primary_monitor + monitors = sct.monitors + assert primary is not None + # Primary should be one of the monitors (excluding index 0 which is "all monitors") + assert primary in monitors[1:] + + +def test_primary_monitor_marked_or_first(mss_impl: Callable[..., MSSBase]) -> None: + """Test that primary_monitor returns either the marked primary or the first monitor.""" + with mss_impl() as sct: + primary = sct.primary_monitor + monitors = sct.monitors + assert primary is not None + + # Either it's marked as primary, or it's the first monitor + if primary.is_primary: + # Should be marked as primary + assert primary.is_primary is True + else: + # Should be the first monitor as fallback + assert primary == monitors[1] + + +@pytest.mark.skipif("mss.windows" not in dir(), reason="Windows only") +def test_primary_monitor_coordinates_windows() -> None: + """Test that on Windows, the primary monitor has coordinates at (0, 0).""" + import mss # noqa: PLC0415 + + with mss.mss() as sct: + primary = sct.primary_monitor + assert primary is not None + if primary.is_primary: + # On Windows, the primary monitor is at (0, 0) + assert primary.left == 0 + assert primary.top == 0 diff --git a/src/tests/test_setup.py b/src/tests/test_setup.py index 940a4f5b..29be9b62 100644 --- a/src/tests/test_setup.py +++ b/src/tests/test_setup.py @@ -97,6 +97,7 @@ def test_sdist() -> None: f"mss-{__version__}/src/tests/test_issue_220.py", f"mss-{__version__}/src/tests/test_leaks.py", f"mss-{__version__}/src/tests/test_macos.py", + f"mss-{__version__}/src/tests/test_primary_monitor.py", f"mss-{__version__}/src/tests/test_save.py", f"mss-{__version__}/src/tests/test_setup.py", f"mss-{__version__}/src/tests/test_tools.py", From b39ef85d49b74c112381e82b6f86899bb99a734d Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Thu, 29 Jan 2026 20:44:10 +0100 Subject: [PATCH 2/8] Roll back some of the Monitor related changes This is to maintain full backwards compatibility. --- CHANGELOG.md | 2 +- src/mss/base.py | 27 +++++-------- src/mss/darwin.py | 29 +++++++------- src/mss/linux/base.py | 30 ++++++++------- src/mss/linux/xlib.py | 35 ++++++++--------- src/mss/models.py | 64 ++----------------------------- src/mss/screenshot.py | 4 +- src/mss/windows.py | 31 ++++++++------- src/tests/test_cls_image.py | 2 +- src/tests/test_implementation.py | 4 +- src/tests/test_primary_monitor.py | 22 ++++++++--- 11 files changed, 95 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d7a479..e408467f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ See Git commit messages for full history. ## 10.2.0.dev0 (2026-xx-xx) -- Add `Monitor` class to replace `dict[str, int]` with `is_primary` and `name` attributes (#153) +- Add `is_primary` and `name` keys to Monitor dicts for primary monitor detection and device names (#153) - Add `primary_monitor` property to MSS base class for easy access to the primary monitor (#153) - Windows: add primary monitor detection using `GetMonitorInfoW` API (#153) - Windows: add monitor device name extraction using `EnumDisplayDevicesW` API (#153) diff --git a/src/mss/base.py b/src/mss/base.py index ae2fa3be..179eed74 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -167,7 +167,7 @@ def close(self) -> None: self._close_impl() self._closed = True - def grab(self, monitor: Monitor | dict[str, int] | tuple[int, int, int, int], /) -> ScreenShot: + def grab(self, monitor: Monitor | tuple[int, int, int, int], /) -> ScreenShot: """Retrieve screen pixels for a given monitor. Note: ``monitor`` can be a tuple like the one @@ -177,23 +177,14 @@ def grab(self, monitor: Monitor | dict[str, int] | tuple[int, int, int, int], /) See :meth:`monitors ` for object details. :returns: Screenshot of the requested region. """ - from mss.models import Monitor as MonitorCls # noqa: PLC0415 - - # Convert PIL bbox style or dict to Monitor + # Convert PIL bbox style if isinstance(monitor, tuple): - monitor = MonitorCls( - monitor[0], - monitor[1], - monitor[2] - monitor[0], - monitor[3] - monitor[1], - ) - elif isinstance(monitor, dict): - monitor = MonitorCls( - monitor["left"], - monitor["top"], - monitor["width"], - monitor["height"], - ) + monitor = { + "left": monitor[0], + "top": monitor[1], + "width": monitor[2] - monitor[0], + "height": monitor[3] - monitor[1], + } if monitor["width"] <= 0 or monitor["height"] <= 0: msg = f"Region has zero or negative size: {monitor!r}" @@ -244,7 +235,7 @@ def primary_monitor(self) -> Monitor | None: return None for monitor in monitors[1:]: # Skip the "all monitors" entry at index 0 - if monitor.is_primary: + if monitor.get("is_primary", False): return monitor # Fallback to the first monitor if no primary is found return monitors[1] diff --git a/src/mss/darwin.py b/src/mss/darwin.py index c41666c7..8f95e6d5 100644 --- a/src/mss/darwin.py +++ b/src/mss/darwin.py @@ -26,11 +26,10 @@ from mss.base import MSSBase from mss.exception import ScreenShotError -from mss.models import Monitor from mss.screenshot import ScreenShot, Size if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctions + from mss.models import CFunctions, Monitor __all__ = ("IMAGE_OPTIONS", "MSS") @@ -159,7 +158,7 @@ def _monitors_impl(self) -> None: # We need to update the value with every single monitor found # using CGRectUnion. Else we will end with infinite values. all_monitors = CGRect() - self._monitors.append(Monitor(0, 0, 0, 0)) # Placeholder, updated later + self._monitors.append({}) # Each monitor display_count = c_uint32(0) @@ -178,24 +177,24 @@ def _monitors_impl(self) -> None: width, height = height, width self._monitors.append( - Monitor( - int_(rect.origin.x), - int_(rect.origin.y), - int_(width), - int_(height), - ), + { + "left": int_(rect.origin.x), + "top": int_(rect.origin.y), + "width": int_(width), + "height": int_(height), + }, ) # Update AiO monitor's values all_monitors = core.CGRectUnion(all_monitors, rect) # Set the AiO monitor's values - self._monitors[0] = Monitor( - int_(all_monitors.origin.x), - int_(all_monitors.origin.y), - int_(all_monitors.size.width), - int_(all_monitors.size.height), - ) + self._monitors[0] = { + "left": int_(all_monitors.origin.x), + "top": int_(all_monitors.origin.y), + "width": int_(all_monitors.size.width), + "height": int_(all_monitors.size.height), + } def _grab_impl(self, monitor: Monitor, /) -> ScreenShot: """Retrieve all pixels from a monitor. Pixels have to be RGB.""" diff --git a/src/mss/linux/base.py b/src/mss/linux/base.py index bb0e730f..f9921d53 100644 --- a/src/mss/linux/base.py +++ b/src/mss/linux/base.py @@ -4,12 +4,12 @@ from mss.base import MSSBase from mss.exception import ScreenShotError -from mss.models import Monitor from . import xcb from .xcb import LIB if TYPE_CHECKING: + from mss.models import Monitor from mss.screenshot import ScreenShot SUPPORTED_DEPTHS = {24, 32} @@ -144,12 +144,12 @@ def _monitors_impl(self) -> None: # monitors. root_geom = xcb.get_geometry(self.conn, self.root) self._monitors.append( - Monitor( - root_geom.x, - root_geom.y, - root_geom.width, - root_geom.height, - ) + { + "left": root_geom.x, + "top": root_geom.y, + "width": root_geom.width, + "height": root_geom.height, + } ) # After that, we have one for each monitor on that X11 screen. For decades, that's been handled by @@ -186,7 +186,9 @@ def _monitors_impl(self) -> None: crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp) if crtc_info.num_outputs == 0: continue - self._monitors.append(Monitor(crtc_info.x, crtc_info.y, crtc_info.width, crtc_info.height)) + self._monitors.append( + {"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height} + ) # Extra credit would be to enumerate the virtual desktops; see # https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that @@ -230,12 +232,12 @@ def _cursor_impl(self) -> ScreenShot: raise ScreenShotError(msg) cursor_img = xcb.xfixes_get_cursor_image(self.conn) - region = Monitor( - cursor_img.x - cursor_img.xhot, - cursor_img.y - cursor_img.yhot, - cursor_img.width, - cursor_img.height, - ) + region = { + "left": cursor_img.x - cursor_img.xhot, + "top": cursor_img.y - cursor_img.yhot, + "width": cursor_img.width, + "height": cursor_img.height, + } data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img) data = bytearray(data_arr) diff --git a/src/mss/linux/xlib.py b/src/mss/linux/xlib.py index b43802cf..26708fef 100644 --- a/src/mss/linux/xlib.py +++ b/src/mss/linux/xlib.py @@ -37,10 +37,9 @@ from mss.base import MSSBase from mss.exception import ScreenShotError -from mss.models import Monitor if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctions + from mss.models import CFunctions, Monitor from mss.screenshot import ScreenShot __all__ = ("MSS",) @@ -543,7 +542,7 @@ def _monitors_impl(self) -> None: gwa = XWindowAttributes() self.xlib.XGetWindowAttributes(display, self._handles.root, byref(gwa)) self._monitors.append( - Monitor(int_(gwa.x), int_(gwa.y), int_(gwa.width), int_(gwa.height)), + {"left": int_(gwa.x), "top": int_(gwa.y), "width": int_(gwa.width), "height": int_(gwa.height)}, ) # Each monitor @@ -566,12 +565,12 @@ def _monitors_impl(self) -> None: continue self._monitors.append( - Monitor( - int_(crtc.x), - int_(crtc.y), - int_(crtc.width), - int_(crtc.height), - ), + { + "left": int_(crtc.x), + "top": int_(crtc.y), + "width": int_(crtc.width), + "height": int_(crtc.height), + }, ) xrandr.XRRFreeCrtcInfo(crtc) xrandr.XRRFreeScreenResources(mon) @@ -619,17 +618,17 @@ def _cursor_impl(self) -> ScreenShot: raise ScreenShotError(msg) cursor_img: XFixesCursorImage = ximage.contents - region = Monitor( - cursor_img.x - cursor_img.xhot, - cursor_img.y - cursor_img.yhot, - cursor_img.width, - cursor_img.height, - ) - - raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region.height * region.width)) + region = { + "left": cursor_img.x - cursor_img.xhot, + "top": cursor_img.y - cursor_img.yhot, + "width": cursor_img.width, + "height": cursor_img.height, + } + + raw_data = cast(cursor_img.pixels, POINTER(c_ulong * region["height"] * region["width"])) raw = bytearray(raw_data.contents) - data = bytearray(region.height * region.width * 4) + data = bytearray(region["height"] * region["width"] * 4) data[3::4] = raw[3::8] data[2::4] = raw[2::8] data[1::4] = raw[1::8] diff --git a/src/mss/models.py b/src/mss/models.py index 0f2a7911..7c3c4d2b 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -4,70 +4,12 @@ from typing import TYPE_CHECKING, Any, Callable, NamedTuple +Monitor = dict[str, Any] +Monitors = list[Monitor] + Pixel = tuple[int, int, int] Pixels = list[tuple[Pixel, ...]] - -class Monitor: - """Represents a display monitor with its position and dimensions. - - :param left: The x-coordinate of the upper-left corner. - :param top: The y-coordinate of the upper-left corner. - :param width: The width of the monitor. - :param height: The height of the monitor. - :param is_primary: Whether this is the primary monitor. - :param name: The device name of the monitor (platform-specific). - """ - - __slots__ = ("height", "is_primary", "left", "name", "top", "width") - - def __init__( # noqa: PLR0913 - self, - left: int, - top: int, - width: int, - height: int, - *, - is_primary: bool = False, - name: str = "", - ) -> None: - self.left = left - self.top = top - self.width = width - self.height = height - self.is_primary = is_primary - self.name = name - - def __repr__(self) -> str: - return ( - f"Monitor(left={self.left}, top={self.top}, width={self.width}, " - f"height={self.height}, is_primary={self.is_primary}, name={self.name!r})" - ) - - def __getitem__(self, key: str) -> int | bool: - """Provide dict-like access for backward compatibility.""" - try: - return getattr(self, key) - except AttributeError as exc: - raise KeyError(key) from exc - - def __setitem__(self, key: str, value: int | bool) -> None: - """Provide dict-like setitem for backward compatibility.""" - if not hasattr(self, key): - raise KeyError(key) - setattr(self, key, value) - - def keys(self) -> list[str]: - """Provide dict-like keys() for backward compatibility.""" - return list(self.__slots__) - - def __contains__(self, key: str) -> bool: - """Provide dict-like 'in' operator for backward compatibility.""" - return hasattr(self, key) and key in self.__slots__ - - -Monitors = list[Monitor] - if TYPE_CHECKING: CFunctions = dict[str, tuple[str, list[Any], Any]] CFunctionsErrChecked = dict[str, tuple[str, list[Any], Any, Callable | None]] diff --git a/src/mss/screenshot.py b/src/mss/screenshot.py index a931c35c..9443aba4 100644 --- a/src/mss/screenshot.py +++ b/src/mss/screenshot.py @@ -68,9 +68,7 @@ def __array_interface__(self) -> dict[str, Any]: @classmethod def from_size(cls: type[ScreenShot], data: bytearray, width: int, height: int, /) -> ScreenShot: """Instantiate a new class given only screenshot's data and size.""" - from mss.models import Monitor # noqa: PLC0415 - - monitor = Monitor(0, 0, width, height) + monitor = {"left": 0, "top": 0, "width": width, "height": height} return cls(data, monitor) @property diff --git a/src/mss/windows.py b/src/mss/windows.py index 162716ec..86829fec 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -32,10 +32,9 @@ from mss.base import MSSBase from mss.exception import ScreenShotError -from mss.models import Monitor if TYPE_CHECKING: # pragma: nocover - from mss.models import CFunctionsErrChecked + from mss.models import CFunctionsErrChecked, Monitor from mss.screenshot import ScreenShot __all__ = ("MSS",) @@ -267,12 +266,12 @@ def _monitors_impl(self) -> None: # All monitors self._monitors.append( - Monitor( - int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN (left) - int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN (top) - int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN (width) - int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN (height) - ), + { + "left": int_(get_system_metrics(76)), # SM_XVIRTUALSCREEN + "top": int_(get_system_metrics(77)), # SM_YVIRTUALSCREEN + "width": int_(get_system_metrics(78)), # SM_CXVIRTUALSCREEN + "height": int_(get_system_metrics(79)), # SM_CYVIRTUALSCREEN + }, ) # Each monitor @@ -310,14 +309,14 @@ def callback(hmonitor: HMONITOR, _data: HDC, rect: LPRECT, _dc: LPARAM) -> bool: device_string = ctypes.wstring_at(ctypes.addressof(display_device.DeviceString)) self._monitors.append( - Monitor( - left, - top, - int_(rct.right) - left, - int_(rct.bottom) - top, - is_primary=is_primary, - name=device_string, - ), + { + "left": left, + "top": top, + "width": int_(rct.right) - left, + "height": int_(rct.bottom) - top, + "is_primary": is_primary, + "name": device_string, + }, ) return True diff --git a/src/tests/test_cls_image.py b/src/tests/test_cls_image.py index e07f2672..cbf02aed 100644 --- a/src/tests/test_cls_image.py +++ b/src/tests/test_cls_image.py @@ -22,4 +22,4 @@ def test_custom_cls_image(mss_impl: Callable[..., MSSBase]) -> None: image = sct.grab(mon1) assert isinstance(image, SimpleScreenShot) assert isinstance(image.raw, bytes) - assert isinstance(image.monitor, Monitor) + assert isinstance(image.monitor, dict) diff --git a/src/tests/test_implementation.py b/src/tests/test_implementation.py index 51faae7d..ecd91eef 100644 --- a/src/tests/test_implementation.py +++ b/src/tests/test_implementation.py @@ -68,10 +68,8 @@ def test_bad_monitor(mss_impl: Callable[..., MSSBase]) -> None: def test_repr(mss_impl: Callable[..., MSSBase]) -> None: - from mss.models import Monitor # noqa: PLC0415 - box = {"top": 0, "left": 0, "width": 10, "height": 10} - expected_box = Monitor(0, 0, 10, 10) + expected_box = {"top": 0, "left": 0, "width": 10, "height": 10} with mss_impl() as sct: img = sct.grab(box) ref = ScreenShot(bytearray(b"42"), expected_box) diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py index 71c3d64e..97f056c0 100644 --- a/src/tests/test_primary_monitor.py +++ b/src/tests/test_primary_monitor.py @@ -9,6 +9,18 @@ from mss.base import MSSBase +def test_primary_monitor_exists(mss_impl: Callable[..., MSSBase]) -> None: + """Test that primary_monitor returns a monitor dict.""" + with mss_impl() as sct: + primary = sct.primary_monitor + assert primary is not None + assert isinstance(primary, dict) + assert "left" in primary + assert "top" in primary + assert "width" in primary + assert "height" in primary + + def test_primary_monitor_is_in_monitors_list(mss_impl: Callable[..., MSSBase]) -> None: """Test that the primary monitor is in the monitors list.""" with mss_impl() as sct: @@ -27,9 +39,9 @@ def test_primary_monitor_marked_or_first(mss_impl: Callable[..., MSSBase]) -> No assert primary is not None # Either it's marked as primary, or it's the first monitor - if primary.is_primary: + if primary.get("is_primary", False): # Should be marked as primary - assert primary.is_primary is True + assert primary["is_primary"] is True else: # Should be the first monitor as fallback assert primary == monitors[1] @@ -43,7 +55,7 @@ def test_primary_monitor_coordinates_windows() -> None: with mss.mss() as sct: primary = sct.primary_monitor assert primary is not None - if primary.is_primary: + if primary.get("is_primary", False): # On Windows, the primary monitor is at (0, 0) - assert primary.left == 0 - assert primary.top == 0 + assert primary["left"] == 0 + assert primary["top"] == 0 From 3f74986e0157f03f42f0c2c64e10562fbce769a3 Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Thu, 29 Jan 2026 20:58:26 +0100 Subject: [PATCH 3/8] Streamline tests for Monitor --- src/tests/test_primary_monitor.py | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py index 97f056c0..c2692244 100644 --- a/src/tests/test_primary_monitor.py +++ b/src/tests/test_primary_monitor.py @@ -9,10 +9,13 @@ from mss.base import MSSBase -def test_primary_monitor_exists(mss_impl: Callable[..., MSSBase]) -> None: - """Test that primary_monitor returns a monitor dict.""" +def test_primary_monitor(mss_impl: Callable[..., MSSBase]) -> None: + """Test that primary_monitor property works correctly.""" with mss_impl() as sct: primary = sct.primary_monitor + monitors = sct.monitors + + # Should return a valid monitor dict assert primary is not None assert isinstance(primary, dict) assert "left" in primary @@ -20,30 +23,13 @@ def test_primary_monitor_exists(mss_impl: Callable[..., MSSBase]) -> None: assert "width" in primary assert "height" in primary - -def test_primary_monitor_is_in_monitors_list(mss_impl: Callable[..., MSSBase]) -> None: - """Test that the primary monitor is in the monitors list.""" - with mss_impl() as sct: - primary = sct.primary_monitor - monitors = sct.monitors - assert primary is not None - # Primary should be one of the monitors (excluding index 0 which is "all monitors") + # Should be in the monitors list (excluding index 0 which is "all monitors") assert primary in monitors[1:] - -def test_primary_monitor_marked_or_first(mss_impl: Callable[..., MSSBase]) -> None: - """Test that primary_monitor returns either the marked primary or the first monitor.""" - with mss_impl() as sct: - primary = sct.primary_monitor - monitors = sct.monitors - assert primary is not None - - # Either it's marked as primary, or it's the first monitor + # Should either be marked as primary or be the first monitor as fallback if primary.get("is_primary", False): - # Should be marked as primary assert primary["is_primary"] is True else: - # Should be the first monitor as fallback assert primary == monitors[1] From c4ce4a9f941a5cd2017acb04fb4251817e0c4eac Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Thu, 29 Jan 2026 21:24:41 +0100 Subject: [PATCH 4/8] Add TODO for Monitor class Had to adjust pyproject.toml so that TODOs don't fail the checking. Wish they could be flagged as warnings but ruff does not support warnings, only errors. Before major releases we should remove the ignore entry for TODO and see what we get. --- pyproject.toml | 1 + src/mss/models.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8a163e57..0b64761e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,7 @@ ignore = [ "C90", # complexity "COM812", # conflict "D", # TODO + "FIX002", # Line contains TODO "ISC001", # conflict "T201", # `print()` ] diff --git a/src/mss/models.py b/src/mss/models.py index 7c3c4d2b..da65594b 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any, Callable, NamedTuple +# TODO @BoboTiG: https://github.com/BoboTiG/python-mss/issues/470 +# Change this to a proper Monitor class in next major release. Monitor = dict[str, Any] Monitors = list[Monitor] From dc9e3b596638730ffe60562c47b7cfae201850ca Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Fri, 30 Jan 2026 08:38:25 +0100 Subject: [PATCH 5/8] Apply suggestions from code review Add back a line that I accidentally dropped from the CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e408467f..b06aaeee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ See Git commit messages for full history. - Linux: improve typing and error messages for X libraries (#418) - Linux: introduce an XCB-powered backend stack with a factory in ``mss.linux`` while keeping the Xlib code as a fallback (#425) - Linux: add the XShmGetImage backend with automatic XGetImage fallback and explicit status reporting (#431) +- Windows: improve error checking and messages for Win32 API calls (#448) - Mac: fix memory leak (#450, #453) - improve multithreading: allow multiple threads to use the same MSS object, allow multiple MSS objects to concurrently take screenshots, and document multithreading guarantees (#446, #452) - Add full demos for different ways to use MSS (#444, #456, #465) From 3fd5c15dc8f112229ac76513c560e77a78f1aeab Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Fri, 30 Jan 2026 08:44:49 +0100 Subject: [PATCH 6/8] Raise exception if no monitors are attached and primary_monitor is accessed --- src/mss/base.py | 8 +++++--- src/tests/test_primary_monitor.py | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mss/base.py b/src/mss/base.py index 179eed74..5baa8dc4 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -221,18 +221,20 @@ def monitors(self) -> Monitors: return self._monitors @property - def primary_monitor(self) -> Monitor | None: + def primary_monitor(self) -> Monitor: """Get the primary monitor. Returns the monitor marked as primary. If no monitor is marked as primary (or the platform doesn't support primary monitor detection), returns the - first monitor (at index 1). Returns None if no monitors are available. + first monitor (at index 1). + + :raises ScreenShotError: If no monitors are available. .. versionadded:: 10.2.0 """ monitors = self.monitors if len(monitors) <= 1: # Only the "all monitors" entry or empty - return None + raise ScreenShotError("No monitor found.") for monitor in monitors[1:]: # Skip the "all monitors" entry at index 0 if monitor.get("is_primary", False): diff --git a/src/tests/test_primary_monitor.py b/src/tests/test_primary_monitor.py index c2692244..cb78a3ea 100644 --- a/src/tests/test_primary_monitor.py +++ b/src/tests/test_primary_monitor.py @@ -16,7 +16,6 @@ def test_primary_monitor(mss_impl: Callable[..., MSSBase]) -> None: monitors = sct.monitors # Should return a valid monitor dict - assert primary is not None assert isinstance(primary, dict) assert "left" in primary assert "top" in primary @@ -40,7 +39,6 @@ def test_primary_monitor_coordinates_windows() -> None: with mss.mss() as sct: primary = sct.primary_monitor - assert primary is not None if primary.get("is_primary", False): # On Windows, the primary monitor is at (0, 0) assert primary["left"] == 0 From 4dd4ea0b2e4a58e59d2c43178d0e8472f3714699 Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Fri, 30 Jan 2026 08:50:37 +0100 Subject: [PATCH 7/8] Improve the Sphinx configuration This will make the docs build without warning. The warning is benign. --- docs/source/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 76d00c63..de88ec8a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,8 @@ "undoc-members": True, "show-inheritance": True, } +# Suppress duplicate target warnings for re-exported classes +suppress_warnings = ["ref.python"] # Monkey-patch WINFUNCTYPE and WinError into ctypes, so that we can # import mss.windows while building the documentation. From 4907986adb0cdf3285a07ee557bc4d390179cd0c Mon Sep 17 00:00:00 2001 From: Halldor Fannar Date: Wed, 4 Feb 2026 10:05:51 +0000 Subject: [PATCH 8/8] Add developer-facing changes to CHANGES.md --- CHANGES.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 1f9ef079..93f99494 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,22 @@ # Technical Changes +## 10.2.0 (2026-xx-xx) + +### base.py +- Added `primary_monitor` property to return the primary monitor (or first monitor as fallback). + +### models.py +- Changed `Monitor` type from `dict[str, int]` to `dict[str, Any]` to support new `is_primary` (bool) and `name` (str) fields. +- Added TODO comment for future Monitor class implementation (#470). + +### windows.py +- Added `MONITORINFOEXW` structure for extended monitor information. +- Added `DISPLAY_DEVICEW` structure for device information. +- Added constants: `CCHDEVICENAME`, `MONITORINFOF_PRIMARY`, `EDD_GET_DEVICE_INTERFACE_NAME`. +- Added `GetMonitorInfoW` to `CFUNCTIONS` for querying monitor properties. +- Added `EnumDisplayDevicesW` to `CFUNCTIONS` for querying device details. +- Modified `_monitors_impl()` callback to extract primary monitor flag and device names using Win32 APIs. + ## 10.1.1 (2025-xx-xx) ### linux/__init__.py