Merge branch 'wayland-display-window-screenshot' into 'master'

RFC/Proposal: Fallback code for display screenshot code (used on '/main'  Web Remote)

Closes #1284 and #1405

See merge request openlp/openlp!582
This commit is contained in:
Tomas Groth 2023-06-06 20:05:55 +00:00
commit 4f7a2338e6
10 changed files with 413 additions and 36 deletions

View File

@ -75,10 +75,50 @@ def is_64bit_instance():
return (sys.maxsize > 2**32)
def is_xorg_server():
__IS_WAYLAND_COMPOSITOR = None
def is_wayland_compositor():
"""
Returns true if the Qt is running on X.org/XWayland display server (Linux/*nix)
Returns true if the OpenLP/Qt instance is running in a Wayland compositor.
NOTE: This checks is only about compositor. Returns True if the application is running in a Wayland compositor.
Will also return True if application is running on X11/X.org mode in a Wayland compositor/desktop environment
:return: True if the OpenLP/Qt instance is running in a Wayland compositor, otherwise False
"""
global __IS_WAYLAND_COMPOSITOR
if __IS_WAYLAND_COMPOSITOR is None:
__IS_WAYLAND_COMPOSITOR = bool(os.getenv('WAYLAND_DISPLAY', False))
return __IS_WAYLAND_COMPOSITOR
def is_xwayland_server():
"""
Returns true if the OpenLP/Qt instance is running in a XWayland X11 server.
NOTE: This will be True only if OpenLP is running on X11 mode on a XWayland server
:return: True if the OpenLP/Qt instance is running in a XWayland X11 server, otherwise False
"""
return is_wayland_compositor() and is_xorg_platform()
def is_xorg_platform():
"""
Returns True if the Qt is running on X.org/XWayland platform (Linux/*nix).
NOTE: This will return True if user is running the OpenLP as X.org application, but on a Wayland/XWayland
environment.
:return: True if the Qt is running on X.org/XWayland display server (Linux/*nix), otherwise False.
"""
from PyQt5 import QtGui
return QtGui.QGuiApplication.platformName() == 'xcb'
def is_wayland_platform():
"""
Returns true if the OpenLP/Qt instance is running using Qt's Wayland platform and running in a Wayland compositor
:return: True if the OpenLP/Qt instance is running in a Wayland compositor, otherwise False
"""
from PyQt5 import QtGui
return QtGui.QGuiApplication.platformName() == 'wayland'

View File

@ -112,6 +112,14 @@ class Registry(metaclass=Singleton):
if event in self.functions_list and function in self.functions_list[event]:
self.functions_list[event].remove(function)
def has_function(self, event):
"""
Returns whether there's any hander associated with the event.
:param event: The function to be checked
"""
return event in self.functions_list
def execute(self, event, *args, **kwargs):
"""
Execute all the handlers associated with the event and return an array of results.
@ -122,7 +130,7 @@ class Registry(metaclass=Singleton):
"""
log.debug(f'Running function {event}')
results = []
if event in self.functions_list:
if self.has_function(event):
for function in self.functions_list[event]:
try:
result = function(*args, **kwargs)

View File

@ -195,6 +195,7 @@ class Settings(QtCore.QSettings):
'advanced/single click preview': False,
'advanced/single click service preview': False,
'advanced/x11 bypass wm': X11_BYPASS_DEFAULT,
'advanced/prefer windowed screen capture': False,
'advanced/search as type': True,
'advanced/ui_theme_name': UiThemes.Automatic,
'advanced/delete service item confirmation': False,

View File

