Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide per-platform screenshots for each widget #2103

Merged
merged 43 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b3fca4d
Add a screenshot generation app.
freakboy3742 Aug 27, 2023
e34d512
Add an API to capture screenshots from windows.
freakboy3742 Aug 27, 2023
134d0ac
Update macOS screenshots, and add tabs for other platforms.
freakboy3742 Aug 27, 2023
d6dcbd7
Add API to retrieve raw image data from toga.Image.
freakboy3742 Aug 28, 2023
55367b4
Add manual screenshotting for webview, main window and window.
freakboy3742 Aug 28, 2023
d164841
Use native resolution screenshots with a width definition.
freakboy3742 Aug 28, 2023
42cdbcc
Add ticket-specific release note.
freakboy3742 Aug 28, 2023
e656e77
Add Web and Textual screenshot tabs, plus removed need for availabili…
freakboy3742 Oct 20, 2023
162c270
Add GTK window screenshot capability.
freakboy3742 Oct 21, 2023
804ff52
Correct minimum geometries for text inputs.
freakboy3742 Oct 21, 2023
898c69e
Add GTK screenshots.
freakboy3742 Oct 21, 2023
5438f80
Tweaked image naming.
freakboy3742 Oct 21, 2023
77d287f
Tweak screenshot mechanism, and update screenshots.
freakboy3742 Oct 21, 2023
2291180
Update landing page screenshot.
freakboy3742 Oct 23, 2023
7998112
Add tests for image data and window screenshots.
freakboy3742 Oct 23, 2023
024fc06
One more edge case memory retention issue.
freakboy3742 Oct 23, 2023
2bf0ca5
Update rubicon version dependency, and use updated import location fo…
freakboy3742 Oct 23, 2023
c9955e5
Correct the descrition of nsdata_to_bytes.
freakboy3742 Oct 23, 2023
e9e7381
Update rubicon and nsdata_to_bytes usage on iOS.
freakboy3742 Oct 23, 2023
a1dd9b1
Add screenshots for iOS.
freakboy3742 Oct 24, 2023
8e20d22
Backport probe changes to other backends.
freakboy3742 Oct 24, 2023
66ba49a
Update toga-demo to work on Windows.
freakboy3742 Oct 24, 2023
96ec72c
Ensure macOS screenshots don't have a transparent background.
freakboy3742 Oct 24, 2023
30bb99f
Ignore some hard-to-manufacture error cases.
freakboy3742 Oct 24, 2023
4f90d12
Revert memory retention fix.
freakboy3742 Oct 30, 2023
d5a6050
Remove an experimental memory retention.
freakboy3742 Oct 31, 2023
2b2d23d
Merge branch 'main' into screenshots
freakboy3742 Oct 31, 2023
dc39620
Preserve the tmate configuration so the technique isn't lost.
freakboy3742 Nov 1, 2023
1057c04
Merge branch 'main' into screenshots
freakboy3742 Nov 1, 2023
ac35f48
Add Android screenshots.
freakboy3742 Nov 1, 2023
2628e56
Make android slow mode be slow.
freakboy3742 Nov 1, 2023
52b3b46
Add Winforms screenshots.
freakboy3742 Nov 1, 2023
f48e780
Use backend name consistently in screenshots.
freakboy3742 Nov 1, 2023
b8aaf3c
Correct content size calculation on Winforms probe.
freakboy3742 Nov 1, 2023
f66b543
Correct android probe assertion of image size.
freakboy3742 Nov 2, 2023
768709f
Remove ineffective window background rendering.
freakboy3742 Nov 2, 2023
e5bd523
Merge branch 'main' into screenshots
freakboy3742 Nov 2, 2023
00745c4
Update windows screenshots.
freakboy3742 Nov 2, 2023
50bb7db
Update GTK canvas screenshot.
freakboy3742 Nov 3, 2023
d9b5edd
Update screenshots for iOS, macOS and Android.
freakboy3742 Nov 3, 2023
3a13e67
Update metadata associated with some widget docs.
freakboy3742 Nov 3, 2023
be7c25b
Add missing tabs for container widgets.
freakboy3742 Nov 3, 2023
c659d3d
Include a prospective fix for #2185.
freakboy3742 Nov 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,11 @@ jobs:
with:
name: testbed-failure-app-data-${{ matrix.backend }}
path: testbed/app_data/*
# This step is only needed if you're trying to diagnose test failures that
# only occur in CI, and can't be reproduced locally. When it runs, it will
# open an SSH server (URL reported in the logs) so you can ssh into the CI
# machine.
# - uses: actions/checkout@v3
# - name: Setup tmate session
# uses: mxschmitt/action-tmate@v3
# if: failure()
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
coverage.xml
dist
build
logs
_build
distribute-*
docs/env
Expand Down
7 changes: 6 additions & 1 deletion android/src/toga_android/images.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pathlib import Path

from android.graphics import Bitmap, BitmapFactory
from java.io import FileOutputStream
from java.io import ByteArrayOutputStream, FileOutputStream


class Image:
Expand All @@ -23,6 +23,11 @@ def get_width(self):
def get_height(self):
return self.native.getHeight()

def get_data(self):
stream = ByteArrayOutputStream()
self.native.compress(Bitmap.CompressFormat.PNG, 90, stream)
return bytes(stream.toByteArray())

def save(self, path):
path = Path(path)
try:
Expand Down
19 changes: 19 additions & 0 deletions android/src/toga_android/window.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from decimal import ROUND_UP

from android import R
from android.graphics import (
Bitmap,
Canvas as A_Canvas,
)
from android.view import ViewTreeObserver
from java import dynamic_proxy
from java.io import ByteArrayOutputStream

from .container import Container

Expand Down Expand Up @@ -97,3 +102,17 @@ def close(self):

def set_full_screen(self, is_full_screen):
self.interface.factory.not_implemented("Window.set_full_screen()")

def get_image_data(self):
bitmap = Bitmap.createBitmap(
self.native_content.getWidth(),
self.native_content.getHeight(),
Bitmap.Config.ARGB_8888,
)
canvas = A_Canvas(bitmap)
# TODO: Need to draw window background as well as the content.
self.native_content.draw(canvas)

stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
return bytes(stream.toByteArray())
10 changes: 9 additions & 1 deletion android/tests_backend/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from android.widget import Button
from java import dynamic_proxy
from org.beeware.android import MainActivity
from pytest import approx


class LayoutListener(dynamic_proxy(ViewTreeObserver.OnGlobalLayoutListener)):
Expand Down Expand Up @@ -88,7 +89,14 @@ async def redraw(self, message=None, delay=0):
print("Redraw timed out")

if self.app.run_slow:
delay = min(delay, 1)
delay = max(delay, 1)
if delay:
print("Waiting for redraw" if message is None else message)
await asyncio.sleep(delay)

def assert_image_size(self, image_size, size):
# Sizes are approximate because of scaling inconsistencies.
assert image_size == (
approx(size[0] * self.scale_factor, abs=2),
approx(size[1] * self.scale_factor, abs=2),
)
4 changes: 0 additions & 4 deletions android/tests_backend/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ def reference_variant(self, reference):
def get_image(self):
return Image.open(BytesIO(self.impl.get_image_data()))

def assert_image_size(self, image, width, height):
assert image.width == width * self.scale_factor
assert image.height == height * self.scale_factor

def motion_event(self, action, x, y):
time = SystemClock.uptimeMillis()
super().motion_event(
Expand Down
1 change: 1 addition & 0 deletions changes/2063.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ability to capture the contents of a window as an image has been added.
1 change: 1 addition & 0 deletions changes/2103.docs.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The widget screenshots were updated to provide examples of widgets on every platform.
2 changes: 1 addition & 1 deletion cocoa/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
version=version,
install_requires=[
"fonttools >= 4.42.1, < 5.0.0",
"rubicon-objc >= 0.4.5rc1, < 0.5.0",
"rubicon-objc >= 0.4.7, < 0.5.0",
f"toga-core == {version}",
],
)
19 changes: 19 additions & 0 deletions cocoa/src/toga_cocoa/images.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ctypes import POINTER, c_char, cast
from pathlib import Path

from toga_cocoa.libs import (
Expand All @@ -8,6 +9,15 @@
)


def nsdata_to_bytes(data: NSData) -> bytes:
"""Convert an NSData into a raw bytes representation"""
# data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to
# POINTER(c_char) to get an addressable array of bytes, and slice that array to
# the known length. We don't use c_char_p because it has handling of NUL
# termination, and POINTER(c_char) allows array subscripting.
return cast(data.bytes, POINTER(c_char))[: data.length]


class Image:
def __init__(self, interface, path=None, data=None):
self.interface = interface
Expand Down Expand Up @@ -40,6 +50,15 @@ def get_width(self):
def get_height(self):
return self.native.size.height

def get_data(self):
return nsdata_to_bytes(
NSBitmapImageRep.representationOfImageRepsInArray(
self.native.representations,
usingType=NSBitmapImageFileType.PNG,
properties=None,
)
)

def save(self, path):
path = Path(path)
try:
Expand Down
15 changes: 6 additions & 9 deletions cocoa/src/toga_cocoa/widgets/canvas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from ctypes import POINTER, c_char, cast
from math import ceil

from rubicon.objc import objc_method, objc_property
Expand All @@ -7,6 +6,7 @@
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,
Expand Down Expand Up @@ -325,15 +325,12 @@ def get_image_data(self):
bitmap.setSize(self.native.bounds.size)
self.native.cacheDisplayInRect(self.native.bounds, toBitmapImageRep=bitmap)

data = bitmap.representationUsingType(
NSBitmapImageFileType.PNG,
properties=None,
return nsdata_to_bytes(
bitmap.representationUsingType(
NSBitmapImageFileType.PNG,
properties=None,
)
)
# data is an NSData object that has .bytes as a c_void_p, and a .length. Cast to
# POINTER(c_char) to get an addressable array of bytes, and slice that array to
# the known length. We don't use c_char_p because it has handling of NUL
# termination, and POINTER(c_char) allows array subscripting.
return cast(data.bytes, POINTER(c_char))[: data.length]

# Rehint
def rehint(self):
Expand Down
3 changes: 1 addition & 2 deletions cocoa/src/toga_cocoa/widgets/webview.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from rubicon.objc import objc_method, objc_property, py_from_ns
from rubicon.objc.runtime import objc_id
from rubicon.objc import objc_id, objc_method, objc_property, py_from_ns
from travertino.size import at_least

from toga.widgets.webview import JavaScriptResult
Expand Down
20 changes: 20 additions & 0 deletions cocoa/src/toga_cocoa/window.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from toga.command import Command
from toga_cocoa.container import Container
from toga_cocoa.images import nsdata_to_bytes
from toga_cocoa.libs import (
SEL,
NSBackingStoreBuffered,
NSBitmapImageFileType,
NSMakeRect,
NSMutableArray,
NSPoint,
Expand Down Expand Up @@ -156,6 +158,10 @@ def __init__(self, interface, title, position, size):
self.container = Container(on_refresh=self.content_refreshed)
self.native.contentView = self.container.native

# Ensure that the container renders it's background in the same color as the window.
self.native.wantsLayer = True
self.container.native.backgroundColor = self.native.backgroundColor

# By default, no toolbar
self._toolbar_items = {}
self.native_toolbar = None
Expand Down Expand Up @@ -293,3 +299,17 @@ def cocoa_windowShouldClose(self):

def close(self):
self.native.close()

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,
)
return nsdata_to_bytes(data)
3 changes: 1 addition & 2 deletions cocoa/tests_backend/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from pathlib import Path

from rubicon.objc import NSPoint, ObjCClass, send_message
from rubicon.objc.runtime import objc_id
from rubicon.objc import NSPoint, ObjCClass, objc_id, send_message

from toga_cocoa.keys import cocoa_key, toga_key
from toga_cocoa.libs import (
Expand Down
5 changes: 5 additions & 0 deletions cocoa/tests_backend/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,8 @@ async def redraw(self, message=None, delay=None):
# Running at "normal" speed, we need to release to the event loop
# 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
6 changes: 0 additions & 6 deletions cocoa/tests_backend/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,6 @@ def get_image(self):
except KeyError:
return image

def assert_image_size(self, image, width, height):
# Cocoa reports image sizing in the natural screen coordinates, not the size of
# the backing store.
assert image.width == width
assert image.height == height

async def mouse_press(self, x, y):
await self.mouse_event(
NSEventType.LeftMouseDown,
Expand Down
3 changes: 1 addition & 2 deletions cocoa/tests_backend/window.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from unittest.mock import Mock

from rubicon.objc import send_message
from rubicon.objc import objc_id, send_message
from rubicon.objc.collections import ObjCListInstance
from rubicon.objc.runtime import objc_id

from toga_cocoa.libs import (
NSURL,
Expand Down
19 changes: 15 additions & 4 deletions core/src/toga/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,23 @@ def __init__(
self.path = path
else:
self.path = Path(path)
self.data = None
else:
self.path = None
self.data = data

self.factory = get_platform_factory()
if self.data is not None:
self._impl = self.factory.Image(interface=self, data=self.data)
if data is not None:
self._impl = self.factory.Image(interface=self, data=data)
else:
self.path = toga.App.app.paths.app / self.path
if not self.path.is_file():
raise FileNotFoundError(f"Image file {self.path} does not exist")
self._impl = self.factory.Image(interface=self, path=self.path)

@property
def size(self) -> (int, int):
"""The size of the image, as a tuple"""
return (self._impl.get_width(), self._impl.get_height())

@property
def width(self) -> int:
"""The width of the image, in pixels."""
Expand All @@ -58,6 +61,14 @@ def height(self) -> int:
"""The height of the image, in pixels."""
return self._impl.get_height()

@property
def data(self) -> bytes:
"""The raw data for the image, in PNG format.

:returns: The raw image data in PNG format.
"""
return self._impl.get_data()

def save(self, path: str | Path):
"""Save image to given path.

Expand Down
7 changes: 7 additions & 0 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from toga.command import Command, CommandSet
from toga.handlers import AsyncResult, wrapped_handler
from toga.images import Image
from toga.platform import get_platform_factory
from toga.widgets.base import WidgetRegistry

Expand Down Expand Up @@ -328,6 +329,12 @@ def close(self) -> None:
self._impl.close()
self._closed = True

def as_image(self) -> Image:
"""Render the current contents of the window as an image.

:returns: A :class:`toga.Image` containing the window content."""
return Image(data=self._impl.get_image_data())

############################################################
# Dialogs
############################################################
Expand Down
8 changes: 8 additions & 0 deletions core/tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,18 @@ def test_dimensions():

image = toga.Image(path="resources/toga.png")

assert image.size == (60, 40)
assert image.width == 60
assert image.height == 40


def test_data():
"The raw data of the image can be retrieved."
image = toga.Image(path="resources/toga.png")

assert image.data == b"pretend this is PNG image data"


def test_image_save():
"An image can be saved"
save_path = Path("/path/to/save.png")
Expand Down
7 changes: 7 additions & 0 deletions core/tests/test_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,13 @@ def test_close_rejected_handler(window, app):
on_close_handler.assert_called_once_with(window)


def test_as_image(window):
"""A window can be captured as an image"""
image = window.as_image()

assert image.data == b"pretend this is PNG image data"


def test_info_dialog(window, app):
"""An info dialog can be shown"""
on_result_handler = Mock()
Expand Down
Loading
Loading