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

[widget audit] ImageView #1956

Merged
merged 44 commits into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
402e036
Update ImageView documentation
mhsmith May 30, 2023
043d722
Port ImageView core tests to pytest
mhsmith May 30, 2023
ae5a98a
Add change note
mhsmith May 30, 2023
e1d5c7e
Port tests for base Image.
freakboy3742 Jun 1, 2023
0802c9b
Update API and tests for ImageView.
freakboy3742 Jun 1, 2023
66b9fdd
Prototype cocoa implementation of new resizing behavior.
freakboy3742 Jun 1, 2023
d4d2216
Merge branch 'pack-fixes' into audit-imageview
freakboy3742 Jun 4, 2023
03d7564
Merge remote-tracking branch 'upstream/main' into audit-imageview
freakboy3742 Jun 4, 2023
8c2ffac
Merge branch 'focus-handler-fix' into audit-imageview
freakboy3742 Jun 6, 2023
b10f648
Clarify height/width interpretation when undefined.
freakboy3742 Jun 6, 2023
baca5a3
Refactor handling of paths.
freakboy3742 Jun 6, 2023
fc66474
Merge branch 'app-paths' into audit-imageview
freakboy3742 Jun 7, 2023
131ea35
100% coverage on Cocoa images and image views.
freakboy3742 Jun 7, 2023
16cce2a
100% coverage on iOS.
freakboy3742 Jun 7, 2023
018ad33
Remove support for loading images from URLs.
freakboy3742 Jun 7, 2023
33965e1
Add future annotations to Image.
freakboy3742 Jun 7, 2023
f1b924f
100% coverage for Android.
freakboy3742 Jun 7, 2023
1537aeb
Initial GTK and Winforms implementations.
freakboy3742 Jun 7, 2023
20d79af
Pin Python and Pillow version to something Android can support.
freakboy3742 Jun 8, 2023
65a0258
100% coverage for winforms.
freakboy3742 Jun 8, 2023
f54bd0b
Return the literal aspect ratio, not a boolean.
freakboy3742 Jun 8, 2023
40d9b0a
100% coverage for GTK Imageview.
freakboy3742 Jun 8, 2023
ccad743
Final docs tweaks.
freakboy3742 Jun 8, 2023
6ba9f03
Enable background color tests, and correct iOS, Android and Cocoa.
freakboy3742 Jun 8, 2023
56775f0
Fill out support for saving files.
freakboy3742 Jun 8, 2023
1c71067
Correct GTK API naming.
freakboy3742 Jun 8, 2023
2dffe06
Ensure the file type is honored on Winforms.
freakboy3742 Jun 8, 2023
8f4d5c0
Correct a cleanup problem on Winforms.
freakboy3742 Jun 8, 2023
da9ce1a
Merge branch 'main' into audit-imageview
freakboy3742 Jun 8, 2023
2bc7b12
Merge branch 'main' into audit-imageview
mhsmith Jun 15, 2023
d92960c
Apply suggestions from code review
freakboy3742 Jun 15, 2023
c201ec6
Add error handling for bad images.
freakboy3742 Jun 15, 2023
f03f7b9
Misc documentation cleanups.
freakboy3742 Jun 15, 2023
72a72b1
Make test resilient for Windows paths.
freakboy3742 Jun 16, 2023
4dfefd2
Use a different constructor to avoid iOS memory issues.
freakboy3742 Jun 16, 2023
809607f
More tweaks to avoid segfaults.
freakboy3742 Jun 16, 2023
1fdf609
Add scaling tests to example app
mhsmith Jun 21, 2023
a4b5a3d
Clarify that image format support is for loading and saving.
freakboy3742 Jun 22, 2023
f558ab8
Clarify types and exception docstrings.
freakboy3742 Jun 22, 2023
8a7d0dc
Merge branch 'main' into audit-imageview
freakboy3742 Jun 22, 2023
a5b47d1
Clarify interpretation of image sizing and Pack handling of images.
freakboy3742 Jun 22, 2023
8b582f7
Merge branch 'main' into audit-imageview
freakboy3742 Jun 22, 2023
abfb125
Corrected Pack when a flex child has a minimum size exceeding the fle…
freakboy3742 Jun 23, 2023
c259379
Clarify the use of object-fit:contain.
freakboy3742 Jun 23, 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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,10 @@ jobs:
uses: actions/setup-python@v4.6.1
if: ${{ matrix.setup-python }}
with:
# We're not using 3.11 yet, because the testbed's ProxyEventLoop isn't
# compatible with it (https://github.com/beeware/toga/issues/1982).
# We're not using Python 3.11 yet, because:
# * The testbed's ProxyEventLoop has some problems with it
# (https://github.com/beeware/toga/issues/1982).
# * It doesn't have an Android build of Pillow yet.
python-version: "3.10"
- name: Install dependencies
run: |
Expand Down
37 changes: 28 additions & 9 deletions android/src/toga_android/images.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
from .libs.android.graphics import BitmapFactory
from pathlib import Path