@ -40,7 +40,8 @@ class Screen(object):
A Python representation of a screen
"""
def __init__(self, number=None, geometry=None, custom_geometry=None, is_primary=False, is_display=False):
def __init__(self, number=None, geometry=None, custom_geometry=None, is_primary=False, is_display=False,
device_pixel_ratio=1.0, raw_screen=None):
"""
Set up the screen object
@ -53,12 +54,16 @@ class Screen(object):
so left = 0, top = 0 refers to the top left of that screen.
:param bool is_primary: Whether or not this screen is the primary screen
:param bool is_display: Whether or not this screen should be used to display lyrics
:param float device_pixel_ratio: Device pixel ratio of screen
:param raw_screen: Raw screen (Qt/QScreen) object
"""
self.number = int(number)
self.geometry = geometry
self.custom_geometry = custom_geometry
self.is_primary = is_primary
self.is_display = is_display
self.device_pixel_ratio = device_pixel_ratio
self.raw_screen = raw_screen
def __str__(self):
"""
@ -158,13 +163,29 @@ class Screen(object):
screen_dict['custom_geometry']['width'],
screen_dict['custom_geometry']['height'])
def on_geometry_changed(self, geometry):
def on_geometry_changed(self, geometry, device_pixel_ratio):
"""
Callback function for when the screens geometry changes
"""
self.geometry = geometry
# Device pixel ratio is implicity changed due to geometry change
self.device_pixel_ratio = device_pixel_ratio
ConfigScreenChangedEmitter().emit()
def try_grab_screen_part(self, x, y, width, height):
"""
Tries to grab a screenshot using the underlying display object
"""
try:
if self.raw_screen:
# windowId = 0 means to grab entire screen. See: https://doc.qt.io/qt-6/qscreen.html#grabWindow
return self.raw_screen.grabWindow(0, x, y, width, height)
else:
return None
except BaseException as e:
log.exception(e)
return None
class ScreenList(metaclass=Singleton):
"""
@ -199,7 +220,7 @@ class ScreenList(metaclass=Singleton):
return len(self.screens)
@property
def current(self):
def current(self) -> Screen:
"""
Return the first "current" desktop
@ -374,9 +395,12 @@ class ScreenList(metaclass=Singleton):
os_screens = self.application.screens()
os_screens.sort(key=cmp_to_key(_screen_compare))
for number, screen in enumerate(os_screens):
device_pixel_ratio = screen.devicePixelRatio()
self.screens.append(
Screen(number, screen.geometry(), is_primary=self.application.primaryScreen() == screen))
screen.geometryChanged.connect(self.screens[-1].on_geometry_changed)
Screen(number, screen.geometry(), is_primary=self.application.primaryScreen() == screen,
device_pixel_ratio=device_pixel_ratio, raw_screen=screen))
screen.geometryChanged.connect(lambda geometry: self.screens[-1]
.on_geometry_changed(geometry, screen.devicePixelRatio()))
def on_screen_added(self, changed_screen):
"""
@ -391,11 +415,13 @@ class ScreenList(metaclass=Singleton):
if is_primary:
for screen in self.screens:
screen.is_primary = False
device_pixel_ratio = changed_screen.devicePixelRatio()
self.screens.append(Screen(number, changed_screen.geometry(),
is_primary=self.application.primaryScreen() == changed_screen))
is_primary=self.application.primaryScreen() == changed_screen,
device_pixel_ratio=device_pixel_ratio, raw_screen=changed_screen))
self.find_new_display_screen()
changed_screen.geometryChanged.connect(self.screens[-1].on_geometry_changed)
changed_screen.geometryChanged.connect(lambda geometry: self.screens[-1]
.on_geometry_changed(geometry, changed_screen.devicePixelRatio()))
ConfigScreenChangedEmitter().emit()
def on_screen_removed(self, removed_screen):

View File

@ -571,3 +571,15 @@ class DisplayWindow(QtWidgets.QWidget, RegistryProperties, LogMixin):
Set an alert
"""
self.run_javascript('Display.alert("{text}", {settings});'.format(text=text, settings=settings))
@QtCore.pyqtSlot(result='QPixmap')
def _grab_screenshot_safe_signal(self):
return self.save_screenshot()
def grab_screenshot_safe(self):
# Using internal Qt's messaging/event system to invoke the function.
# Usually we would need to use PyQt's signals, but they aren't blocking. So we had to resort to this solution,
# which use a less-documented Qt mechanism to invoke the signal in a blocking way.
return QtCore.QMetaObject.invokeMethod(self, '_grab_screenshot_safe_signal',
QtCore.Qt.ConnectionType.BlockingQueuedConnection,
QtCore.Q_RETURN_ARG('QPixmap'))

