Skip to content

Commit 439fb11

Browse files
committed
Add more detail about _monitors on Linux
This also involves adding InternAtom support, which will likely be used for other tasks in the future.
1 parent 781b5b6 commit 439fb11

7 files changed

Lines changed: 691 additions & 48 deletions

File tree

src/mss/linux/base.py

Lines changed: 175 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from __future__ import annotations
22

33
from typing import TYPE_CHECKING, Any
4+
from urllib.parse import urlencode
45

56
from mss.base import MSSBase
67
from mss.exception import ScreenShotError
8+
from mss.tools import parse_edid
79

810
from . import xcb
911
from .xcb import LIB
1012

1113
if TYPE_CHECKING:
14+
from ctypes import Array
15+
1216
from mss.models import Monitor
1317
from mss.screenshot import ScreenShot
1418

@@ -140,8 +144,37 @@ def _monitors_impl(self) -> None:
140144
msg = "Cannot identify monitors while the connection is closed"
141145
raise ScreenShotError(msg)
142146

143-
# The first entry is the whole X11 screen that the root is on. That's the one that covers all the
144-
# monitors.
147+
self._append_root_monitor()
148+
149+
randr_version = self._randr_get_version()
150+
if randr_version is None or randr_version < (1, 2):
151+
return
152+
153+
# XRandR terminology (very abridged, but enough for this code):
154+
# - X screen / framebuffer: the overall drawable area for this root.
155+
# - CRTC: a display controller that scans out a rectangular region of the X screen. A CRTC with zero
156+
# outputs is inactive. A CRTC may drive multiple outputs in clone/mirroring mode.
157+
# - Output: a physical connector (e.g. "HDMI-1", "DP-1"). The RandR "connection" state (connected vs
158+
# disconnected) is separate from whether the output is currently driven by a CRTC.
159+
# - Monitor (RandR 1.5+): a logical rectangle presented to clients. Monitors may be client-defined (useful
160+
# for tiled displays) and are the closest match to what MSS wants.
161+
#
162+
# This implementation prefers RandR 1.5+ Monitors when available; otherwise it falls back to enumerating
163+
# active CRTCs.
164+
165+
primary_output = self._randr_get_primary_output(randr_version)
166+
edid_atom = self._randr_get_edid_atom()
167+
168+
if randr_version >= (1, 5):
169+
self._monitors_from_randr_monitors(primary_output, edid_atom)
170+
else:
171+
self._monitors_from_randr_crtcs(randr_version, primary_output, edid_atom)
172+
173+
def _append_root_monitor(self) -> None:
174+
if self.conn is None:
175+
msg = "Cannot identify monitors while the connection is closed"
176+
raise ScreenShotError(msg)
177+
145178
root_geom = xcb.get_geometry(self.conn, self.root)
146179
self._monitors.append(
147180
{
@@ -152,47 +185,164 @@ def _monitors_impl(self) -> None:
152185
}
153186
)
154187

155-
# After that, we have one for each monitor on that X11 screen. For decades, that's been handled by
156-
# Xrandr. We don't presently try to work with Xinerama. So, we're going to check the different outputs,
157-
# according to Xrandr. If that fails, we'll just leave the one root covering everything.
188+
def _randr_get_version(self) -> tuple[int, int] | None:
189+
if self.conn is None:
190+
msg = "Cannot identify monitors while the connection is closed"
191+
raise ScreenShotError(msg)
158192

159-
# Make sure we have the Xrandr extension we need. This will query the cache that we started populating in
160-
# __init__.
161193
randr_ext_data = xcb.get_extension_data(self.conn, LIB.randr_id)
162194
if not randr_ext_data.present:
163-
return
195+
return None
164196

