diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8397f..b06aaee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ See Git commit messages for full history. ## 10.2.0.dev0 (2026-xx-xx) +- 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) - 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) diff --git a/CHANGES.md b/CHANGES.md index 1f9ef07..93f9949 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 diff --git a/docs/source/conf.py b/docs/source/conf.py index 76d00c6..de88ec8 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. diff --git a/pyproject.toml b/pyproject.toml index 8a163e5..0b64761 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/base.py b/src/mss/base.py index b84d2ce..5baa8dc 100644 --- a/src/mss/base.py +++ b/src/mss/base.py @@ -220,6 +220,28 @@ def monitors(self) -> Monitors: self._monitors_impl() return self._monitors + @property + 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). + + :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 + raise ScreenShotError("No monitor found.") + + for monitor in monitors[1:]: # Skip the "all monitors" entry at index 0 + if monitor.get("is_primary", False): + return monitor + # Fallback to the first monitor if no primary is found + return monitors[1] + def save( self, /, diff --git a/src/mss/models.py b/src/mss/models.py index 2bddc9d..da65594 100644 --- a/src/mss/models.py +++ b/src/mss/models.py @@ -1,9 +1,12 @@ # 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] +# 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] Pixel = tuple[int, int, int] diff --git a/src/mss/windows.py b/src/mss/windows.py index 6726862..86829fe 100644 --- a/src/mss/windows.py +++ b/src/mss/windows.py @@ -46,6 +46,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 +77,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 +145,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 +154,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 @@ -242,17 +276,46 @@ def _monitors_impl(self) -> None: # 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), + "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_primary_monitor.py b/src/tests/test_primary_monitor.py new file mode 100644 index 0000000..cb78a3e --- /dev/null +++ b/src/tests/test_primary_monitor.py @@ -0,0 +1,45 @@ +"""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(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 isinstance(primary, dict) + assert "left" in primary + assert "top" in primary + assert "width" in primary + assert "height" in primary + + # Should be in the monitors list (excluding index 0 which is "all monitors") + assert primary in monitors[1:] + + # Should either be marked as primary or be the first monitor as fallback + if primary.get("is_primary", False): + assert primary["is_primary"] is True + else: + 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 + if primary.get("is_primary", False): + # 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 940a4f5..29be9b6 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",