View File

@ -100,6 +100,9 @@ class AdvancedTab(SettingsTab):
self.allow_transparent_display_check_box = QtWidgets.QCheckBox(self.display_workaround_group_box)
self.allow_transparent_display_check_box.setObjectName('allow_transparent_display_check_box')
self.display_workaround_layout.addWidget(self.allow_transparent_display_check_box)
self.prefer_windowed_capture_check_box = QtWidgets.QCheckBox(self.display_workaround_group_box)
self.prefer_windowed_capture_check_box.setObjectName('prefer_windowed_capture_check_box')
self.display_workaround_layout.addWidget(self.prefer_windowed_capture_check_box)
self.left_layout.addWidget(self.display_workaround_group_box)
# Proxies
self.proxy_widget = ProxyWidget(self.right_column)
@ -135,6 +138,8 @@ class AdvancedTab(SettingsTab):
self.alternate_rows_check_box.setText(translate('OpenLP.AdvancedTab', 'Use alternating row colours in lists'))
self.allow_transparent_display_check_box.setText(
translate('OpenLP.AdvancedTab', 'Disable display transparency'))
self.prefer_windowed_capture_check_box.setText(
translate('OpenLP.AdvancedTab', 'Prefer window capture instead of screen capture'))
self.proxy_widget.retranslate_ui()
def load(self):
@ -148,6 +153,8 @@ class AdvancedTab(SettingsTab):
self.alternate_rows_check_box.setChecked(self.settings.value('advanced/alternate rows'))
self.alternate_rows_check_box.blockSignals(False)
self.allow_transparent_display_check_box.setChecked(self.settings.value('advanced/disable transparent display'))
self.prefer_windowed_capture_check_box.setChecked(
self.settings.value('advanced/prefer windowed screen capture'))
self.data_directory_copy_check_box.hide()
self.new_data_directory_has_files_label.hide()
self.data_directory_cancel_button.hide()
@ -163,6 +170,8 @@ class AdvancedTab(SettingsTab):
"""
self.settings.setValue('advanced/disable transparent display',
self.allow_transparent_display_check_box.isChecked())
self.settings.setValue('advanced/prefer windowed screen capture',
self.prefer_windowed_capture_check_box.isChecked())
self.settings.setValue('advanced/ignore aspect ratio', self.ignore_aspect_ratio_check_box.isChecked())
if self.x11_bypass_check_box.isChecked() != self.settings.value('advanced/x11 bypass wm'):
self.settings.setValue('advanced/x11 bypass wm', self.x11_bypass_check_box.isChecked())

View File

@ -33,6 +33,7 @@ from openlp.core.common import SlideLimits
from openlp.core.common.actions import ActionList, CategoryOrder
from openlp.core.common.i18n import UiStrings, translate
from openlp.core.common.mixins import LogMixin, RegistryProperties
from openlp.core.common.platform import is_macosx, is_wayland_compositor
from openlp.core.common.registry import Registry, RegistryBase
from openlp.core.common.utils import wait_for
from openlp.core.display.screens import ScreenList
@ -1234,6 +1235,7 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
Gets an image of the display screen and updates the preview frame.
"""
display_image = self._capture_maindisplay()
# display_image.setDevicePixelRatio(self.preview_display.devicePixelRatio())
base64_image = image_to_byte(display_image)
self.screen_capture = base64_image
self.preview_display.set_single_image_data('#000', base64_image)
@ -1243,11 +1245,95 @@ class SlideController(QtWidgets.QWidget, LogMixin, RegistryProperties):
Creates an image of the current screen.
"""
self.log_debug('_capture_maindisplay {text}'.format(text=self.screens.current))
win_id = QtWidgets.QApplication.desktop().winId()
screen = QtWidgets.QApplication.primaryScreen()
rect = ScreenList().current.display_geometry
win_image = screen.grabWindow(win_id, rect.x(), rect.y(), rect.width(), rect.height())
win_image.setDevicePixelRatio(self.preview_display.devicePixelRatio())
# Wayland needs screenshot fallback even when OpenLP is running on X11/xcb mode.
fallback_to_windowed = is_wayland_compositor() or \
self.settings.value('advanced/prefer windowed screen capture')
if not fallback_to_windowed:
# Check if display screen is outside real screen bounds
# OpenLP can't take reliable screenshots when any of these conditions happens
# See last warning in grabWindow at https://doc.qt.io/qt-6/qscreen.html#grabWindow
display_rect = ScreenList().current.display_geometry
screen_rect = ScreenList().current.geometry
display_above_horizontal = display_rect.left() < screen_rect.left()
display_above_vertical = display_rect.top() < screen_rect.top()
display_beyond_horizontal = (display_rect.left() + display_rect.width() >
screen_rect.left() + screen_rect.width())
display_beyond_vertical = (display_rect.top() + display_rect.height() >
screen_rect.top() + screen_rect.height())
fallback_to_windowed = display_above_horizontal or display_above_vertical \
or display_beyond_horizontal or display_beyond_vertical
if fallback_to_windowed:
if self.service_item.is_capable(ItemCapabilities.ProvidesOwnDisplay) or self.service_item.is_media() or \
self.service_item.is_command():
if self.service_item.is_command():
# Attempting to get screenshot from command handler
service_item_name = self.service_item.name.lower()
event_name = '{text}_attempt_screenshot'.format(text=service_item_name)
if Registry().has_function(event_name):
results = Registry().execute(event_name, [self.service_item, self.selected_row])
if len(results):
succedded, binary_data = results[0]
if succedded and binary_data:
self.log_debug('_capture_maindisplay using window capture from {name}'
.format(name=service_item_name))
return binary_data
# Falling back to desktop capture, maybe it works
self.log_debug('_capture_maindisplay cannot do window capture, trying to get screenshot from screen'
' anyway')
return self._capture_maindisplay_desktop()
else:
self.log_debug('_capture_maindisplay falling back to window capture')
return self._capture_maindisplay_window()
else:
return self._capture_maindisplay_desktop()
def _capture_maindisplay_desktop(self):
# At least on macOS, there's a crash when opening /main Remote API endpoint,
# due to macOS requiring the screenshot code to be called on the main thread.
if is_macosx():
self.log_debug('_capture_maindisplay_desktop macos thread-safe call')
return self._capture_maindisplay_desktop_mainthread_safe()
else:
self.log_debug('_capture_maindisplay_desktop default call')
return self._capture_maindisplay_desktop_signal()
@QtCore.pyqtSlot(result='QPixmap')
def _capture_maindisplay_desktop_signal(self):
current_screen = ScreenList().current
display_rect = current_screen.display_geometry
screen_rect = current_screen.geometry
# As we capture using current screen object, we need relative coordinates.
# (OpenLP is storing screen positions like a big one-screen, and we can't change this without
# disrupting existing users)
relative_x = display_rect.x() - screen_rect.x()
relative_y = display_rect.y() - screen_rect.y()
width = display_rect.width()
height = display_rect.height()
win_image = current_screen.try_grab_screen_part(relative_x, relative_y, width, height)
return win_image
def _capture_maindisplay_desktop_mainthread_safe(self):
# Using internal Qt's messaging/event system to invoke the function.
# Usually we would need to use PyQt's signals, but they aren't blocking. So we had to resort to this solution,
# which use a less-documented Qt mechanism to invoke the signal in a blocking way.
return QtCore.QMetaObject.invokeMethod(self, '_capture_maindisplay_desktop_signal',
QtCore.Qt.ConnectionType.BlockingQueuedConnection,
QtCore.Q_RETURN_ARG('QPixmap'))
def _capture_maindisplay_window(self):
win_image = None
for display in self.displays:
if display.is_display:
if display.hide_mode == HideMode.Screen or display.hide_mode == HideMode.Blank:
# Sending a black image to avoid artifacts
size = display.size()
win_image = QtGui.QPixmap(size)
win_image.fill(QtGui.QColorConstants.Black)
else:
win_image = display.grab_screenshot_safe()
if win_image:
win_image.setDevicePixelRatio(self.preview_display.devicePixelRatio())
break
return win_image
def is_slide_loaded(self):

View File

@ -279,6 +279,16 @@ class Controller(object):
return
self.doc.poll_slidenumber(self.is_live, self.hide_mode)
def attempt_screenshot(self, index):
"""
Tries to perform a live screenshot when visible service item uses ProvidesOwnDisplay flag.
:returns: A tuple: whether the request succedded, and then the image.
"""
if self.is_live:
return self.doc.attempt_screenshot(index)
return (False, None)
class MessageListener(object):
"""
@ -310,6 +320,7 @@ class MessageListener(object):
Registry().register_function('presentations_slide', self.slide)
Registry().register_function('presentations_blank', self.blank)
Registry().register_function('presentations_unblank', self.unblank)
Registry().register_function('presentations_attempt_screenshot', self.attempt_live_screenshot)
self.timer = QtCore.QTimer()
self.timer.setInterval(500)
self.timer.timeout.connect(self.timeout)
@ -487,3 +498,13 @@ class MessageListener(object):
Poll occasionally to check which slide is currently displayed so the slidecontroller view can be updated.
"""
self.live_handler.poll()
def attempt_live_screenshot(self, message):
"""
Tries to perform a live screenshot when visible service item uses ProvidesOwnDisplay flag.
:returns: A tuple: whether the request succedded, and then the image.
"""
current_row = message[1]
result = self.live_handler.attempt_screenshot(current_row)
return result

View File

@ -86,6 +86,10 @@ class PresentationDocument(object):
``get_thumbnail_path(slide_no, check_exists)``
Returns a path to an image containing a preview for the requested slide
``attempt_screenshot()``
Attemps to take a screenshot from the presentation window. Returns a tuple with whether it succedded and
the result image.
"""
def __init__(self, controller, document_path):
"""
@ -386,6 +390,9 @@ class PresentationDocument(object):
self._sha256_file_hash = sha256_file_hash(self.file_path)
return self._sha256_file_hash
def attempt_screenshot(self, index):
return (False, None)
class PresentationList(metaclass=Singleton):
"""