165-
# We ask the server to give us anything up to the version we support (i.e., what we expect the reply
166-
# structs to look like). If the server only supports 1.2, then that's what it'll give us, and we're ok
167-
# with that, but we also use a faster path if the server implements at least 1.3.
168197
randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION)
169-
randr_version = (randr_version_data.major_version, randr_version_data.minor_version)
170-
if randr_version < (1, 2):
171-
return
198+
return (randr_version_data.major_version, randr_version_data.minor_version)
199+
200+
def _randr_get_primary_output(self, randr_version: tuple[int, int], /) -> xcb.RandrOutput:
201+
if self.conn is None:
202+
msg = "Cannot identify monitors while the connection is closed"
203+
raise ScreenShotError(msg)
204+
205+
if randr_version >= (1, 3):
206+
primary_output_data = xcb.randr_get_output_primary(self.conn, self.drawable)
207+
return primary_output_data.output
208+
return xcb.RandrOutput(0)
209+
210+
def _randr_get_edid_atom(self) -> xcb.Atom | None:
211+
if self.conn is None:
212+
msg = "Cannot identify monitors while the connection is closed"
213+
raise ScreenShotError(msg)
214+
215+
edid_atom = xcb.intern_atom(self.conn, "EDID", only_if_exists=True)
216+
if edid_atom is not None:
217+
return edid_atom
218+
219+
# Formerly, "EDID" was known as "EdidData". I don't know when it changed.
220+
return xcb.intern_atom(self.conn, "EdidData", only_if_exists=True)
221+
222+
def _randr_output_ids(
223+
self,
224+
output: xcb.RandrOutput,
225+
timestamp: xcb.Timestamp,
226+
edid_atom: xcb.Atom | None,
227+
/,
228+
) -> dict[str, Any]:
229+
if self.conn is None:
230+
msg = "Cannot identify monitors while the connection is closed"
231+
raise ScreenShotError(msg)
232+
233+
output_info = xcb.randr_get_output_info(self.conn, output, timestamp)
234+
if output_info.status != 0:
235+
msg = "Display configuration changed while detecting monitors."
236+
raise ScreenShotError(msg)
237+
238+
rv: dict[str, Any] = {}
239+
240+
output_name_arr = xcb.randr_get_output_info_name(output_info)
241+
rv["output"] = bytes(output_name_arr).decode("utf_8", errors="replace")
242+
243+
if edid_atom is not None:
244+
edid_prop = xcb.randr_get_output_property(
245+
self.conn, # connection
246+
output, # output
247+
edid_atom, # property
248+
xcb.XCB_NONE, # property type: Any
249+
0, # long-offset: 0
250+
1024, # long-length: in 4-byte units; 4k is plenty for an EDID
251+
0, # delete: false
252+
0, # pending: false
253+
)
254+
if edid_prop.type_.value != 0:
255+
edid_block = bytes(xcb.randr_get_output_property_data(edid_prop))
256+
edid_data = parse_edid(edid_block)
257+
if "display_name" in edid_data:
258+
rv["name"] = edid_data["display_name"]
259+
260+
edid_params: dict[str, str] = {}
261+
if "id_legacy" in edid_data:
262+
edid_params["model"] = edid_data["id_legacy"]
263+
if "serial_number" in edid_data:
264+
edid_params["serial"] = str(edid_data["serial_number"])
265+
if "manufacture_year" in edid_data:
266+
if "manufacture_week" in edid_data:
267+
edid_params["mfr_date"] = (
268+
f"{edid_data['manufacture_year']:04d}W{edid_data['manufacture_week']:02d}"
269+
)
270+
else:
271+
edid_params["mfr_date"] = f"{edid_data['manufacture_year']:04d}"
272+
if "model_year" in edid_data:
273+
edid_params["model_year"] = f"{edid_data['model_year']:04d}"
274+
rv["unique_id"] = urlencode(edid_params)
275+
276+
return rv
277+
278+
@staticmethod
279+
def _choose_randr_output(outputs: Array[xcb.RandrOutput], primary_output: xcb.RandrOutput, /) -> xcb.RandrOutput:
280+
if any(o.value == primary_output.value for o in outputs):
281+
return primary_output
282+
return outputs[0]
283+
284+
def _monitors_from_randr_monitors(self, primary_output: xcb.RandrOutput, edid_atom: xcb.Atom | None, /) -> None:
285+
if self.conn is None:
286+
msg = "Cannot identify monitors while the connection is closed"
287+
raise ScreenShotError(msg)
288+
289+
monitors_reply = xcb.randr_get_monitors(self.conn, self.drawable.value, 1)
290+
timestamp = monitors_reply.timestamp
291+
for randr_monitor in xcb.randr_get_monitors_monitors(monitors_reply):
292+
monitor = {
293+
"left": randr_monitor.x,
294+
"top": randr_monitor.y,
295+
"width": randr_monitor.width,
296+
"height": randr_monitor.height,
297+
}
298+
if randr_monitor.primary:
299+
monitor["is_primary"] = True
300+
301+
if randr_monitor.nOutput > 0:
302+
outputs = xcb.randr_monitor_info_outputs(randr_monitor)
303+
chosen_output = self._choose_randr_output(outputs, primary_output)
304+
monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom)
305+
306+
self._monitors.append(monitor)
307+
308+
def _monitors_from_randr_crtcs(
309+
self,
310+
randr_version: tuple[int, int],
311+
primary_output: xcb.RandrOutput,
312+
edid_atom: xcb.Atom | None,
313+
/,
314+
) -> None:
315+
if self.conn is None:
316+
msg = "Cannot identify monitors while the connection is closed"
317+
raise ScreenShotError(msg)
172318

173319
screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply
174-
# Check to see if we have the xcb_randr_get_screen_resources_current function in libxcb-randr, and that
175-
# the server supports it.
176320
if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3):
177321
screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable.value)
178322
crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources)
179323
else:
180-
# Either the client or the server doesn't support the _current form. That's ok; we'll use the old
181-
# function, which forces a new query to the physical monitors.
182324
screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable)
183325
crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources)
326+
timestamp = screen_resources.config_timestamp
184327

185328
for crtc in crtcs:
186-
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp)
329+
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, timestamp)
187330
if crtc_info.num_outputs == 0:
188331
continue
189-
self._monitors.append(
190-
{"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height}
191-
)
332+
monitor = {
333+
"left": crtc_info.x,
334+
"top": crtc_info.y,
335+
"width": crtc_info.width,
336+
"height": crtc_info.height,
337+
}
338+
339+
outputs = xcb.randr_get_crtc_info_outputs(crtc_info)
340+
chosen_output = self._choose_randr_output(outputs, primary_output)
341+
monitor |= self._randr_output_ids(chosen_output, timestamp, edid_atom)
342+
if chosen_output.value == primary_output.value:
343+
monitor["is_primary"] = True
192344

193-
# Extra credit would be to enumerate the virtual desktops; see
194-
# https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that
195-
# style is.
345+
self._monitors.append(monitor)
196346

197347
def _cursor_impl_check_xfixes(self) -> bool:
198348
"""Check XFixes availability and version.

0 commit comments

Comments
 (0)