Skip to content

Commit 3a4a810

Browse files
corranwebsterjohnzhou721HalfWhitt
authored
Add drawing bitmap images to Canvas (#4047)
Co-authored-by: John <[email protected]> Co-authored-by: Charles Whittington <[email protected]>
1 parent 97cd94d commit 3a4a810

File tree

18 files changed

+336
-42
lines changed

18 files changed

+336
-42
lines changed

android/src/toga_android/widgets/canvas.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,19 @@ def _text_paint(self, font):
248248
paint.setTextSize(self.scale_out(font.size()))
249249
return paint
250250

251+
# Bitmaps
252+
def draw_image(self, image, x, y, width, height, canvas, **kwargs):
253+
canvas.save()
254+
canvas.translate(x, y)
255+
canvas.scale(width / image.width, height / image.height)
256+
canvas.drawBitmap(
257+
image._impl.native,
258+
0,
259+
0,
260+
None,
261+
)
262+
canvas.restore()
263+
251264
def get_image_data(self):
252265
bitmap = Bitmap.createBitmap(
253266
self.native.getWidth(), self.native.getHeight(), Bitmap.Config.ARGB_8888

changes/995.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Canvas contexts now support drawing bitmap images with a ``draw_image()`` method, similar to the equivalent HTML Canvas API.

cocoa/src/toga_cocoa/libs/core_graphics.py

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,37 @@ class CGAffineTransform(Structure):
4242
core_graphics.CGAffineTransformMakeScale.restype = CGAffineTransform
4343
core_graphics.CGAffineTransformMakeScale.argtypes = [CGFloat, CGFloat]
4444

45+
######################################################################
46+
# CGImage.h
47+
48+
CGImageRef = c_void_p
49+
register_preferred_encoding(b"^{CGImage=}", CGImageRef)
50+
51+
core_graphics.CGImageGetWidth.argtypes = [CGImageRef]
52+
core_graphics.CGImageGetWidth.restype = c_size_t
53+
54+
core_graphics.CGImageGetHeight.argtypes = [CGImageRef]
55+
core_graphics.CGImageGetHeight.restype = c_size_t
56+
57+
kCGImageAlphaNone = 0
58+
kCGImageAlphaPremultipliedLast = 1
59+
kCGImageAlphaPremultipliedFirst = 2
60+
kCGImageAlphaLast = 3
61+
kCGImageAlphaFirst = 4
62+
kCGImageAlphaNoneSkipLast = 5
63+
kCGImageAlphaNoneSkipFirst = 6
64+
kCGImageAlphaOnly = 7
65+
66+
kCGBitmapAlphaInfoMask = 0x1F
67+
kCGBitmapFloatComponents = 1 << 8
68+
69+
kCGBitmapByteOrderMask = 0x7000
70+
kCGBitmapByteOrderDefault = 0 << 12
71+
kCGBitmapByteOrder16Little = 1 << 12
72+
kCGBitmapByteOrder32Little = 2 << 12
73+
kCGBitmapByteOrder16Big = 3 << 12
74+
kCGBitmapByteOrder32Big = 4 << 12
75+
4576
######################################################################
4677
# CGContext.h
4778
CGContextRef = c_void_p
@@ -170,6 +201,8 @@ class CGAffineTransform(Structure):
170201
]
171202
core_graphics.CGContextTranslateCTM.restype = c_void_p
172203
core_graphics.CGContextTranslateCTM.argtypes = [CGContextRef, CGFloat, CGFloat]
204+
core_graphics.CGContextDrawImage.restype = c_void_p
205+
core_graphics.CGContextDrawImage.argtypes = [CGContextRef, CGRect, CGImageRef]
173206

174207
CGPathRef = c_void_p
175208
register_preferred_encoding(b"^{__CGPath=}", CGPathRef)
@@ -207,37 +240,6 @@ class CGEventRef(c_void_p):
207240
kCGScrollEventUnitPixel = 0
208241
kCGScrollEventUnitLine = 1
209242

210-
######################################################################
211-
# CGImage.h
212-
213-
CGImageRef = c_void_p
214-
register_preferred_encoding(b"^{CGImage=}", CGImageRef)
215-
216-
core_graphics.CGImageGetWidth.argtypes = [CGImageRef]
217-
core_graphics.CGImageGetWidth.restype = c_size_t
218-
219-
core_graphics.CGImageGetHeight.argtypes = [CGImageRef]
220-
core_graphics.CGImageGetHeight.restype = c_size_t
221-
222-
kCGImageAlphaNone = 0
223-
kCGImageAlphaPremultipliedLast = 1
224-
kCGImageAlphaPremultipliedFirst = 2
225-
kCGImageAlphaLast = 3
226-
kCGImageAlphaFirst = 4
227-
kCGImageAlphaNoneSkipLast = 5
228-
kCGImageAlphaNoneSkipFirst = 6
229-
kCGImageAlphaOnly = 7
230-
231-
kCGBitmapAlphaInfoMask = 0x1F
232-
kCGBitmapFloatComponents = 1 << 8
233-
234-
kCGBitmapByteOrderMask = 0x7000
235-
kCGBitmapByteOrderDefault = 0 << 12
236-
kCGBitmapByteOrder16Little = 1 << 12
237-
kCGBitmapByteOrder32Little = 2 << 12
238-
kCGBitmapByteOrder16Big = 3 << 12
239-
kCGBitmapByteOrder32Big = 4 << 12
240-
241243
######################################################################
242244
# CGDirectDisplay.h
243245

cocoa/src/toga_cocoa/widgets/canvas.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,29 @@ def write_text(self, text, x, y, font, baseline, line_height, **kwargs):
332332
NSRect(origin, NSSize(2**31 - 1, 0)), options=0, context=None
333333
)
334334

