diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index 333cbf81f5..33bb53ece9 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -248,6 +248,19 @@ def _text_paint(self, font): paint.setTextSize(self.scale_out(font.size())) return paint + # Bitmaps + def draw_image(self, image, x, y, width, height, canvas, **kwargs): + canvas.save() + canvas.translate(x, y) + canvas.scale(width / image.width, height / image.height) + canvas.drawBitmap( + image._impl.native, + 0, + 0, + None, + ) + canvas.restore() + def get_image_data(self): bitmap = Bitmap.createBitmap( self.native.getWidth(), self.native.getHeight(), Bitmap.Config.ARGB_8888 diff --git a/changes/995.feature.md b/changes/995.feature.md new file mode 100644 index 0000000000..40a909805d --- /dev/null +++ b/changes/995.feature.md @@ -0,0 +1 @@ +Canvas contexts now support drawing bitmap images with a ``draw_image()`` method, similar to the equivalent HTML Canvas API. diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index ace469dcef..f082238103 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -42,6 +42,37 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +###################################################################### +# 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 +kCGImageAlphaLast = 3 +kCGImageAlphaFirst = 4 +kCGImageAlphaNoneSkipLast = 5 +kCGImageAlphaNoneSkipFirst = 6 +kCGImageAlphaOnly = 7 + +kCGBitmapAlphaInfoMask = 0x1F +kCGBitmapFloatComponents = 1 << 8 + +kCGBitmapByteOrderMask = 0x7000 +kCGBitmapByteOrderDefault = 0 << 12 +kCGBitmapByteOrder16Little = 1 << 12 +kCGBitmapByteOrder32Little = 2 << 12 +kCGBitmapByteOrder16Big = 3 << 12 +kCGBitmapByteOrder32Big = 4 << 12 + ###################################################################### # CGContext.h CGContextRef = c_void_p @@ -170,6 +201,8 @@ class CGAffineTransform(Structure): ] core_graphics.CGContextTranslateCTM.restype = c_void_p core_graphics.CGContextTranslateCTM.argtypes = [CGContextRef, CGFloat, CGFloat] +core_graphics.CGContextDrawImage.restype = c_void_p +core_graphics.CGContextDrawImage.argtypes = [CGContextRef, CGRect, CGImageRef] CGPathRef = c_void_p register_preferred_encoding(b"^{__CGPath=}", CGPathRef) @@ -207,37 +240,6 @@ class CGEventRef(c_void_p): kCGScrollEventUnitPixel = 0 kCGScrollEventUnitLine = 1 -###################################################################### -# 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 -kCGImageAlphaLast = 3 -kCGImageAlphaFirst = 4 -kCGImageAlphaNoneSkipLast = 5 -kCGImageAlphaNoneSkipFirst = 6 -kCGImageAlphaOnly = 7 - -kCGBitmapAlphaInfoMask = 0x1F -kCGBitmapFloatComponents = 1 << 8 - -kCGBitmapByteOrderMask = 0x7000 -kCGBitmapByteOrderDefault = 0 << 12 -kCGBitmapByteOrder16Little = 1 << 12 -kCGBitmapByteOrder32Little = 2 << 12 -kCGBitmapByteOrder16Big = 3 << 12 -kCGBitmapByteOrder32Big = 4 << 12 - ###################################################################### # CGDirectDisplay.h diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index ec3274cbfe..c3f5012b50 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -332,6 +332,29 @@ def write_text(self, text, x, y, font, baseline, line_height, **kwargs): NSRect(origin, NSSize(2**31 - 1, 0)), options=0, context=None ) + # Images + def draw_image(self, image, x, y, width, height, draw_context, **kwargs): + ns_image = image._impl.native + + # Have an NSImage, need a CGImage + ns_rectangle = NSRect(NSPoint(0, 0), NSSize(width, height)) + cg_image = ns_image.CGImageForProposedRect( + ns_rectangle, context=NSGraphicsContext.currentContext, hints=None + ) + + # Quartz is flipped relative to data, so we: + # - store the current state + # - translate to bottom of the image + # - flip vertical axis + # - draw image at the new y-origin, with normal height + # - restore state + core_graphics.CGContextSaveGState(draw_context) + core_graphics.CGContextTranslateCTM(draw_context, 0, y + height) + core_graphics.CGContextScaleCTM(draw_context, 1.0, -1.0) + rectangle = CGRectMake(x, 0, width, height) + core_graphics.CGContextDrawImage(draw_context, rectangle, cg_image) + core_graphics.CGContextRestoreGState(draw_context) + def get_image_data(self): bitmap = self.native.bitmapImageRepForCachingDisplayInRect(self.native.bounds) self.native.cacheDisplayInRect(self.native.bounds, toBitmapImageRep=bitmap) diff --git a/core/src/toga/widgets/canvas/context.py b/core/src/toga/widgets/canvas/context.py index fb5ef720be..f977b25c45 100644 --- a/core/src/toga/widgets/canvas/context.py +++ b/core/src/toga/widgets/canvas/context.py @@ -9,12 +9,14 @@ from toga.colors import BLACK, Color from toga.constants import Baseline, FillRule from toga.fonts import Font +from toga.images import Image from .drawingobject import ( Arc, BeginPath, BezierCurveTo, ClosePath, + DrawImage, DrawingObject, Ellipse, Fill, @@ -387,6 +389,47 @@ def write_text( self.append(write_text) return write_text + ########################################################################### + # Bitmap drawing + ########################################################################### + + def draw_image( + self, + image: Image, + x: float = 0.0, + y: float = 0.0, + width: float | None = None, + height: float | None = None, + ): + """Draw a Toga Image in the canvas context. + + The x, y coordinates specify the location of the bottom-left corner + of the image. If supplied, the width and height specify the size + of the image when it is rendered in the context, the image will be + scaled to fit. + + Drawing of images is performed with the current transformation matrix + applied, so the destination rectangle of the image will be rotated, + scaled and translated by any transformations which are currently applied. + + :param image: a Toga Image + :param x: The x-coordinate of the bottom-left corner of the image when + it is drawn. + :param y: The y-coordinate of the bottom-left corner of the image when + it is drawn. + :param width: The width of the destination rectangle where the image + will be drawn. The image will be scaled to fit the width. If the + width is omitted, the natural width of the image will be used and + no scaling will be done. + :param height: The height of the destination rectangle where the image + will be drawn. The image will be scaled to fit the height. If the + height is omitted, the natural height of the image will be used and + no scaling will be done. + """ + draw_image = DrawImage(image, x, y, width, height) + self.append(draw_image) + return draw_image + ########################################################################### # Transformations ########################################################################### diff --git a/core/src/toga/widgets/canvas/drawingobject.py b/core/src/toga/widgets/canvas/drawingobject.py index e3d6c0c9aa..b65a334452 100644 --- a/core/src/toga/widgets/canvas/drawingobject.py +++ b/core/src/toga/widgets/canvas/drawingobject.py @@ -12,6 +12,7 @@ SYSTEM_DEFAULT_FONT_SIZE, Font, ) +from toga.images import Image if TYPE_CHECKING: from toga.colors import ColorT @@ -418,6 +419,58 @@ def font(self, value: Font | None) -> None: self._font = value +class DrawImage(DrawingObject): + def __init__( + self, + image: Image, + x: float = 0.0, + y: float = 0.0, + width: float | None = None, + height: float | None = None, + ): + self.image = image + self.x = x + self.y = y + self.width = width + self.height = height + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(image={self.image!r}, x={self.x}, y={self.y}, " + f"width={self.width!r}, height={self.height})" + ) + + def _draw(self, impl: Any, **kwargs: Any) -> None: + impl.draw_image( + self.image, + self.x, + self.y, + self.width, + self.height, + **kwargs, + ) + + @property + def width(self) -> float: + if self._width is None: + return self.image.width + return self._width + + @width.setter + def width(self, value: float | None): + self._width = value + + @property + def height(self) -> float: + if self._height is None: + return self.image.height + return self._height + + @height.setter + def height(self, value: float | None): + self._height = value + + class Rotate(DrawingObject): def __init__(self, radians: float): self.radians = radians diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 99c5a7570f..e35750d435 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -1,12 +1,16 @@ +from pathlib import Path + import pytest from toga.colors import REBECCAPURPLE, rgb from toga.constants import Baseline, FillRule from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font +from toga.images import Image from toga.widgets.canvas import Arc, Ellipse from toga_dummy.utils import assert_action_performed REBECCA_PURPLE_COLOR = rgb(102, 51, 153) +ABSOLUTE_FILE_PATH = Path(__file__).parent.parent.parent / "resources/toga.png" def test_begin_path(widget): @@ -713,6 +717,60 @@ def test_reset_transform(widget): ] +@pytest.mark.parametrize( + "kwargs, args_repr, draw_kwargs", + [ + # Defaults + ( + {"x": 10, "y": 20}, + "x=10, y=20, width=32, height=32", + { + "x": 10, + "y": 20, + "width": 32, + "height": 32, + }, + ), + # Into rectangle + ( + { + "x": 10, + "y": 20, + "width": 100, + "height": 50, + }, + "x=10, y=20, width=100, height=50", + { + "x": 10, + "y": 20, + "width": 100, + "height": 50, + }, + ), + ], +) +def test_draw_image(widget, kwargs, args_repr, draw_kwargs): + """An image can be drawn.""" + image = Image(ABSOLUTE_FILE_PATH) + draw_op = widget.context.draw_image(image=image, **kwargs) + + assert_action_performed(widget, "redraw") + assert repr(draw_op) == f"DrawImage(image={image!r}, {args_repr})" + + # The first and last instructions push/pull the root context, and can be ignored. + draw_kwargs["image"] = image + assert widget._impl.draw_instructions[1:-1] == [ + ("draw_image", draw_kwargs), + ] + + # All the attributes can be retrieved. + assert draw_op.image == draw_kwargs["image"] + assert draw_op.x == draw_kwargs["x"] + assert draw_op.y == draw_kwargs["y"] + assert draw_op.width == draw_kwargs["width"] + assert draw_op.height == draw_kwargs["height"] + + @pytest.mark.parametrize("value", [True, False]) def test_anticlockwise_deprecated(widget, value): """The 'anticlockwise' parameter is deprecated.""" diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index 8392b46c01..19278498ff 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -263,6 +263,15 @@ def measure_text(self, text, font, line_height): # Image + def draw_image(self, image, x, y, width, height, draw_instructions, **kwargs): + """Draw an Image into the context.""" + draw_instructions.append( + ( + "draw_image", + dict(image=image, x=x, y=y, width=width, height=height, **kwargs), + ) + ) + def get_image_data(self): """Return the Toga logo as the "native" image.""" self._action("get image data") diff --git a/examples/canvas/canvas/app.py b/examples/canvas/canvas/app.py index f8e6ec87c8..564ececefe 100644 --- a/examples/canvas/canvas/app.py +++ b/examples/canvas/canvas/app.py @@ -33,6 +33,7 @@ SMILE = "smile" SEA = "sea" STAR = "star" +BEES_IMAGE = "bees" CONTINUOUS = "continuous" DASH_1_1 = "dash 1-1" @@ -45,6 +46,9 @@ def startup(self): # Set up main window self.main_window = toga.MainWindow(size=(750, 500)) + # Bitmap image for display. + self.image = toga.Image("resources/pride-brutus.png") + self.canvas = toga.Canvas( flex=1, on_resize=self.refresh_canvas, @@ -72,6 +76,7 @@ def startup(self): SMILE: self.draw_smile, SEA: self.draw_sea, STAR: self.draw_star, + BEES_IMAGE: self.draw_image, } self.dash_patterns = { CONTINUOUS: None, @@ -546,6 +551,14 @@ def draw_star(self, context, factor): self.y_middle - radius * math.cos(i * rotation_angle), ) + def draw_image(self, context, factor): + with context.Context() as ctx: + ctx.draw_image( + self.image, + self.x_middle - self.image.width / 2, + self.y_middle - self.image.height / 2, + ) + def draw_instructions(self, context, factor): text = """Instructions: 1. Use the controls to modify the image diff --git a/examples/canvas/canvas/resources/pride-brutus.png b/examples/canvas/canvas/resources/pride-brutus.png new file mode 100644 index 0000000000..64d811b17a Binary files /dev/null and b/examples/canvas/canvas/resources/pride-brutus.png differ diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 3511989302..914068ae5e 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -390,6 +390,26 @@ def measure_text(self, text, font, line_height): metrics.line_height * len(widths), ) + def draw_image(self, image, x, y, width, height, cairo_context): + # save old path, create a new path to draw in + old_path = cairo_context.copy_path() + cairo_context.new_path() + cairo_context.save() + + # apply translation and scale so source rectangle maps to destination rectangle + cairo_context.translate(x, y) + cairo_context.scale(width / image.width, height / image.height) + + # draw a filled rectangle with the pixmap as the source for the fill + cairo_context.rectangle(0, 0, image.width, image.height) + Gdk.cairo_set_source_pixbuf(cairo_context, image._impl.native, 0, 0) + cairo_context.fill() + + # restore the old path + cairo_context.restore() + cairo_context.new_path() + cairo_context.append_path(old_path) + def get_image_data(self): width, height = self._size() diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 285730cb86..5d9849fbbf 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -42,6 +42,15 @@ class CGAffineTransform(Structure): core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat] +###################################################################### +# CGImage.h + +CGImageRef = c_void_p +register_preferred_encoding(b"^{CGImage=}", CGImageRef) + +core_graphics.CGImageCreateWithImageInRect.argtypes = [CGImageRef, CGRect] +core_graphics.CGImageCreateWithImageInRect.restype = CGImageRef + ###################################################################### # CGContext.h CGContextRef = c_void_p @@ -170,6 +179,8 @@ class CGAffineTransform(Structure): ] core_graphics.CGContextTranslateCTM.restype = c_void_p core_graphics.CGContextTranslateCTM.argtypes = [CGContextRef, CGFloat, CGFloat] +core_graphics.CGContextDrawImage.restype = c_void_p +core_graphics.CGContextDrawImage.argtypes = [CGContextRef, CGRect, CGImageRef] CGPathRef = c_void_p register_preferred_encoding(b"^{__CGPath=}", CGPathRef) @@ -205,13 +216,3 @@ class CGAffineTransform(Structure): def CGRectMake(x, y, w, h): return CGRect(CGPoint(x, y), CGSize(w, h)) - - -###################################################################### -# CGImage.h - -CGImageRef = c_void_p -register_preferred_encoding(b"^{CGImage=}", CGImageRef) - -core_graphics.CGImageCreateWithImageInRect.argtypes = [CGImageRef, CGRect] -core_graphics.CGImageCreateWithImageInRect.restype = CGImageRef diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index a547eac0e3..54258e1d7f 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -316,6 +316,26 @@ def write_text(self, text, x, y, font, baseline, line_height, **kwargs): NSRect(origin, NSSize(2**31 - 1, 0)), options=0, context=None ) + # Images + def draw_image(self, image, x, y, width, height, draw_context, **kwargs): + ui_image = image._impl.native + + # Have an UIImage, need a CGImage + cg_image = ui_image.CGImage + + # Quartz is flipped relative to data, so we: + # - store the current state + # - translate to bottom of the image + # - flip vertical axis + # - draw image at the new y-origin, with normal height + # - restore state + core_graphics.CGContextSaveGState(draw_context) + core_graphics.CGContextTranslateCTM(draw_context, 0, y + height) + core_graphics.CGContextScaleCTM(draw_context, 1.0, -1.0) + rectangle = CGRectMake(x, 0, width, height) + core_graphics.CGContextDrawImage(draw_context, rectangle, cg_image) + core_graphics.CGContextRestoreGState(draw_context) + def get_image_data(self): renderer = UIGraphicsImageRenderer.alloc().initWithSize(self.native.bounds.size) diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index fb5be177b6..d61e3810c1 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -1,7 +1,7 @@ import logging from math import ceil, cos, degrees, sin -from PySide6.QtCore import QBuffer, QIODevice, QPointF, Qt +from PySide6.QtCore import QBuffer, QIODevice, QPointF, QRectF, Qt from PySide6.QtGui import ( QFontMetrics, QMouseEvent, @@ -330,6 +330,10 @@ def write_text( # reset state to how things were before translating draw_context.restore() + # Bitmap images + def draw_image(self, image, x, y, width, height, draw_context: QPainter, **kwargs): + draw_context.drawImage(QRectF(x, y, width, height), image._impl.native) + def get_image_data(self): pixmap = self.native.grab() buffer = QBuffer() diff --git a/testbed/src/testbed/resources/canvas/draw_image.png b/testbed/src/testbed/resources/canvas/draw_image.png new file mode 100644 index 0000000000..ab13ac8e78 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/draw_image.png differ diff --git a/testbed/src/testbed/resources/canvas/draw_image_in_rect.png b/testbed/src/testbed/resources/canvas/draw_image_in_rect.png new file mode 100644 index 0000000000..687b084a21 Binary files /dev/null and b/testbed/src/testbed/resources/canvas/draw_image_in_rect.png differ diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index c4962e61f9..3a7f1c224c 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -21,6 +21,7 @@ ) from toga.constants import Baseline, FillRule from toga.fonts import BOLD +from toga.images import Image as TogaImage from toga.style.pack import SYSTEM, Pack from .conftest import build_cleanup_test @@ -877,3 +878,30 @@ async def test_write_text_and_path(canvas, probe): await probe.redraw("Text and path should be drawn independently") assert_reference(probe, "write_text_and_path", 0.04) + + +async def test_draw_image_at_point(canvas, probe): + "Images can be drawn at a point." + + image = TogaImage("resources/sample.png") + canvas.context.begin_path() + canvas.context.draw_image(image, 10, 10) + + await probe.redraw("Image should be drawn") + assert_reference(probe, "draw_image", threshold=0.05) + + +async def test_draw_image_in_rect(canvas, probe): + "Images can be drawn in a rectangle." + + image = TogaImage("resources/sample.png") + canvas.context.begin_path() + canvas.context.translate(82, 46) + canvas.context.rotate(-pi / 6) + canvas.context.translate(-82, -46) + canvas.context.draw_image(image, 10, 10, 72, 144) + canvas.context.rect(10, 10, 72, 144) + canvas.context.stroke(REBECCAPURPLE) + + await probe.redraw("Image should be drawn") + assert_reference(probe, "draw_image_in_rect", threshold=0.05) diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index d373e4d40e..2492d79736 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -363,6 +363,12 @@ def measure_text(self, text, font, line_height): self._line_height(font, line_height) * len(sizes), ) + def draw_image(self, image, x, y, width, height, draw_context, **kwargs): + draw_context.graphics.ResetTransform() + draw_context.graphics.Transform = draw_context.matrix + draw_context.graphics.DrawImage(image._impl.native, x, y, width, height) + draw_context.graphics.ResetTransform() + def get_image_data(self): # Winforms backgrounds don't honor transparency, so the background that is # rendered to screen manually computes the blended color. However, we want the