from java.io import FileOutputStream

from .libs.android.graphics import Bitmap, BitmapFactory


class Image:
def __init__(self, interface, path=None, url=None, data=None):
def __init__(self, interface, path=None, data=None):
self.interface = interface
self.path = path
self.url = url

if path:
self.native = BitmapFactory.decodeFile(str(path))
elif url:
# Android BitmapFactory nor ImageView provide a convenient async way to fetch images by URL
self.native = None
elif data:
else:
self.native = BitmapFactory.decodeByteArray(data, 0, len(data))

def get_width(self):
return self.native.getWidth()

def get_height(self):
return self.native.getHeight()

def save(self, path):
self.interface.factory.not_implemented("Image.save()")
path = Path(path)
try:
format = {
".jpg": Bitmap.CompressFormat.JPEG,
".jpeg": Bitmap.CompressFormat.JPEG,
".png": Bitmap.CompressFormat.PNG,
}[path.suffix.lower()]
str_path = str(path)
except KeyError:
raise ValueError(f"Don't know how to save image of type {path.suffix!r}")

out = FileOutputStream(str_path)
self.native.compress(format, 90, out)
out.flush()
out.close()
1 change: 1 addition & 0 deletions android/src/toga_android/libs/android/graphics/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rubicon.java import JavaClass

Bitmap = JavaClass("android/graphics/Bitmap")
BitmapFactory = JavaClass("android/graphics/BitmapFactory")
Color = JavaClass("android/graphics/Color")
DashPathEffect = JavaClass("android/graphics/DashPathEffect")
Expand Down
28 changes: 27 additions & 1 deletion android/src/toga_android/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
from toga.widgets.imageview import rehint_imageview

from ..libs.android.widget import ImageView as A_ImageView
from .base import Widget


class ImageView(Widget):
def create(self):
self.native = A_ImageView(self._native_activity)
self.native.setAdjustViewBounds(True)

def set_background_color(self, value):
self.set_background_simple(value)

def set_image(self, image):
if image and image._impl.native:
if image:
self.native.setImageBitmap(image._impl.native)
else:
self.native.setImageDrawable(None)

def rehint(self):
# User specified sizes are in "pixels", which is DP;
# we need to convert all sizes into SP.
dpi = self.native.getContext().getResources().getDisplayMetrics().densityDpi
# Toga needs to know how the current DPI compares to the platform default,
# which is 160: https://developer.android.com/training/multiscreen/screendensities
scale = float(dpi) / 160

width, height, aspect_ratio = rehint_imageview(
image=self.interface.image, style=self.interface.style, scale=scale
)
self.interface.intrinsic.width = width
self.interface.intrinsic.height = height
if aspect_ratio is not None:
self.native.setScaleType(A_ImageView.ScaleType.FIT_CENTER)
else:
self.native.setScaleType(A_ImageView.ScaleType.FIT_XY)
14 changes: 14 additions & 0 deletions android/tests_backend/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from android.graphics import Bitmap

from .probe import BaseProbe


class ImageProbe(BaseProbe):
def __init__(self, app, image):
super().__init__()
self.app = app
self.image = image
assert isinstance(self.image._impl.native, Bitmap)

def supports_extension(self, extension):
return extension.lower() in {".jpg", ".jpeg", ".png"}
11 changes: 11 additions & 0 deletions android/tests_backend/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from android.widget import ImageView

