Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
See Git commit messages for full history.

## 10.2.0.dev0 (2026-xx-xx)
- Add `is_primary`, `name`, and `unique_id` keys to Monitor dicts for primary monitor detection, device names, and stable per-monitor identification (#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 and unique device interface name 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)
Expand Down
17 changes: 17 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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, optional), `name` (str, optional), and `unique_id` (str, optional) 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, device names, and device interface name (unique_id) using Win32 APIs; `unique_id` uses `EDD_GET_DEVICE_INTERFACE_NAME` when available.

## 10.1.1 (2025-xx-xx)

### linux/__init__.py
Expand Down
2 changes: 2 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ ignore = [
"C90", # complexity
"COM812", # conflict
"D", # TODO
"FIX002", # Line contains TODO
"ISC001", # conflict
"T201", # `print()`
]
Expand Down
25 changes: 25 additions & 0 deletions src/mss/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,37 @@ def monitors(self) -> Monitors:
- ``top``: the y-coordinate of the upper-left corner
- ``width``: the width
- ``height``: the height
- ``is_primary``: (optional) true if this is the primary monitor
- ``name``: (optional) human-readable device name
- ``unique_id``: (optional) platform-specific stable identifier for the monitor
"""
with self._lock:
if not 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,
/,
Expand Down
5 changes: 4 additions & 1 deletion src/mss/models.py
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
88 changes: 79 additions & 9 deletions src/mss/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -242,19 +276,55 @@ 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
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 = int_(rct.left)
top = int_(rct.top)
# Check the dwFlags field for MONITORINFOF_PRIMARY
is_primary = bool(info.dwFlags & MONITORINFOF_PRIMARY)
display_device = DISPLAY_DEVICEW()
display_device.cb = ctypes.sizeof(DISPLAY_DEVICEW)

# EnumDisplayDevicesW can get friendly name (e.g. "Generic PnP Monitor")
device_string: str | None = None
if user32.EnumDisplayDevicesW(
ctypes.cast(ctypes.addressof(info.szDevice), POINTER(WORD)),
0,
ctypes.byref(display_device),
0,
):
device_string = ctypes.wstring_at(ctypes.addressof(display_device.DeviceString))

# Get device interface name (stable per-physical-monitor ID) when supported
unique_id: str | None = None
if user32.EnumDisplayDevicesW(
ctypes.cast(ctypes.addressof(info.szDevice), POINTER(WORD)),
0,
ctypes.byref(display_device),
EDD_GET_DEVICE_INTERFACE_NAME,
):
unique_id = ctypes.wstring_at(ctypes.addressof(display_device.DeviceID))

mon_dict: dict[str, Any] = {
"left": left,
"top": top,
"width": int_(rct.right) - left,
"height": int_(rct.bottom) - top,
"is_primary": is_primary,
}
if device_string is not None:
mon_dict["name"] = device_string
if unique_id is not None:
mon_dict["unique_id"] = unique_id
self._monitors.append(mon_dict)
return True

user32.EnumDisplayMonitors(0, None, callback, 0)
Expand Down
46 changes: 46 additions & 0 deletions src/tests/test_primary_monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""This is part of the MSS Python's module.
Source: https://github.com/BoboTiG/python-mss.
"""

import platform
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(platform.system() != "Windows", 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
1 change: 1 addition & 0 deletions src/tests/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down