335+
# Images
336+
def draw_image(self, image, x, y, width, height, draw_context, **kwargs):
337+
ns_image = image._impl.native
338+
339+
# Have an NSImage, need a CGImage
340+
ns_rectangle = NSRect(NSPoint(0, 0), NSSize(width, height))
341+
cg_image = ns_image.CGImageForProposedRect(
342+
ns_rectangle, context=NSGraphicsContext.currentContext, hints=None
343+
)
344+
345+
# Quartz is flipped relative to data, so we:
346+
# - store the current state
347+
# - translate to bottom of the image
348+
# - flip vertical axis
349+
# - draw image at the new y-origin, with normal height
350+
# - restore state
351+
core_graphics.CGContextSaveGState(draw_context)
352+
core_graphics.CGContextTranslateCTM(draw_context, 0, y + height)
353+
core_graphics.CGContextScaleCTM(draw_context, 1.0, -1.0)
354+
rectangle = CGRectMake(x, 0, width, height)
355+
core_graphics.CGContextDrawImage(draw_context, rectangle, cg_image)
356+
core_graphics.CGContextRestoreGState(draw_context)
357+
335358
def get_image_data(self):
336359
bitmap = self.native.bitmapImageRepForCachingDisplayInRect(self.native.bounds)
337360
self.native.cacheDisplayInRect(self.native.bounds, toBitmapImageRep=bitmap)

core/src/toga/widgets/canvas/context.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
from toga.colors import BLACK, Color
1010
from toga.constants import Baseline, FillRule
1111
from toga.fonts import Font
12+
from toga.images import Image
1213

1314
from .drawingobject import (
1415
Arc,
1516
BeginPath,
1617
BezierCurveTo,
1718
ClosePath,
19+
DrawImage,
1820
DrawingObject,
1921
Ellipse,
2022
Fill,
@@ -387,6 +389,47 @@ def write_text(
387389
self.append(write_text)
388390
return write_text
389391

392+
###########################################################################
393+
# Bitmap drawing
394+
###########################################################################
395+
396+
def draw_image(
397+
self,
398+
image: Image,
399+
x: float = 0.0,
400+
y: float = 0.0,
401+
width: float | None = None,
402+
height: float | None = None,
403+
):
404+
"""Draw a Toga Image in the canvas context.
405+
406+
The x, y coordinates specify the location of the bottom-left corner
407+
of the image. If supplied, the width and height specify the size
408+
of the image when it is rendered in the context, the image will be
409+
scaled to fit.
410+
411+
Drawing of images is performed with the current transformation matrix
412+
applied, so the destination rectangle of the image will be rotated,
413+
scaled and translated by any transformations which are currently applied.
414+
415+
:param image: a Toga Image
416+
:param x: The x-coordinate of the bottom-left corner of the image when
417+
it is drawn.
418+
:param y: The y-coordinate of the bottom-left corner of the image when
419+
it is drawn.
420+
:param width: The width of the destination rectangle where the image
421+
will be drawn. The image will be scaled to fit the width. If the
422+
width is omitted, the natural width of the image will be used and
423+
no scaling will be done.
424+
:param height: The height of the destination rectangle where the image
425+
will be drawn. The image will be scaled to fit the height. If the
426+
height is omitted, the natural height of the image will be used and
427+
no scaling will be done.
428+
"""
429+
draw_image = DrawImage(image, x, y, width, height)
430+
self.append(draw_image)
431+
return draw_image
432+
390433
###########################################################################
391434
# Transformations
392435
###########################################################################

core/src/toga/widgets/canvas/drawingobject.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
SYSTEM_DEFAULT_FONT_SIZE,
1313
Font,
1414
)
15+
from toga.images import Image
1516

1617
if TYPE_CHECKING:
1718
from toga.colors import ColorT
@@ -418,6 +419,58 @@ def font(self, value: Font | None) -> None:
418419
self._font = value
419420

420421

422+
class DrawImage(DrawingObject):
423+
def __init__(
424+
self,
425+
image: Image,
426+
x: float = 0.0,
427+
y: float = 0.0,
428+
width: float | None = None,
429+
height: float | None = None,
430+
):
431+
self.image = image
432+
self.x = x
433+
self.y = y
434+
self.width = width
435+
self.height = height
436+
437+
def __repr__(self) -> str:
438+
return (
439+
f"{self.__class__.__name__}(image={self.image!r}, x={self.x}, y={self.y}, "
440+
f"width={self.width!r}, height={self.height})"
441+
)
442+
443+
def _draw(self, impl: Any, **kwargs: Any) -> None:
444+
impl.draw_image(
445+
self.image,
446+
self.x,
447+
self.y,
448+
self.width,
449+
self.height,
450+
**kwargs,
451+
)
452+
453+
@property
454+
def width(self) -> float:
455+
if self._width is None:
456+
return self.image.width
457+
return self._width
458+
459+
@width.setter
460+
def width(self, value: float | None):
461+
self._width = value
462+
463+
@property
464+
def height(self) -> float:
465+
if self._height is None:
466+
return self.image.height
467+
return self._height
468+
469+
@height.setter
470+
def height(self, value: float | None):
471+
self._height = value
472+
473+
421474
class Rotate(DrawingObject):
422475
def __init__(self, radians: float):
423476
self.radians = radians

core/tests/widgets/canvas/test_draw_operations.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from pathlib import Path
2+
13
import pytest
24

35
from toga.colors import REBECCAPURPLE, rgb
46
from toga.constants import Baseline, FillRule
57
from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font
8+
from toga.images import Image
69
from toga.widgets.canvas import Arc, Ellipse
710
from toga_dummy.utils import assert_action_performed
811

912
REBECCA_PURPLE_COLOR = rgb(102, 51, 153)
13+
ABSOLUTE_FILE_PATH = Path(__file__).parent.parent.parent / "resources/toga.png"
1014

1115

1216
def test_begin_path(widget):
@@ -713,6 +717,60 @@ def test_reset_transform(widget):
713717
]
714718