from .base import SimpleProbe


class ImageViewProbe(SimpleProbe):
native_class = ImageView

@property
def preserve_aspect_ratio(self):
return self.native.getScaleType() == ImageView.ScaleType.FIT_CENTER
1 change: 1 addition & 0 deletions changes/1956.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ImageView widget now has 100% test coverage, and complete API documentation.
1 change: 1 addition & 0 deletions changes/1956.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Images and ImageViews now longer support loading images from URLs. If you need to display an image from a URL, use a background task to obtain the image data asynchronously, then create the Image and/or set the ImageView on the completion of the asychronous load.
freakboy3742 marked this conversation as resolved.
Show resolved Hide resolved
49 changes: 19 additions & 30 deletions cocoa/src/toga_cocoa/images.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from pathlib import Path

from toga_cocoa.libs import (
NSURL,
NSBitmapImageFileType,
NSBitmapImageRep,
NSData,
Expand All @@ -10,45 +9,35 @@


class Image:
def __init__(self, interface, path=None, url=None, data=None):
def __init__(self, interface, path=None, data=None):
self.interface = interface
self.path = path
self.url = url

if path:
self.native = NSImage.alloc().initWithContentsOfFile(str(path))
elif url:
self.native = NSImage.alloc().initByReferencingURL(
NSURL.URLWithString_(url)
)
elif data:
if isinstance(data, NSData):
nsdata = data
else:
nsdata = NSData.dataWithBytes(data, length=len(data))

else:
nsdata = NSData.dataWithBytes(data, length=len(data))
self.native = NSImage.alloc().initWithData(nsdata)

def get_width(self):
return self.native.size.width

def get_height(self):
return self.native.size.height

def save(self, path):
path = Path(path)
try:
if path.suffix == "":
# If no suffix is provided in the filename, default to PNG,
# and append that suffix to the filename.
str_path = str(path) + ".png"
filetype = NSBitmapImageFileType.PNG
else:
filetype = {
".jpg": NSBitmapImageFileType.JPEG,
".jpeg": NSBitmapImageFileType.JPEG,
".png": NSBitmapImageFileType.PNG,
".gif": NSBitmapImageFileType.GIF,
".bmp": NSBitmapImageFileType.BMP,
".tiff": NSBitmapImageFileType.TIFF,
}[path.suffix.lower()]
str_path = str(path)
filetype = {
".jpg": NSBitmapImageFileType.JPEG,
".jpeg": NSBitmapImageFileType.JPEG,
".png": NSBitmapImageFileType.PNG,
".gif": NSBitmapImageFileType.GIF,
".bmp": NSBitmapImageFileType.BMP,
".tiff": NSBitmapImageFileType.TIFF,
}[path.suffix.lower()]
str_path = str(path)
except KeyError:
raise ValueError(f"Don't know how to save image of type {path.suffix}")
raise ValueError(f"Don't know how to save image of type {path.suffix!r}")

bitmapData = NSBitmapImageRep.representationOfImageRepsInArray(
self.native.representations,
Expand Down
2 changes: 1 addition & 1 deletion cocoa/src/toga_cocoa/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def set_enabled(self, value):
# APPLICATOR

def set_bounds(self, x, y, width, height):
# print("SET BOUNDS ON", self.interface, x, y, width, height)
# print(f"SET BOUNDS ON {self.interface} {width}x{height} @ ({x},{y})")
self.constraints.update(x, y, width, height)

def set_alignment(self, alignment):
Expand Down
30 changes: 16 additions & 14 deletions cocoa/src/toga_cocoa/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from toga.widgets.imageview import rehint_imageview
from toga_cocoa.libs import (
NSImage,
NSImageAlignment,
NSImageFrameNone,
NSImageScaleAxesIndependently,
NSImageScaleProportionallyUpOrDown,
NSImageView,
NSSize,
)

from .base import Widget
Expand All @@ -14,26 +14,28 @@ class ImageView(Widget):
def create(self):
self.native = NSImageView.alloc().init()

# self._impl.imageFrameStyle = NSImageFrameGrayBezel
# self.native.imageFrameStyle = NSImageFrameGrayBezel
self.native.imageFrameStyle = NSImageFrameNone
self.native.imageAlignment = NSImageAlignment.Center.value
self.native.imageScaling = NSImageScaleProportionallyUpOrDown
self.native.imageScaling = NSImageScaleAxesIndependently

# Add the layout constraints
self.add_constraints()

def set_image(self, image):
if image:
self.native.image = self.interface._image._impl.native
self.native.image = self.interface.image._impl.native
else:
width = 0
height = 0
if self.interface.style.width:
width = self.interface.style.width
if self.interface.style.height:
height = self.interface.style.height

self.native.image = NSImage.alloc().initWithSize(NSSize(width, height))
self.native.image = None

def rehint(self):
pass
width, height, aspect_ratio = rehint_imageview(
image=self.interface.image,
style=self.interface.style,
)
self.interface.intrinsic.width = width
self.interface.intrinsic.height = height
if aspect_ratio is not None:
self.native.imageScaling = NSImageScaleProportionallyUpOrDown
else:
self.native.imageScaling = NSImageScaleAxesIndependently
14 changes: 14 additions & 0 deletions cocoa/tests_backend/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from toga_cocoa.libs import NSImage

from .probe import BaseProbe


class ImageProbe(BaseProbe):
def __init__(self, app, image):
super().__init__()
self.app = app
self.image = image
assert isinstance(self.image._impl.native, NSImage)

def supports_extension(self, extension):
return extension.lower() in {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff"}
11 changes: 11 additions & 0 deletions cocoa/tests_backend/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from toga_cocoa.libs import NSImageScaleProportionallyUpOrDown, NSImageView

from .base import SimpleProbe


class ImageViewProbe(SimpleProbe):
native_class = NSImageView

@property
def preserve_aspect_ratio(self):
return self.native.imageScaling == NSImageScaleProportionallyUpOrDown
66 changes: 35 additions & 31 deletions core/src/toga/images.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,61 @@
import pathlib
import warnings
from __future__ import annotations

from pathlib import Path

import toga
from toga.platform import get_platform_factory


class Image:
"""A representation of graphical content.
def __init__(
self,
path: str | None | Path = None,
*,
data: bytes | None = None,
):
"""Create a new image.

:param path: Path to the image. Allowed values can be local file
(relative or absolute path) or URL (HTTP or HTTPS). Relative paths
will be interpreted relative to the application module directory.
:param data: A bytes object with the contents of an image in a supported
format.
"""
An image must be provided either a ``path`` or ``data``, but not both.

def __init__(self, path=None, *, data=None):
:param path: Path to the image. This can be an absolute path to an image
file, or a path relative to the file that describes the App class.
Can be specified as a string, or as a :any:`pathlib.Path` object.
:param data: A bytes object with the contents of an image in a supported
format.
"""
if path is None and data is None:
raise ValueError("Either path or data must be set.")
if path is not None and data is not None:
raise ValueError("Only either path or data can be set.")

if path:
if isinstance(path, pathlib.Path):
self.path = path
elif path.startswith("http://") or path.startswith("https://"):
if path is not None:
if isinstance(path, Path):
self.path = path
else:
self.path = pathlib.Path(path)
self.path = Path(path)
self.data = None
else:
self.path = None
self.data = data
self.data = data

self.factory = get_platform_factory()
if self.data is not None:
self._impl = self.factory.Image(interface=self, data=self.data)
elif isinstance(self.path, pathlib.Path):
full_path = toga.App.app.paths.app / self.path
if not full_path.exists():
raise FileNotFoundError(
"Image file {full_path!r} does not exist".format(
full_path=full_path
)
)
self._impl = self.factory.Image(interface=self, path=full_path)
else:
self._impl = self.factory.Image(interface=self, url=self.path)
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 width(self) -> int:
"""The width of the image, in pixels."""
return self._impl.get_width()

def bind(self, factory=None):
warnings.warn(
"Icons no longer need to be explicitly bound.", DeprecationWarning
)
return self._impl
@property
def height(self) -> int:
"""The height of the image, in pixels."""
return self._impl.get_height()

def save(self, path):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def save(self, path):
def save(self, path: str | Path):

"""Save image to given path.
Expand Down
Loading