diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 1ee8663341..c4cfda2fad 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -2,6 +2,7 @@ import sys import warnings +from android.content import Context from android.graphics.drawable import BitmapDrawable from android.media import RingtoneManager from android.view import Menu, MenuItem @@ -11,6 +12,7 @@ from toga.command import Command, Group, Separator from .libs import events +from .screens import Screen as ScreenImpl from .window import Window @@ -325,3 +327,9 @@ def hide_cursor(self): def show_cursor(self): pass + + def get_screens(self): + context = self.native.getApplicationContext() + display_manager = context.getSystemService(Context.DISPLAY_SERVICE) + screen_list = display_manager.getDisplays() + return [ScreenImpl(self, screen) for screen in screen_list] diff --git a/android/src/toga_android/screens.py b/android/src/toga_android/screens.py new file mode 100644 index 0000000000..a4d3dead0c --- /dev/null +++ b/android/src/toga_android/screens.py @@ -0,0 +1,48 @@ +from android.graphics import ( + Bitmap, + Canvas as A_Canvas, +) + +from toga.screens import Screen as ScreenInterface + +from .widgets.base import Scalable + + +class Screen(Scalable): + _instances = {} + + def __new__(cls, app, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + cls.app = app + instance.init_scale(instance.app.native) + return instance + + def get_name(self): + return self.native.getName() + + def get_origin(self): + return (0, 0) + + def get_size(self): + return ( + self.scale_out(self.native.getWidth()), + self.scale_out(self.native.getHeight()), + ) + + def get_image_data(self): + # Get the root view of the current activity + root_view = self.app.native.getWindow().getDecorView().getRootView() + bitmap = Bitmap.createBitmap( + *map(self.scale_in, self.get_size()), + Bitmap.Config.ARGB_8888, + ) + canvas = A_Canvas(bitmap) + root_view.draw(canvas) + + return bitmap diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index ffd523a7d1..5e8f1ffe1b 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -1,6 +1,7 @@ from decimal import ROUND_UP from android import R +from android.content import Context from android.graphics import ( Bitmap, Canvas as A_Canvas, @@ -10,6 +11,7 @@ from java.io import ByteArrayOutputStream from .container import Container +from .screens import Screen as ScreenImpl class LayoutListener(dynamic_proxy(ViewTreeObserver.OnGlobalLayoutListener)): @@ -103,6 +105,11 @@ def close(self): def set_full_screen(self, is_full_screen): self.interface.factory.not_implemented("Window.set_full_screen()") + def get_current_screen(self): + context = self.app.native.getApplicationContext() + window_manager = context.getSystemService(Context.WINDOW_SERVICE) + return ScreenImpl(self.app, window_manager.getDefaultDisplay()) + def get_image_data(self): bitmap = Bitmap.createBitmap( self.native_content.getWidth(), diff --git a/android/tests_backend/probe.py b/android/tests_backend/probe.py index d21380ee43..b219b47b95 100644 --- a/android/tests_backend/probe.py +++ b/android/tests_backend/probe.py @@ -94,7 +94,7 @@ async def redraw(self, message=None, delay=0): print("Waiting for redraw" if message is None else message) await asyncio.sleep(delay) - def assert_image_size(self, image_size, size): + def assert_image_size(self, image_size, size, screen): # Sizes are approximate because of scaling inconsistencies. assert image_size == ( approx(size[0] * self.scale_factor, abs=2), diff --git a/android/tests_backend/screens.py b/android/tests_backend/screens.py new file mode 100644 index 0000000000..ebae1b2006 --- /dev/null +++ b/android/tests_backend/screens.py @@ -0,0 +1,21 @@ +from android.view import Display + +import toga +from toga.images import Image as TogaImage +from toga_android.widgets.base import Scalable + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe, Scalable): + def __init__(self, screen): + app = toga.App.app + super().__init__(app) + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + self.init_scale(app._impl.native) + assert isinstance(self.native, Display) + + def get_screenshot(self, format=TogaImage): + return self.screen.as_image(format=format) diff --git a/changes/1930.feature.rst b/changes/1930.feature.rst new file mode 100644 index 0000000000..f3a7076122 --- /dev/null +++ b/changes/1930.feature.rst @@ -0,0 +1 @@ +Toga apps can now access details about the screens attached to the computer. Window position APIs have been extended to allow for placement on a specific screen, and positioning relative to a specific screen. diff --git a/changes/1930.removal.rst b/changes/1930.removal.rst new file mode 100644 index 0000000000..60539edbe9 --- /dev/null +++ b/changes/1930.removal.rst @@ -0,0 +1 @@ +The macOS implementations of ``Window.as_image()`` and ``Canvas.as_image()`` APIs now return images in native device resolution, not CSS pixel resolution. This will result in images that are double the previous size on Retina displays. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index b072464c95..1811f5f702 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -37,6 +37,7 @@ objc_method, objc_property, ) +from .screens import Screen as ScreenImpl from .window import Window @@ -402,6 +403,9 @@ def _submenu(self, group, menubar): def main_loop(self): self.loop.run_forever(lifecycle=CocoaLifecycle(self.native)) + def get_screens(self): + return [ScreenImpl(native=screen) for screen in NSScreen.screens] + def set_main_window(self, window): pass diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index d7722cea4f..ca75f37a11 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -202,6 +202,16 @@ class CGEventRef(c_void_p): ###################################################################### # CGImage.h + +CGImageRef = c_void_p +register_preferred_encoding(b"^{CGImage=}", CGImageRef) + +core_graphics.CGImageGetWidth.argtypes = [CGImageRef] +core_graphics.CGImageGetWidth.restype = c_size_t + +core_graphics.CGImageGetHeight.argtypes = [CGImageRef] +core_graphics.CGImageGetHeight.restype = c_size_t + kCGImageAlphaNone = 0 kCGImageAlphaPremultipliedLast = 1 kCGImageAlphaPremultipliedFirst = 2 @@ -220,3 +230,16 @@ class CGEventRef(c_void_p): kCGBitmapByteOrder32Little = 2 << 12 kCGBitmapByteOrder16Big = 3 << 12 kCGBitmapByteOrder32Big = 4 << 12 + +###################################################################### +# CGDirectDisplay.h + +CGDirectDisplayID = c_uint32 + +# CGDirectDisplayID CGMainDisplayID(void); +core_graphics.CGMainDisplayID.restype = CGDirectDisplayID +core_graphics.CGMainDisplayID.argtypes = None + +# CGImageRef CGDisplayCreateImage(CGDirectDisplayID displayID, CGRect rect); +core_graphics.CGDisplayCreateImage.restype = CGImageRef +core_graphics.CGDisplayCreateImage.argtypes = [CGDirectDisplayID, CGRect] diff --git a/cocoa/src/toga_cocoa/screens.py b/cocoa/src/toga_cocoa/screens.py new file mode 100644 index 0000000000..f58c1954e4 --- /dev/null +++ b/cocoa/src/toga_cocoa/screens.py @@ -0,0 +1,53 @@ +from rubicon.objc import CGSize + +from toga.screens import Screen as ScreenInterface +from toga_cocoa.libs import ( + NSImage, + core_graphics, +) + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + return str(self.native.localizedName) + + def get_origin(self): + frame_native = self.native.frame + return (int(frame_native.origin.x), int(frame_native.origin.y)) + + def get_size(self): + frame_native = self.native.frame + return (int(frame_native.size.width), int(frame_native.size.height)) + + def get_image_data(self): + # Retrieve the device description dictionary for the NSScreen + device_description = self.native.deviceDescription + # Extract the CGDirectDisplayID from the device description + cg_direct_display_id = device_description.objectForKey_( + "NSScreenNumber" + ).unsignedIntValue + + cg_image = core_graphics.CGDisplayCreateImage( + cg_direct_display_id, + self.native.frame, + ) + # Get the size of the CGImage + target_size = CGSize( + core_graphics.CGImageGetWidth(cg_image), + core_graphics.CGImageGetHeight(cg_image), + ) + # Create an NSImage from the CGImage + ns_image = NSImage.alloc().initWithCGImage(cg_image, size=target_size) + return ns_image diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index d8c8ca8305..a37ff954f7 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -1,21 +1,20 @@ from math import ceil -from rubicon.objc import objc_method, objc_property +from rubicon.objc import CGSize, objc_method, objc_property from travertino.size import at_least from toga.colors import BLACK, TRANSPARENT, color from toga.widgets.canvas import Baseline, FillRule from toga_cocoa.colors import native_color -from toga_cocoa.images import nsdata_to_bytes from toga_cocoa.libs import ( CGFloat, CGPathDrawingMode, CGRectMake, NSAttributedString, - NSBitmapImageFileType, NSFontAttributeName, NSForegroundColorAttributeName, NSGraphicsContext, + NSImage, NSMutableDictionary, NSPoint, NSRect, @@ -321,16 +320,19 @@ def write_text(self, text, x, y, font, baseline, **kwargs): ) def get_image_data(self): + bitmap = self.native.bitmapImageRepForCachingDisplayInRect(self.native.bounds) - bitmap.setSize(self.native.bounds.size) self.native.cacheDisplayInRect(self.native.bounds, toBitmapImageRep=bitmap) - return nsdata_to_bytes( - bitmap.representationUsingType( - NSBitmapImageFileType.PNG, - properties=None, - ) + # Get a reference to the CGImage from the bitmap + cg_image = bitmap.CGImage + + target_size = CGSize( + core_graphics.CGImageGetWidth(cg_image), + core_graphics.CGImageGetHeight(cg_image), ) + ns_image = NSImage.alloc().initWithCGImage(cg_image, size=target_size) + return ns_image # Rehint def rehint(self): diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 17b70dd16b..b10a63d784 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,10 +1,11 @@ +from rubicon.objc import CGSize + from toga.command import Command, Separator from toga_cocoa.container import Container -from toga_cocoa.images import nsdata_to_bytes from toga_cocoa.libs import ( SEL, NSBackingStoreBuffered, - NSBitmapImageFileType, + NSImage, NSMakeRect, NSMutableArray, NSPoint, @@ -14,10 +15,13 @@ NSToolbarItem, NSWindow, NSWindowStyleMask, + core_graphics, objc_method, objc_property, ) +from .screens import Screen as ScreenImpl + def toolbar_identifier(cmd): return f"Toolbar-{type(cmd).__name__}-{id(cmd)}" @@ -305,16 +309,23 @@ def cocoa_windowShouldClose(self): def close(self): self.native.close() + def get_current_screen(self): + return ScreenImpl(self.native.screen) + def get_image_data(self): bitmap = self.container.native.bitmapImageRepForCachingDisplayInRect( self.container.native.bounds ) - bitmap.setSize(self.container.native.bounds.size) self.container.native.cacheDisplayInRect( self.container.native.bounds, toBitmapImageRep=bitmap ) - data = bitmap.representationUsingType( - NSBitmapImageFileType.PNG, - properties=None, + + # Get a reference to the CGImage from the bitmap + cg_image = bitmap.CGImage + + target_size = CGSize( + core_graphics.CGImageGetWidth(cg_image), + core_graphics.CGImageGetHeight(cg_image), ) - return nsdata_to_bytes(data) + ns_image = NSImage.alloc().initWithCGImage(cg_image, size=target_size) + return ns_image diff --git a/cocoa/tests_backend/probe.py b/cocoa/tests_backend/probe.py index df70de74c7..671671f3cc 100644 --- a/cocoa/tests_backend/probe.py +++ b/cocoa/tests_backend/probe.py @@ -4,6 +4,7 @@ from rubicon.objc import SEL, NSArray, NSObject, ObjCClass, objc_method from rubicon.objc.api import NSString +import toga from toga_cocoa.libs.appkit import appkit NSRunLoop = ObjCClass("NSRunLoop") @@ -48,7 +49,7 @@ async def post_event(self, event, delay=None): async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" - if self.app.run_slow: + if toga.App.app.run_slow: # If we're running slow, wait for a second print("Waiting for redraw" if message is None else message) delay = 1 @@ -60,7 +61,7 @@ async def redraw(self, message=None, delay=None): # for at least one iteration. `runUntilDate:None` does this. NSRunLoop.currentRunLoop.runUntilDate(None) - def assert_image_size(self, image_size, size): - # Cocoa reports image sizing in the natural screen coordinates, not the size of - # the backing store. - assert image_size == size + def assert_image_size(self, image_size, size, screen): + # Screenshots are captured in native device resolution, not in CSS pixels. + scale = int(screen._impl.native.backingScaleFactor) + assert image_size == (size[0] * scale, size[1] * scale) diff --git a/cocoa/tests_backend/screens.py b/cocoa/tests_backend/screens.py new file mode 100644 index 0000000000..19745e40b1 --- /dev/null +++ b/cocoa/tests_backend/screens.py @@ -0,0 +1,16 @@ +from toga.images import Image as TogaImage +from toga_cocoa.libs import NSScreen + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe): + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + assert isinstance(self.native, NSScreen) + + def get_screenshot(self, format=TogaImage): + return self.screen.as_image(format=format) diff --git a/cocoa/tests_backend/widgets/canvas.py b/cocoa/tests_backend/widgets/canvas.py index cd2472ef0a..900f313f0c 100644 --- a/cocoa/tests_backend/widgets/canvas.py +++ b/cocoa/tests_backend/widgets/canvas.py @@ -4,6 +4,7 @@ from rubicon.objc import NSPoint from toga.colors import TRANSPARENT +from toga.images import Image as TogaImage from toga_cocoa.libs import NSEventType, NSView from .base import SimpleProbe @@ -27,7 +28,7 @@ def reference_variant(self, reference): return reference def get_image(self): - image = Image.open(BytesIO(self.impl.get_image_data())) + image = Image.open(BytesIO(TogaImage(self.impl.get_image_data()).data)) try: # If the image has an ICC profile, convert it into sRGB colorspace. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index b80ef34a9a..b114ba1cf5 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -27,6 +27,7 @@ from toga.icons import Icon from toga.paths import Paths from toga.platform import get_platform_factory +from toga.screens import Screen from toga.widgets.base import Widget from toga.window import Window @@ -496,6 +497,11 @@ def __init__( def _create_impl(self): self.factory.App(interface=self) + @property + def screens(self) -> list[Screen]: + """Returns a list of available screens.""" + return [screen.interface for screen in self._impl.get_screens()] + @property def paths(self) -> Paths: """Paths for platform-appropriate locations on the user's file system. diff --git a/core/src/toga/screens.py b/core/src/toga/screens.py new file mode 100644 index 0000000000..dbdd061cf7 --- /dev/null +++ b/core/src/toga/screens.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from toga.images import Image +from toga.platform import get_platform_factory + +if TYPE_CHECKING: + from toga.images import ImageT + + +class Screen: + def __init__(self, _impl): + self._impl = _impl + self.factory = get_platform_factory() + + @property + def name(self) -> str: + """Unique name of the screen.""" + return self._impl.get_name() + + @property + def origin(self) -> tuple[int, int]: + """The absolute coordinates of the screen's origin, as a ``(x, y)`` tuple.""" + return self._impl.get_origin() + + @property + def size(self) -> tuple[int, int]: + """The size of the screen, as a ``(width, height)`` tuple.""" + return self._impl.get_size() + + def as_image(self, format: type[ImageT] = Image) -> ImageT: + """Render the current contents of the screen as an image. + + :param format: Format for the resulting image. Defaults to + :class:`~toga.images.Image`; also supports :any:`PIL.Image.Image` if Pillow + is installed + :returns: An image containing the screen content, in the format requested. + """ + return Image(self._impl.get_image_data()).as_format(format) diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 2304b5f45e..c9456ce9a7 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -25,6 +25,7 @@ if TYPE_CHECKING: from toga.app import App from toga.images import ImageT + from toga.screens import Screen from toga.widgets.base import Widget @@ -197,6 +198,21 @@ def __init__( self.on_close = on_close + @property + def screen(self) -> Screen: + """Instance of the :class:`toga.Screen` on which this window is present.""" + return self._impl.get_current_screen().interface + + @screen.setter + def screen(self, app_screen: Screen) -> None: + original_window_location = self.position + original_origin = self.screen.origin + new_origin = app_screen.origin + x = original_window_location[0] - original_origin[0] + new_origin[0] + y = original_window_location[1] - original_origin[1] + new_origin[1] + + self._impl.set_position((x, y)) + @property def id(self) -> str: """A unique identifier for the window.""" @@ -311,13 +327,45 @@ def size(self, size: tuple[int, int]) -> None: @property def position(self) -> tuple[int, int]: - """Position of the window, as a tuple of ``(x, y)`` coordinates, in - :ref:`CSS pixels `.""" - return self._impl.get_position() + """Absolute position of the window, as a ``(x, y)`` tuple coordinates, in + :ref:`CSS pixels `. + + The origin is the top left corner of the primary screen. + """ + absolute_origin = self._app.screens[0].origin + absolute_window_position = self._impl.get_position() + + window_position = ( + absolute_window_position[0] - absolute_origin[0], + absolute_window_position[1] - absolute_origin[1], + ) + return window_position @position.setter def position(self, position: tuple[int, int]) -> None: - self._impl.set_position(position) + absolute_origin = self._app.screens[0].origin + absolute_new_position = ( + position[0] + absolute_origin[0], + position[1] + absolute_origin[1], + ) + self._impl.set_position(absolute_new_position) + + @property + def screen_position(self) -> tuple[int, int]: + """Position of the window with respect to current screen, as a ``(x, y)`` tuple.""" + current_relative_position = ( + self.position[0] - self.screen.origin[0], + self.position[1] - self.screen.origin[1], + ) + return current_relative_position + + @screen_position.setter + def screen_position(self, position: tuple[int, int]) -> None: + new_relative_position = ( + position[0] + self.screen.origin[0], + position[1] + self.screen.origin[1], + ) + self._impl.set_position(new_relative_position) def show(self) -> None: """Show the window. If the window is already visible, this method has no diff --git a/core/tests/app/test_screens.py b/core/tests/app/test_screens.py new file mode 100644 index 0000000000..566891df09 --- /dev/null +++ b/core/tests/app/test_screens.py @@ -0,0 +1,46 @@ +from PIL.Image import Image as PILImage + +from toga.images import Image as TogaImage +from toga_dummy.utils import assert_action_performed + + +def test_name(app): + """The name of the screens can be retrieved""" + assert app.screens[0].name == "Primary Screen" + assert app.screens[1].name == "Secondary Screen" + + +def test_origin(app): + """The origin of the screens can be retrieved""" + assert app.screens[0].origin == (0, 0) + assert app.screens[1].origin == (-1366, -768) + + +def test_size(app): + """The size of the screens can be retrieved""" + assert app.screens[0].size == (1920, 1080) + assert app.screens[1].size == (1366, 768) + + +def test_as_image(app): + """A screen can be captured as an image""" + for screen in app.screens: + # `as_image()` should default to `toga.images.Image` as format. + toga_image_screenshot = screen.as_image() + assert_action_performed(screen, "get image data") + + # Check if returned image is of type `toga.images.Image`, and the right size + assert isinstance(toga_image_screenshot, TogaImage) + assert toga_image_screenshot.size == screen.size + + +def test_as_image_format(app): + """A screen can be captured as an image in a non-default format""" + for screen in app.screens: + # Capture image in PIL format + pil_screenshot = screen.as_image(format=PILImage) + assert_action_performed(screen, "get image data") + + # Check if returned image is of type `PIL.Image.Image`, and the right size + assert isinstance(pil_screenshot, PILImage) + assert pil_screenshot.size == screen.size diff --git a/core/tests/test_window.py b/core/tests/test_window.py index f925dafa16..bd10287bb9 100644 --- a/core/tests/test_window.py +++ b/core/tests/test_window.py @@ -354,6 +354,33 @@ def test_as_image(window): assert image.size == (318, 346) +def test_screen(window, app): + """A window can be moved to a different screen""" + # Cannot actually change window.screen, so just check + # the window positions as a substitute for moving the + # window between the screens. + # `window.screen` will return `Secondary Screen` + assert window.screen == app.screens[1] + assert window.position == (100, 100) + window.screen = app.screens[0] + assert window.position == (1466, 868) + + +def test_screen_position(window, app): + """The window can be relocated using absolute and relative screen positions.""" + # Details about screen layout are in toga_dummy=>app.py=>get_screens() + initial_position = window.position + window.position = (-100, -100) + assert window.position != initial_position + assert window.position == (-100, -100) + assert window.screen_position == (1266, 668) + + # Move the window to a new position. + window.screen_position = (100, 100) + assert window.position == (-1266, -668) + assert window.screen_position == (100, 100) + + def test_info_dialog(window, app): """An info dialog can be shown""" on_result_handler = Mock() diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 7de538407c..02030dc87e 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -96,6 +96,7 @@ Device and Hardware Usage Description ==================================================================== ======================================================================== :doc:`Camera ` A sensor that can capture photos and/or video. + :doc:`Screen ` A representation of a screen attached to a device. ==================================================================== ======================================================================== Other @@ -121,3 +122,4 @@ Other widgets/index constants keys + screens diff --git a/docs/reference/api/screens.rst b/docs/reference/api/screens.rst new file mode 100644 index 0000000000..57d9dcaa0f --- /dev/null +++ b/docs/reference/api/screens.rst @@ -0,0 +1,38 @@ +Screen +====== + +A representation of a screen attached to a device. + +.. rst-class:: widget-support +.. csv-filter:: Availability (:ref:`Key `) + :header-rows: 1 + :file: ../data/widgets_by_platform.csv + :included_cols: 4,5,6,7,8,9,10 + :exclude: {0: '(?!(Screen|Device)$)'} + +Usage +----- + +An app will always have access to at least one screen. The :any:`toga.App.screens` +attribute will return the list of all available screens; the screen at index 0 will be +the "primary" screen. Screen sizes and positions are given in CSS pixels. + +.. code-block:: python + + # Print the size of the primary screen. + print(my_app.screens[0].size) + + # Print the identifying name of the second screen + print(my_app.screens[1].name) + +Notes +----- + +* When using the GTK backend under Wayland, the screen at index 0 may not be the primary + screen. This because the separation of concerns enforced by Wayland makes determining + the primary screen unreliable. + +Reference +--------- + +.. autoclass:: toga.screens.Screen diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 92094edd00..7af7bad338 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -29,8 +29,14 @@ ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that ca SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,,, OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,|y|,|y|,, Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|y|,,,|y|,|y|,, +Screen,Hardware,:class:`~toga.screens.Screen`,A representation of a screen attached to a device.,|y|,|y|,|y|,|y|,|y|,|b|,|b| App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| -Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, +Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b| Image,Resource,:class:`~toga.Image`,Graphical content of arbitrary size.,|y|,|y|,|y|,|y|,|y|,, +ListSource,Resource,:class:`~toga.sources.ListSource`,A data source describing an ordered list of data.,|y|,|y|,|y|,|y|,|y|,|y|,|y| +Source,Resource,:class:`~toga.sources.Source`,A base class for data source implementations.,|y|,|y|,|y|,|y|,|y|,|y|,|y| +TreeSource,Resource,:class:`~toga.sources.TreeSource`,A data source describing an ordered hierarchical tree of data.,|y|,|y|,|y|,|y|,|y|,|y|,|y| +Validators,Resource,:ref:`Validators `,A mechanism for validating that input meets a given set of criteria.,|y|,|y|,|y|,|y|,|y|,|y|,|y| +ValueSource,Resource,:class:`~toga.sources.ValueSource`,A data source describing a single value.,|y|,|y|,|y|,|y|,|y|,|y|,|y| diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 0f752b5e7d..7e95948625 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -2,6 +2,7 @@ import sys from pathlib import Path +from .screens import Screen as ScreenImpl from .utils import LoggedObject from .window import Window @@ -67,6 +68,33 @@ def hide_cursor(self): def simulate_exit(self): self.interface.on_exit() + def get_screens(self): + # _________________________________________________ + # Display Setup: | + # ________________________________________________| + # |--1366--| | + # (-1366,-768) _________ | + # | | | | + # 768 |Secondary| | + # | | Screen | | + # | |_________|(0,0) | + # _________ | + # | | | | + # 1080 | Primary | | + # | | Screen | | + # | |_________|(1920,1080) | + # |---1920--| | + # ________________________________________________| + # `window.screen` will return `Secondary Screen` | + # as window is on secondary screen to better | + # test out the differences between | + # `window.position` & `window.screen_position`. | + # ________________________________________________| + return [ + ScreenImpl(native=("Primary Screen", (0, 0), (1920, 1080))), + ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))), + ] + class DocumentApp(App): def create(self): diff --git a/dummy/src/toga_dummy/screens.py b/dummy/src/toga_dummy/screens.py new file mode 100644 index 0000000000..efde573938 --- /dev/null +++ b/dummy/src/toga_dummy/screens.py @@ -0,0 +1,41 @@ +from PIL import Image, ImageDraw + +from toga.screens import Screen as ScreenInterface + +from .utils import LoggedObject # noqa + + +class Screen(LoggedObject): + _instances = {} + + # native: tuple = ( + # name: str, + # origin: tuple(x:int, y:int), + # size: tuple(width:int, height:int) + # ) + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + return self.native[0] + + def get_origin(self): + return self.native[1] + + def get_size(self): + return self.native[2] + + def get_image_data(self): + self._action("get image data") + + img = Image.new("RGB", self.native[2], "white") + draw = ImageDraw.Draw(img) + draw.text((0, 0), self.native[0], fill="black") # text = self.native[0] + return img diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index 710ec40fc3..2965d7c340 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -2,6 +2,7 @@ import toga_dummy +from .screens import Screen as ScreenImpl from .utils import LoggedObject @@ -105,3 +106,7 @@ def set_full_screen(self, is_full_screen): def simulate_close(self): self.interface.on_close() + + def get_current_screen(self): + # `window.screen` will return `Secondary Screen` + return ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))) diff --git a/examples/window/window/app.py b/examples/window/window/app.py index a986ef2787..a1050e5cb8 100644 --- a/examples/window/window/app.py +++ b/examples/window/window/app.py @@ -1,8 +1,9 @@ import asyncio from datetime import datetime +from functools import partial import toga -from toga.constants import COLUMN +from toga.constants import COLUMN, RIGHT from toga.style import Pack @@ -17,6 +18,12 @@ def do_left(self, widget, **kwargs): def do_right(self, widget, **kwargs): self.main_window.position = (2000, 500) + def do_left_current_screen(self, widget, **kwargs): + self.main_window.screen_position = (0, 100) + + def do_right_current_screen(self, widget, **kwargs): + self.main_window.screen_position = (1080, 100) + def do_small(self, widget, **kwargs): self.main_window.size = (400, 300) @@ -69,6 +76,20 @@ def do_new_windows(self, widget, **kwargs): ) no_close_handler_window.show() + def do_screen_change(self, screen, widget, **kwargs): + self.current_window.screen = screen + + async def do_save_screenshot(self, screen, window, **kwargs): + screenshot = screen.as_image() + path = await self.main_window.save_file_dialog( + "Save screenshot", + suggested_filename=f"Screenshot_{screen.name}.png", + file_types=["png"], + ) + if path is None: + return + screenshot.save(path) + async def do_current_window_cycling(self, widget, **kwargs): for window in self.windows: self.current_window = window @@ -140,6 +161,16 @@ def startup(self): ) btn_do_left = toga.Button("Go left", on_press=self.do_left, style=btn_style) btn_do_right = toga.Button("Go right", on_press=self.do_right, style=btn_style) + btn_do_left_current_screen = toga.Button( + "Go left on current screen", + on_press=self.do_left_current_screen, + style=btn_style, + ) + btn_do_right_current_screen = toga.Button( + "Go right on current screen", + on_press=self.do_right_current_screen, + style=btn_style, + ) btn_do_small = toga.Button( "Become small", on_press=self.do_small, style=btn_style ) @@ -177,12 +208,49 @@ def startup(self): btn_hide = toga.Button("Hide", on_press=self.do_hide, style=btn_style) btn_beep = toga.Button("Beep", on_press=self.do_beep, style=btn_style) + screen_change_btns_box = toga.Box( + children=[ + toga.Label( + text="Move current window to:", + style=Pack(width=200, text_align=RIGHT), + ) + ], + style=Pack(padding=5), + ) + for index, screen in sorted(enumerate(self.screens), key=lambda s: s[1].origin): + screen_change_btns_box.add( + toga.Button( + text=f"{index}: {screen.name}", + on_press=partial(self.do_screen_change, screen), + style=Pack(padding_left=5), + ) + ) + screen_as_image_btns_box = toga.Box( + children=[ + toga.Label( + text="Take screenshot of screen:", + style=Pack(width=200, text_align=RIGHT), + ) + ], + style=Pack(padding=5), + ) + for index, screen in sorted(enumerate(self.screens), key=lambda s: s[1].origin): + screen_as_image_btns_box.add( + toga.Button( + text=f"{index}: {screen.name}", + on_press=partial(self.do_save_screenshot, screen), + style=Pack(padding_left=5), + ) + ) + self.inner_box = toga.Box( children=[ self.label, btn_do_origin, btn_do_left, btn_do_right, + btn_do_left_current_screen, + btn_do_right_current_screen, btn_do_small, btn_do_large, btn_do_app_full_screen, @@ -195,6 +263,8 @@ def startup(self): btn_change_content, btn_hide, btn_beep, + screen_change_btns_box, + screen_as_image_btns_box, ], style=Pack(direction=COLUMN), ) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index d85db4e896..812995764c 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -1,4 +1,5 @@ import asyncio +import os import signal import sys from pathlib import Path @@ -11,6 +12,7 @@ from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk +from .screens import Screen as ScreenImpl from .window import Window @@ -187,6 +189,23 @@ def main_loop(self): self.loop.run_forever(application=self.native) + def get_screens(self): + display = Gdk.Display.get_default() + if "WAYLAND_DISPLAY" in os.environ: # pragma: no cover + # `get_primary_monitor()` doesn't work on wayland, so return as it is. + return [ + ScreenImpl(native=display.get_monitor(i)) + for i in range(display.get_n_monitors()) + ] + else: + primary_screen = ScreenImpl(display.get_primary_monitor()) + screen_list = [primary_screen] + [ + ScreenImpl(native=display.get_monitor(i)) + for i in range(display.get_n_monitors()) + if display.get_monitor(i) != primary_screen.native + ] + return screen_list + def set_main_window(self, window): pass diff --git a/gtk/src/toga_gtk/screens.py b/gtk/src/toga_gtk/screens.py new file mode 100644 index 0000000000..c095e4f7a5 --- /dev/null +++ b/gtk/src/toga_gtk/screens.py @@ -0,0 +1,50 @@ +import os + +from toga.screens import Screen as ScreenInterface + +from .libs import Gdk + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + return self.native.get_model() + + def get_origin(self): + geometry = self.native.get_geometry() + return geometry.x, geometry.y + + def get_size(self): + geometry = self.native.get_geometry() + return geometry.width, geometry.height + + def get_image_data(self): + if "WAYLAND_DISPLAY" in os.environ: + # Not implemented on wayland due to wayland security policies. + self.interface.factory.not_implemented("Screen.get_image_data() on Wayland") + else: + # Only works for Xorg + display = self.native.get_display() + screen = display.get_default_screen() + window = screen.get_root_window() + geometry = self.native.get_geometry() + screenshot = Gdk.pixbuf_get_from_window( + window, geometry.x, geometry.y, geometry.width, geometry.height + ) + success, buffer = screenshot.save_to_bufferv("png", [], []) + if success: + return bytes(buffer) + else: # pragma: no cover + print("Failed to save screenshot to buffer.") + return None diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index 9e2c1389fc..a8f48ab26d 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -2,6 +2,7 @@ from .container import TogaContainer from .libs import Gdk, Gtk +from .screens import Screen as ScreenImpl class Window: @@ -163,6 +164,11 @@ def set_full_screen(self, is_full_screen): else: self.native.unfullscreen() + def get_current_screen(self): + display = Gdk.Display.get_default() + monitor_native = display.get_monitor_at_window(self.native.get_window()) + return ScreenImpl(monitor_native) + def get_image_data(self): display = self.native.get_display() display.flush() diff --git a/gtk/tests_backend/probe.py b/gtk/tests_backend/probe.py index abb09434fd..198d7b554a 100644 --- a/gtk/tests_backend/probe.py +++ b/gtk/tests_backend/probe.py @@ -1,5 +1,6 @@ import asyncio +import toga from toga_gtk.libs import Gtk @@ -14,12 +15,12 @@ async def redraw(self, message=None, delay=None): Gtk.main_iteration_do(blocking=False) # If we're running slow, wait for a second - if self.app.run_slow: + if toga.App.app.run_slow: print("Waiting for redraw" if message is None else message) delay = 1 if delay: await asyncio.sleep(delay) - def assert_image_size(self, image_size, size): + def assert_image_size(self, image_size, size, screen): assert image_size == size diff --git a/gtk/tests_backend/screens.py b/gtk/tests_backend/screens.py new file mode 100644 index 0000000000..399544797c --- /dev/null +++ b/gtk/tests_backend/screens.py @@ -0,0 +1,28 @@ +import os + +import pytest +from gi.repository import GdkX11 + +from toga.images import Image as TogaImage + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe): + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + if "WAYLAND_DISPLAY" in os.environ: + # The native display type on Wayland is `__gi__.GdkWaylandMonitor` + # However, that class can't be imported directly. + pass + else: + assert isinstance(self.native, GdkX11.X11Monitor) + + def get_screenshot(self, format=TogaImage): + if "WAYLAND_DISPLAY" in os.environ: + pytest.skip("Screen.as_image() is not implemented on wayland.") + else: + return self.screen.as_image(format=format) diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 343790c2a1..3f3f058cfc 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -3,9 +3,11 @@ from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle -from toga_iOS.libs import UIResponder, av_foundation +from toga_iOS.libs import UIResponder, UIScreen, av_foundation from toga_iOS.window import Window +from .screens import Screen as ScreenImpl + class MainWindow(Window): _is_main_window = True @@ -120,3 +122,6 @@ def hide_cursor(self): def show_cursor(self): # No-op; mobile doesn't support cursors pass + + def get_screens(self): + return [ScreenImpl(UIScreen.mainScreen)] diff --git a/iOS/src/toga_iOS/screens.py b/iOS/src/toga_iOS/screens.py new file mode 100644 index 0000000000..c81f2b08c4 --- /dev/null +++ b/iOS/src/toga_iOS/screens.py @@ -0,0 +1,43 @@ +from rubicon.objc import Block, objc_id + +from toga.screens import Screen as ScreenInterface +from toga_iOS.libs import UIGraphicsImageRenderer, UIImage + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + # Return a dummy name as UIScreen object has no name related attributes. + return "iOS Screen" + + def get_origin(self): + return (0, 0) + + def get_size(self): + return ( + int(self.native.bounds.size.width), + int(self.native.bounds.size.height), + ) + + def get_image_data(self): + ui_view = self.native.snapshotViewAfterScreenUpdates_(True) + renderer = UIGraphicsImageRenderer.alloc().initWithSize(ui_view.bounds.size) + + def render(context): + ui_view.drawViewHierarchyInRect(ui_view.bounds, afterScreenUpdates=True) + + ui_image = UIImage.imageWithData( + renderer.PNGDataWithActions(Block(render, None, objc_id)) + ) + return ui_image diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index 7bd8bda595..c4996b0a58 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -19,6 +19,8 @@ uikit, ) +from .screens import Screen as ScreenImpl + class Window: _is_main_window = False @@ -115,6 +117,9 @@ def set_full_screen(self, is_full_screen): def close(self): pass + def get_current_screen(self): + return ScreenImpl(UIScreen.mainScreen) + def get_image_data(self): # This is... baroque. # diff --git a/iOS/tests_backend/probe.py b/iOS/tests_backend/probe.py index c3a4653d11..1b101dc6bd 100644 --- a/iOS/tests_backend/probe.py +++ b/iOS/tests_backend/probe.py @@ -1,5 +1,6 @@ import asyncio +import toga from toga_iOS.libs import NSRunLoop, UIScreen @@ -7,7 +8,7 @@ class BaseProbe: async def redraw(self, message=None, delay=None): """Request a redraw of the app, waiting until that redraw has completed.""" # If we're running slow, wait for a second - if self.app.run_slow: + if toga.App.app.run_slow: print("Waiting for redraw" if message is None else message) delay = 1 @@ -18,7 +19,7 @@ async def redraw(self, message=None, delay=None): # for at least one iteration. `runUntilDate:None` does this. NSRunLoop.currentRunLoop.runUntilDate(None) - def assert_image_size(self, image_size, size): + def assert_image_size(self, image_size, size, screen): # Retina displays render images at a higher resolution than their reported size. scale = int(UIScreen.mainScreen.scale) assert image_size == (size[0] * scale, size[1] * scale) diff --git a/iOS/tests_backend/screens.py b/iOS/tests_backend/screens.py new file mode 100644 index 0000000000..453a6bca35 --- /dev/null +++ b/iOS/tests_backend/screens.py @@ -0,0 +1,16 @@ +from toga.images import Image as TogaImage +from toga_iOS.libs import UIScreen + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe): + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + assert isinstance(self.native, UIScreen) + + def get_screenshot(self, format=TogaImage): + return self.screen.as_image(format=format) diff --git a/testbed/tests/app/__init__.py b/testbed/tests/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/test_app.py b/testbed/tests/app/test_app.py similarity index 97% rename from testbed/tests/test_app.py rename to testbed/tests/app/test_app.py index 326860a6a8..b4d331bbce 100644 --- a/testbed/tests/test_app.py +++ b/testbed/tests/app/test_app.py @@ -6,7 +6,7 @@ from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE from toga.style.pack import Pack -from .test_window import window_probe +from ..test_window import window_probe @pytest.fixture @@ -550,3 +550,19 @@ async def test_beep(app): # can be invoked without raising an error, but there's no way to verify that the app # actually made a noise. app.beep() + + +async def test_screens(app, app_probe): + """Screens must have unique origins and names, with the primary screen at (0,0).""" + + # Get the origin of screen 0 + assert app.screens[0].origin == (0, 0) + + # Check for unique names + screen_names = [s.name for s in app.screens] + unique_names = set(screen_names) + assert len(screen_names) == len(unique_names) + + # Check that the origin of every other screen is not "0,0" + origins_not_zero = all(screen.origin != (0, 0) for screen in app.screens[1:]) + assert origins_not_zero is True diff --git a/testbed/tests/app/test_screens.py b/testbed/tests/app/test_screens.py new file mode 100644 index 0000000000..78ec227b10 --- /dev/null +++ b/testbed/tests/app/test_screens.py @@ -0,0 +1,69 @@ +from importlib import import_module + +from PIL.Image import Image as PILImage + +from toga.images import Image as TogaImage + + +def screen_probe(screen): + module = import_module("tests_backend.screens") + return getattr(module, "ScreenProbe")(screen) + + +async def test_name(app): + """The name of the screens can be retrieved""" + for screen in app.screens: + # Just check that it returns a string as the name will be platform specific. + assert isinstance(screen.name, str) + + +async def test_origin(app): + """The origin of the screens can be retrieved""" + for screen in app.screens: + origin = screen.origin + assert ( + isinstance(origin, tuple) + and len(origin) == 2 + and all(isinstance(val, int) for val in origin) + ) + + +async def test_size(app): + """The size of the screens can be retrieved""" + for screen in app.screens: + size = screen.size + assert ( + isinstance(size, tuple) + and len(size) == 2 + # Check that neither the width or height is zero. + and all(isinstance(val, int) and val > 0 for val in size) + ) + + +async def test_as_image(app): + """A screen can be captured as an image""" + # Using a probe for test as the feature is not implemented on some platforms. + for screen in app.screens: + probe = screen_probe(screen) + + # `get_screenshot()` should default to `toga.images.Image` as format. + screenshot = probe.get_screenshot() + await probe.redraw(f"Screenshot of {screen} has been taken") + # Check if returned image is of type `toga.images.Image`. + assert isinstance(screenshot, TogaImage) + probe.assert_image_size( + screenshot.size, + probe.screen.size, + screen=probe.screen, + ) + + # Capture screenshot in PIL format + pil_screenshot = probe.get_screenshot(format=PILImage) + await probe.redraw(f"Screenshot of {screen} has been taken in PIL format") + # Check if returned image is of type `PIL.Image.Image`. + assert isinstance(pil_screenshot, PILImage) + probe.assert_image_size( + pil_screenshot.size, + probe.screen.size, + screen=probe.screen, + ) diff --git a/testbed/tests/test_window.py b/testbed/tests/test_window.py index 70067ce9c5..d85654a9f2 100644 --- a/testbed/tests/test_window.py +++ b/testbed/tests/test_window.py @@ -152,6 +152,16 @@ async def test_full_screen(main_window, main_window_probe): main_window.full_screen = False await main_window_probe.wait_for_window("Full screen is a no-op") + async def test_screen(main_window, main_window_probe): + """The window can be relocated to another screen, using both absolute and relative screen positions.""" + assert main_window.screen.origin == (0, 0) + initial_size = main_window.size + main_window.position = (150, 50) + await main_window_probe.wait_for_window("Main window can't be moved") + assert main_window.size == initial_size + assert main_window.position == (0, 0) + assert main_window.screen_position == (0, 0) + else: #################################################################################### # Desktop platform tests @@ -479,12 +489,46 @@ async def test_full_screen(second_window, second_window_probe): assert not second_window_probe.is_full_screen assert second_window_probe.content_size == initial_content_size + @pytest.mark.parametrize( + "second_window_kwargs", + [dict(title="Secondary Window", position=(200, 150))], + ) + async def test_screen(second_window, second_window_probe): + """The window can be relocated to another screen, using both absolute and relative screen positions.""" + + initial_position = second_window.position + + # Move the window using absolute position. + second_window.position = (200, 200) + await second_window_probe.wait_for_window("Secondary window has been moved") + assert second_window.position != initial_position + + # `position` and `screen_position` will be same as the window will be in primary screen. + assert second_window.position == (200, 200) + assert second_window.screen_position == (200, 200) + + # Move the window between available screens and assert its `screen_position` + for screen in second_window.app.screens: + second_window.screen = screen + await second_window_probe.wait_for_window( + f"Secondary window has been moved to {screen.name}" + ) + assert second_window.screen == screen + assert second_window.screen_position == ( + second_window.position[0] - screen.origin[0], + second_window.position[1] - screen.origin[1], + ) + async def test_as_image(main_window, main_window_probe): """The window can be captured as a screenshot""" screenshot = main_window.as_image() - main_window_probe.assert_image_size(screenshot.size, main_window_probe.content_size) + main_window_probe.assert_image_size( + screenshot.size, + main_window_probe.content_size, + screen=main_window.screen, + ) ######################################################################################## diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 07e4df0023..e351ed1364 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -231,7 +231,11 @@ async def test_image_data(canvas, probe): # Cloned image is the right size. The platform may do DPI scaling; # let the probe determine the correct scaled size. - probe.assert_image_size(image.size, (200, 200)) + probe.assert_image_size( + image.size, + (200, 200), + screen=canvas.window.screen, + ) def assert_reference(probe, reference, threshold=0.0): diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index ff71825494..5c21bdab5e 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -2,6 +2,7 @@ from textual.app import App as TextualApp +from .screens import Screen as ScreenImpl from .window import Window @@ -69,6 +70,9 @@ def show_cursor(self): def hide_cursor(self): pass + def get_screens(self): + return [ScreenImpl(window._impl.native) for window in self.interface.windows] + class DocumentApp(App): pass diff --git a/textual/src/toga_textual/screens.py b/textual/src/toga_textual/screens.py new file mode 100644 index 0000000000..cde07341e5 --- /dev/null +++ b/textual/src/toga_textual/screens.py @@ -0,0 +1,32 @@ +from toga.screens import Screen as ScreenInterface + +from .widgets.base import Scalable + + +class Screen(Scalable): + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + return "Textual Screen" + + def get_origin(self): + return (0, 0) + + def get_size(self): + return ( + self.scale_out_horizontal(self.native.size.width), + self.scale_out_vertical(self.native.size.height), + ) + + def get_image_data(self): + self.interface.factory.not_implemented("Screen.get_image_data()") diff --git a/textual/src/toga_textual/window.py b/textual/src/toga_textual/window.py index 239a8568c1..09a6daf381 100644 --- a/textual/src/toga_textual/window.py +++ b/textual/src/toga_textual/window.py @@ -7,6 +7,7 @@ from textual.widgets import Button as TextualButton from .container import Container +from .screens import Screen as ScreenImpl class WindowCloseButton(TextualButton): @@ -168,3 +169,6 @@ def close(self): def set_full_screen(self, is_full_screen): pass + + def get_current_screen(self): + return ScreenImpl(self.native) diff --git a/textual/tests_backend/screens.py b/textual/tests_backend/screens.py new file mode 100644 index 0000000000..5951eb515e --- /dev/null +++ b/textual/tests_backend/screens.py @@ -0,0 +1,15 @@ +import pytest + +from textual.screen import Screen as TextualScreen + + +class ScreenProbe: + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + assert isinstance(self.native, TextualScreen) + + def get_screenshot(self): + pytest.skip("Screen.as_image is not implemented on textual.") diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 2a10cb5eb1..07ed538ff6 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -3,6 +3,8 @@ from toga_web.libs import create_element, js from toga_web.window import Window +from .screens import Screen as ScreenImpl + class MainWindow(Window): def on_close(self, *args): @@ -209,3 +211,6 @@ def show_cursor(self): def hide_cursor(self): self.interface.factory.not_implemented("App.hide_cursor()") + + def get_screens(self): + return [ScreenImpl(js.document.documentElement)] diff --git a/web/src/toga_web/screens.py b/web/src/toga_web/screens.py new file mode 100644 index 0000000000..15e5d49ce1 --- /dev/null +++ b/web/src/toga_web/screens.py @@ -0,0 +1,27 @@ +from toga.screens import Screen as ScreenInterface + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + return "Web Screen" + + def get_origin(self): + return (0, 0) + + def get_size(self): + return self.native.clientWidth, self.native.clientHeight + + def get_image_data(self): + self.interface.factory.not_implemented("Screen.get_image_data()") diff --git a/web/src/toga_web/window.py b/web/src/toga_web/window.py index 6e437cda4e..b78c4028cf 100644 --- a/web/src/toga_web/window.py +++ b/web/src/toga_web/window.py @@ -1,5 +1,7 @@ from toga_web.libs import create_element, js +from .screens import Screen as ScreenImpl + class Window: def __init__(self, interface, title, position, size): @@ -77,3 +79,6 @@ def set_size(self, size): def set_full_screen(self, is_full_screen): self.interface.factory.not_implemented("Window.set_full_screen()") + + def get_current_screen(self): + return ScreenImpl(js.document.documentElement) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 66c0619e59..1bf3aa0b30 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -18,6 +18,7 @@ from .keys import toga_to_winforms_key from .libs.proactor import WinformsProactorEventLoop from .libs.wrapper import WeakrefCallable +from .screens import Screen as ScreenImpl from .window import Window @@ -307,6 +308,15 @@ def exit(self): # pragma: no cover self._is_exiting = True self.native.Exit() + def get_screens(self): + primary_screen = ScreenImpl(WinForms.Screen.PrimaryScreen) + screen_list = [primary_screen] + [ + ScreenImpl(native=screen) + for screen in WinForms.Screen.AllScreens + if screen != primary_screen.native + ] + return screen_list + def set_main_window(self, window): self.app_context.MainForm = window._impl.native diff --git a/winforms/src/toga_winforms/screens.py b/winforms/src/toga_winforms/screens.py new file mode 100644 index 0000000000..7a0001e9e1 --- /dev/null +++ b/winforms/src/toga_winforms/screens.py @@ -0,0 +1,47 @@ +from System.Drawing import ( + Bitmap, + Graphics, + Imaging, + Point, + Size, +) +from System.IO import MemoryStream + +from toga.screens import Screen as ScreenInterface + + +class Screen: + _instances = {} + + def __new__(cls, native): + if native in cls._instances: + return cls._instances[native] + else: + instance = super().__new__(cls) + instance.interface = ScreenInterface(_impl=instance) + instance.native = native + cls._instances[native] = instance + return instance + + def get_name(self): + name = self.native.DeviceName + # WinForms Display naming convention is "\\.\DISPLAY1". Remove the + # non-text part to prevent any errors due to non-escaped characters. + return name.split("\\")[-1] + + def get_origin(self): + return self.native.Bounds.X, self.native.Bounds.Y + + def get_size(self): + return self.native.Bounds.Width, self.native.Bounds.Height + + def get_image_data(self): + bitmap = Bitmap(*self.get_size()) + graphics = Graphics.FromImage(bitmap) + source_point = Point(*self.get_origin()) + destination_point = Point(0, 0) + copy_size = Size(*self.get_size()) + graphics.CopyFromScreen(source_point, destination_point, copy_size) + stream = MemoryStream() + bitmap.Save(stream, Imaging.ImageFormat.Png) + return bytes(stream.ToArray()) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 86ea5e76b0..a23882e290 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -7,6 +7,7 @@ from .container import Container from .libs.wrapper import WeakrefCallable +from .screens import Screen as ScreenImpl from .widgets.base import Scalable @@ -189,6 +190,9 @@ def resize_content(self): self.native.ClientSize.Height - vertical_shift, ) + def get_current_screen(self): + return ScreenImpl(WinForms.Screen.FromControl(self.native)) + def get_image_data(self): size = Size(self.native_content.Size.Width, self.native_content.Size.Height) bitmap = Bitmap(size.Width, size.Height) diff --git a/winforms/tests_backend/probe.py b/winforms/tests_backend/probe.py index bc02d4bba2..77bb06fd34 100644 --- a/winforms/tests_backend/probe.py +++ b/winforms/tests_backend/probe.py @@ -1,7 +1,11 @@ import asyncio +from System import IntPtr +from System.Drawing import Graphics from System.Windows.Forms import SendKeys +import toga + KEY_CODES = { f"<{name}>": f"{{{name.upper()}}}" for name in ["esc", "up", "down", "left", "right"] @@ -19,7 +23,7 @@ async def redraw(self, message=None, delay=None): # Winforms style changes always take effect immediately. # If we're running slow, wait for a second - if self.app.run_slow: + if toga.App.app.run_slow: delay = 1 if delay: @@ -28,7 +32,8 @@ async def redraw(self, message=None, delay=None): @property def scale_factor(self): - return self.native.CreateGraphics().DpiX / 96 + # Does the same thing as `return self.native.CreateGraphics().DpiX / 96` + return Graphics.FromHdc(Graphics.FromHwnd(IntPtr.Zero).GetHdc()).DpiX / 96 async def type_character(self, char, *, shift=False, ctrl=False, alt=False): try: @@ -49,5 +54,5 @@ async def type_character(self, char, *, shift=False, ctrl=False, alt=False): # background. SendKeys.SendWait(key_code) - def assert_image_size(self, image_size, size): + def assert_image_size(self, image_size, size, screen): assert image_size == (size[0] * self.scale_factor, size[1] * self.scale_factor) diff --git a/winforms/tests_backend/screens.py b/winforms/tests_backend/screens.py new file mode 100644 index 0000000000..c6fdfc9c55 --- /dev/null +++ b/winforms/tests_backend/screens.py @@ -0,0 +1,17 @@ +from System.Windows.Forms import Screen as WinFormsScreen + +from toga.images import Image as TogaImage + +from .probe import BaseProbe + + +class ScreenProbe(BaseProbe): + def __init__(self, screen): + super().__init__() + self.screen = screen + self._impl = screen._impl + self.native = screen._impl.native + assert isinstance(self.native, WinFormsScreen) + + def get_screenshot(self, format=TogaImage): + return self.screen.as_image(format=format)