715719

720+
@pytest.mark.parametrize(
721+
"kwargs, args_repr, draw_kwargs",
722+
[
723+
# Defaults
724+
(
725+
{"x": 10, "y": 20},
726+
"x=10, y=20, width=32, height=32",
727+
{
728+
"x": 10,
729+
"y": 20,
730+
"width": 32,
731+
"height": 32,
732+
},
733+
),
734+
# Into rectangle
735+
(
736+
{
737+
"x": 10,
738+
"y": 20,
739+
"width": 100,
740+
"height": 50,
741+
},
742+
"x=10, y=20, width=100, height=50",
743+
{
744+
"x": 10,
745+
"y": 20,
746+
"width": 100,
747+
"height": 50,
748+
},
749+
),
750+
],
751+
)
752+
def test_draw_image(widget, kwargs, args_repr, draw_kwargs):
753+
"""An image can be drawn."""
754+
image = Image(ABSOLUTE_FILE_PATH)
755+
draw_op = widget.context.draw_image(image=image, **kwargs)
756+
757+
assert_action_performed(widget, "redraw")
758+
assert repr(draw_op) == f"DrawImage(image={image!r}, {args_repr})"
759+
760+
# The first and last instructions push/pull the root context, and can be ignored.
761+
draw_kwargs["image"] = image
762+
assert widget._impl.draw_instructions[1:-1] == [
763+
("draw_image", draw_kwargs),
764+
]
765+
766+
# All the attributes can be retrieved.
767+
assert draw_op.image == draw_kwargs["image"]
768+
assert draw_op.x == draw_kwargs["x"]
769+
assert draw_op.y == draw_kwargs["y"]
770+
assert draw_op.width == draw_kwargs["width"]
771+
assert draw_op.height == draw_kwargs["height"]
772+
773+
716774
@pytest.mark.parametrize("value", [True, False])
717775
def test_anticlockwise_deprecated(widget, value):
718776
"""The 'anticlockwise' parameter is deprecated."""

dummy/src/toga_dummy/widgets/canvas.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ def measure_text(self, text, font, line_height):
263263

264264
# Image
265265

266+
def draw_image(self, image, x, y, width, height, draw_instructions, **kwargs):
267+
"""Draw an Image into the context."""
268+
draw_instructions.append(
269+
(
270+
"draw_image",
271+
dict(image=image, x=x, y=y, width=width, height=height, **kwargs),
272+
)
273+
)
274+
266275
def get_image_data(self):
267276
"""Return the Toga logo as the "native" image."""
268277
self._action("get image data")

0 commit comments

Comments
 (0)