Skip to content

Commit

Permalink
Merge pull request #2103 from freakboy3742/screenshots
Browse files Browse the repository at this point in the history
Provide per-platform screenshots for each widget
  • Loading branch information
freakboy3742 authored Nov 3, 2023
2 parents 055e6a3 + c659d3d commit 6085478
Show file tree
Hide file tree
Showing 214 changed files with 2,040 additions and 314 deletions.
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

0 comments on commit 6085478

Please sign in to comment.