View File

@ -21,16 +21,19 @@
"""
Package to test the openlp.core.ui.slidecontroller package.
"""
from collections import namedtuple
import datetime
from unittest.mock import MagicMock, patch, sentinel
from PyQt5 import QtCore, QtGui
from openlp.core.lib.serviceitem import ServiceItem
from pytest import mark
from openlp.core.state import State
from openlp.core.common.enum import ServiceItemType
from openlp.core.common.registry import Registry
from openlp.core.lib import ItemCapabilities, ServiceItemAction
from openlp.core.lib.serviceitem import ServiceItem
from openlp.core.ui import HideMode
from openlp.core.ui.slidecontroller import NON_TEXT_MENU, WIDE_MENU, NARROW_MENU, InfoLabel, LiveController, \
PreviewController, SlideController
@ -1432,36 +1435,200 @@ def test_display_maindisplay(mocked_image_to_byte, registry):
slide_controller.preview_display.set_single_image_data.assert_called_once_with('#000', 'placeholder bytified')
CaptureMainDisplayMockReturn = namedtuple('CaptureMainDisplayMockReturn', ['slide_controller', 'mocked_primary_screen',
'windowed_screenshot_mock',
'mocked_screenlist_instance'])
def _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application, mocked_is_wayland_compositor):
mocked_is_wayland_compositor.return_value = False
slide_controller = SlideController(None)
windowed_screenshot_mock = QtGui.QPixmap(64, 33)
display_mock = MagicMock(grab_screenshot_safe=MagicMock(return_value=windowed_screenshot_mock), is_display=True)
slide_controller.displays = [display_mock]
slide_controller.service_item = ServiceItem(None)
mocked_geometry = MagicMock(
x=MagicMock(return_value=geometry[1][0]),
y=MagicMock(return_value=geometry[1][1]),
left=MagicMock(return_value=geometry[1][0]),
top=MagicMock(return_value=geometry[1][1]),
width=MagicMock(return_value=geometry[1][2]),
height=MagicMock(return_value=geometry[1][3])
)
mocked_display_geometry = MagicMock(
x=MagicMock(return_value=geometry[0][0]),
y=MagicMock(return_value=geometry[0][1]),
left=MagicMock(return_value=geometry[0][0]),
top=MagicMock(return_value=geometry[0][1]),
width=MagicMock(return_value=geometry[0][2]),
height=MagicMock(return_value=geometry[0][3])
)
mocked_screenlist_instance = MagicMock()
mocked_screenlist.return_value = mocked_screenlist_instance
mocked_screenlist_instance.current = MagicMock(display_geometry=mocked_display_geometry, geometry=mocked_geometry)
mocked_primary_screen = MagicMock()
mocked_application.primaryScreen = MagicMock(return_value=mocked_primary_screen)
slide_controller.preview_display = MagicMock()
return CaptureMainDisplayMockReturn(mocked_primary_screen=mocked_primary_screen, slide_controller=slide_controller,
windowed_screenshot_mock=windowed_screenshot_mock,
mocked_screenlist_instance=mocked_screenlist_instance)
@patch(u'openlp.core.ui.slidecontroller.image_to_byte')
@patch(u'openlp.core.ui.slidecontroller.ScreenList')
@patch(u'openlp.core.ui.slidecontroller.QtWidgets.QApplication')
def test__capture_maindisplay(mocked_application, mocked_screenlist, mocked_image_to_byte, registry):
@patch(u'openlp.core.ui.slidecontroller.is_wayland_compositor')
@mark.parametrize('geometry', [[[34, 67, 77, 42], [0, 0, 800, 600]]])
def test__capture_maindisplay(mocked_is_wayland_compositor, mocked_application, mocked_screenlist,
mocked_image_to_byte, geometry, registry, settings):
"""
Test the _capture_maindisplay method
"""
# GIVEN: A mocked slide controller, with mocked functions
slide_controller = SlideController(None)
mocked_display_geometry = MagicMock(
x=MagicMock(return_value=34),
y=MagicMock(return_value=67),
width=MagicMock(return_value=77),
height=MagicMock(return_value=42)
)
mocked_screenlist_instance = MagicMock()
mocked_screenlist.return_value = mocked_screenlist_instance
mocked_screenlist_instance.current = MagicMock(display_geometry=mocked_display_geometry)
mocked_primary_screen = MagicMock()
mocked_application.primaryScreen = MagicMock(return_value=mocked_primary_screen)
mocked_application.desktop = MagicMock(return_value=MagicMock(
winId=MagicMock(return_value=23)
))
slide_controller.preview_display = MagicMock()
mocks = _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application,
mocked_is_wayland_compositor)
# WHEN: _capture_maindisplay is called
slide_controller._capture_maindisplay()
mocks.slide_controller._capture_maindisplay()
# THEN: Screenshot should have been taken with correct winId and dimensions
mocked_primary_screen.grabWindow.assert_called_once_with(23, 34, 67, 77, 42)
mocks.mocked_screenlist_instance.current.try_grab_screen_part.assert_called_once()
@patch(u'openlp.core.ui.slidecontroller.image_to_byte')
@patch(u'openlp.core.ui.slidecontroller.ScreenList')
@patch(u'openlp.core.ui.slidecontroller.QtWidgets.QApplication')
@patch(u'openlp.core.ui.slidecontroller.is_wayland_compositor')
@mark.parametrize('geometry', [[[34, 67, 77, 42], [0, 0, 800, 600]]])
def test__capture_maindisplay_wayland_fallbacks_to_windowed(mocked_is_wayland_compositor, mocked_application,
mocked_screenlist, mocked_image_to_byte, registry,
geometry, settings):
"""
Test the _capture_maindisplay method fallbacks to windowed capture mode if user is running on Wayland compositor
"""
# GIVEN: A mocked slide controller, with mocked functions
mocks = _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application,
mocked_is_wayland_compositor)
mocked_is_wayland_compositor.return_value = True
# WHEN: _capture_maindisplay is called
photo = mocks.slide_controller._capture_maindisplay()
# THEN: Screenshot should have been taken from DisplayWindow and Screen should not be touched
assert photo == mocks.windowed_screenshot_mock
mocks.mocked_primary_screen.grabWindow.assert_not_called()
@patch(u'openlp.core.ui.slidecontroller.image_to_byte')
@patch(u'openlp.core.ui.slidecontroller.ScreenList')
@patch(u'openlp.core.ui.slidecontroller.QtWidgets.QApplication')
@patch(u'openlp.core.ui.slidecontroller.is_wayland_compositor')
@mark.parametrize('geometry', [[[400, 400, 800, 600], [0, 0, 800, 600]], [[510, 0, 800, 600], [0, 0, 800, 600]],
[[0, 320, 800, 600], [0, 0, 800, 600]], [[-200, -100, 800, 600], [0, 0, 800, 600]],
[[-120, 0, 800, 600], [0, 0, 800, 600]], [[0, -140, 800, 600], [0, 0, 800, 600]],
[[-150, 0, 800, 600], [-152, 0, 800, 600]], [[0, -210, 800, 600], [0, -200, 800, 600]],
[[200, 0, 800, 600], [120, 0, 800, 600]], [[0, 230, 800, 600], [0, 110, 800, 600]]])
def test__capture_maindisplay_offscreen_fallbacks_to_windowed(mocked_is_wayland_compositor, mocked_application,
mocked_screenlist, mocked_image_to_byte, geometry,
registry, settings):
"""
Test the _capture_maindisplay method fallbacks to windowed capture mode if user have a display
above/beyond screen boundaries.
"""
# GIVEN: A mocked slide controller, with mocked functions and offscreen geometry
mocks = _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application,
mocked_is_wayland_compositor)
mocked_is_wayland_compositor.return_value = False
# WHEN: _capture_maindisplay is called
photo = mocks.slide_controller._capture_maindisplay()
# THEN: Screenshot should have been taken from DisplayWindow and Screen should not be touched
assert photo == mocks.windowed_screenshot_mock
mocks.mocked_primary_screen.grabWindow.assert_not_called()
@patch(u'openlp.core.ui.slidecontroller.ScreenList')
@patch(u'openlp.core.ui.slidecontroller.QtWidgets.QApplication')
@patch(u'openlp.core.ui.slidecontroller.is_wayland_compositor')
@mark.parametrize('geometry', [[[34, 67, 77, 42], [0, 0, 800, 600]]])
def test__capture_maindisplay_offscreen_command_screenshot(mocked_is_wayland_compositor, mocked_application,
mocked_screenlist, geometry, registry, settings):
"""
Test the _capture_maindisplay method invoke '{text}_attempt_screenshot' event on command-based service items.
"""
# GIVEN: A mocked slide controller, with mocked functions and offscreen geometry
mocks = _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application,
mocked_is_wayland_compositor)
mocked_is_wayland_compositor.return_value = True
mocks.slide_controller.service_item.capabilities = [ItemCapabilities.ProvidesOwnDisplay]
mocks.slide_controller.service_item.name = 'screenshottable'
mocks.slide_controller.service_item.service_item_type = ServiceItemType.Command
mocks.slide_controller.selected_row = 0
pixmap = QtGui.QPixmap()
def attempt_screenshot(params):
nonlocal pixmap
return True, pixmap
registry.register_function('screenshottable_attempt_screenshot', attempt_screenshot)
# WHEN: _capture_maindisplay is called
photo = mocks.slide_controller._capture_maindisplay()
# THEN: Screenshot should have been taken from DisplayWindow and Screen should not be touched
assert photo == pixmap
@patch(u'openlp.core.ui.slidecontroller.ScreenList')
@patch(u'openlp.core.ui.slidecontroller.QtWidgets.QApplication')
@patch(u'openlp.core.ui.slidecontroller.is_wayland_compositor')
@mark.parametrize('geometry', [[[-800, -600, 800, 600], [0, 0, 800, 600]]])
@mark.parametrize('hide_mode', [HideMode.Screen, HideMode.Blank])
def test__capture_maindisplay_window_fakes_black_screen(mocked_is_wayland_compositor, mocked_application,
mocked_screenlist, geometry, hide_mode, registry, settings):
"""
Test the _capture_maindisplay_window method fakes a black screen if display mode is Screen or Blank.
"""
# GIVEN: A mocked slide controller, with mocked functions and offscreen geometry
mocks = _init__capture_maindisplay_mocks(geometry, mocked_screenlist, mocked_application,
mocked_is_wayland_compositor)
screen_size = QtCore.QSize(geometry[1][2], geometry[1][3])
mocked_display = MagicMock()
mocked_display.is_display = True
mocked_display.hide_mode = hide_mode
mocked_display.size = MagicMock(return_value=screen_size)
mocks.slide_controller.displays = [mocked_display]
# WHEN: _capture_maindisplay is called
pixmap = mocks.slide_controller._capture_maindisplay()
# THEN: A fake black screen should be returned
photo_size = pixmap.size()
image = pixmap.toImage()
assert photo_size.width() == screen_size.width()
assert photo_size.height() == screen_size.height()
assert image.pixelColor(int(geometry[1][2] / 2), int(geometry[1][3] / 2)).isValid()
assert image.pixelColor(int(geometry[1][2] / 2), int(geometry[1][3] / 2)) == QtGui.QColorConstants.Black
@patch('openlp.core.ui.slidecontroller.is_macosx')
def test__capture_maindisplay_desktop_calls_safe_on_macos(mocked_is_macosx, registry, settings):
"""
Test the _capture_maindisplay_desktop method fallbacks to calling thread-safe code on macOS
(avoids a hard crash due to Cocoa/macOS internal details)
"""
# GIVEN: A mocked system check (running macOS) and mocked maindisplay call
mocked_is_macosx.return_value = True
slide_controller = SlideController()
# slidecontroller._capture_maindisplay_desktop_signal = MagicMock(return_value=QtGui.QPixmap())
slide_controller._capture_maindisplay_desktop_mainthread_safe = MagicMock()
# WHEN: trying to grab desktop screenshot
slide_controller._capture_maindisplay_desktop()
# THEN: Screenshot should have been taken through thread-safe call
slide_controller._capture_maindisplay_desktop_mainthread_safe.assert_called_once()
@patch(u'openlp.core.ui.slidecontroller.image_to_byte')