Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
38b071c
Initial proof of concept of Canvas.draw_image()
corranwebster Dec 20, 2025
0188b2f
Add test for draw image.
corranwebster Dec 21, 2025
c60617d
Add simple tests, iOS backend.
corranwebster Dec 21, 2025
d43dc36
Add to changelog.
corranwebster Dec 21, 2025
7a708f8
Add dummy implementation.
corranwebster Dec 21, 2025
bae64af
Add core test for drawImage
corranwebster Dec 21, 2025
85f7691
Work in progress.
corranwebster Dec 22, 2025
1be95d1
Make draw in rect test more interesting - test transform of image.
corranwebster Jan 2, 2026
9105840
Fix core tests.
corranwebster Jan 2, 2026
db97a47
Fix coverage.
corranwebster Jan 2, 2026
97ba784
Add Qt implementation of draw_image().
corranwebster Jan 2, 2026
6e575a7
Add support for Android (untested).
corranwebster Jan 2, 2026
62f0ba5
Add winforms implementation.
corranwebster Jan 2, 2026
4dbf19b
Fixes for Android and windows - see if they work.
corranwebster Jan 2, 2026
600427f
Try some more fixes.
corranwebster Jan 2, 2026
37812a4
Implement Gtk backend; various attempted fixes for other backends.
corranwebster Jan 3, 2026
116e894
Fixes for Gtk, and Winforms, slight tweaking of docstrongs.
corranwebster Jan 3, 2026
49b1a90
fix for Gtk: try creating the image surface directly from the pixbuf …
corranwebster Jan 3, 2026
cb76ac7
The get_pixels() method returns a Python buffer, so more likely to work.
corranwebster Jan 3, 2026
d8e3cff
Use get_rowstride().
corranwebster Jan 3, 2026
66c9804
ImageSirface needs a writable buffer, so copy pixels into a bytearray.
corranwebster Jan 3, 2026
3c7a7fe
Remove old code.
corranwebster Jan 3, 2026
08f9d30
Need to convert BGR to RGB format in Gtk
corranwebster Jan 3, 2026
e4ceaeb
Fix typo.
corranwebster Jan 3, 2026
d19598f
Try a completely different approack for Gtk.
corranwebster Jan 3, 2026
217fb9d
Try doing transformation before setting the source.
corranwebster Jan 3, 2026
ce9a7a7
Scaled by inverse of what we need.
corranwebster Jan 3, 2026
7f77033
After transformation, we're essentially just drawing the image in its…
corranwebster Jan 3, 2026
1db15db
Clean-up, update changelog.
corranwebster Jan 3, 2026
93a843b
Update changes/995.feature.md
corranwebster Jan 5, 2026
e10ff4f
Make the image flip a little clearer in iOS and Cocoa backends.
corranwebster Jan 5, 2026
23d1157
Fix test docstring
HalfWhitt Jan 12, 2026
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
13 changes: 13 additions & 0 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions changes/995.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a draw_image() method to Canvas contexts using the API of the HTML Canvas draw_image() method.
64 changes: 33 additions & 31 deletions cocoa/src/toga_cocoa/libs/core_graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions cocoa/src/toga_cocoa/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 vertical middle of image
# - flip vertical axis
# - draw image with bottom edge at -height/2
# - restore state
core_graphics.CGContextSaveGState(draw_context)
core_graphics.CGContextTranslateCTM(draw_context, 0, y + height / 2)
core_graphics.CGContextScaleCTM(draw_context, 1.0, -1.0)
rectangle = CGRectMake(x, -height / 2, 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)
Expand Down
43 changes: 43 additions & 0 deletions core/src/toga/widgets/canvas/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
###########################################################################
Expand Down
53 changes: 53 additions & 0 deletions core/src/toga/widgets/canvas/drawingobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
SYSTEM_DEFAULT_FONT_SIZE,
Font,
)
from toga.images import Image

if TYPE_CHECKING:
from toga.colors import ColorT
Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions core/tests/widgets/canvas/test_draw_operations.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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):
"""A reset transform operation can be added."""
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."""
Expand Down
9 changes: 9 additions & 0 deletions dummy/src/toga_dummy/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading