Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
include:
- experimental: false

- python-version: "3.12-dev"
experimental: true
# - python-version: "3.13-dev"
# experimental: true

steps:
- name: Checkout
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ jobs:
ci:
uses: ./.github/workflows/ci.yml

docs:
name: Verify Docs Build
uses: beeware/.github/.github/workflows/docs-build-verify.yml@main
secrets: inherit
with:
project-name: "toga-chart"
project-version: ${{ github.ref_name }}

release:
name: Create Release
needs: ci
needs: [ ci, docs ]
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down Expand Up @@ -54,4 +62,4 @@ jobs:
- name: Publish release to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository_url: https://test.pypi.org/legacy/
repository-url: https://test.pypi.org/legacy/
1 change: 1 addition & 0 deletions changes/24.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The requirements of ``toga-chart`` were modified so that toga-chart is only dependent on ``toga-core``, rather than the ``toga`` meta-package. This makes it possible to install ``toga-chart`` on Android, as the meta-package no longer attempts to install the ``toga-gtk`` backend on Android; but it requires that end-users explicitly specify ``toga`` or an explicit backend in their own app requirements.
2 changes: 1 addition & 1 deletion examples/chart/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def startup(self):
self.set_data()

# Set up main window
self.main_window = toga.MainWindow(title=self.name)
self.main_window = toga.MainWindow()

self.chart = toga_chart.Chart(style=Pack(flex=1), on_draw=self.draw_chart)

Expand Down
5 changes: 3 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ classifiers =
license = New BSD
license_files =
LICENSE
description = "A Toga matplotlib backend."
description = A Toga matplotlib backend.
long_description = file: README.rst
long_description_content_type = text/x-rst
keywords=
Expand All @@ -41,7 +41,7 @@ include_package_data = True
package_dir=
= src
install_requires =
toga >= 0.3.1
toga-core >= 0.4.0
matplotlib >= 3.0.3

[options.packages.find]
Expand All @@ -53,6 +53,7 @@ dev =
pytest == 7.4.3
setuptools_scm[toml] == 8.0.4
tox == 4.11.3
toga-dummy >= 0.4.0
docs =
furo == 2023.9.10
pyenchant == 3.2.2
Expand Down
179 changes: 79 additions & 100 deletions src/toga_chart/chart.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import math
import sys

from matplotlib.backend_bases import FigureCanvasBase, RendererBase
from matplotlib.backend_bases import RendererBase
from matplotlib.figure import Figure
from matplotlib.path import Path
from matplotlib.transforms import Affine2D
Expand All @@ -12,36 +12,52 @@


class Chart(Widget):
"""Create new chart.

Args:
id (str): An identifier for this widget.
style (:obj:`Style`): An optional style object. If no
style is provided then a new one will be created for the widget.
on_resize (:obj:`callable`): Handler to invoke when the chart is resized.
The default resize handler will draw the chart on every resize;
generally, you won't need to override this default behavior.
on_draw (:obj:`callable`): Handler to invoke when the chart needs to be
drawn.
factory (:obj:`module`): A python module that is capable to return a
implementation of this class with the same name. (optional &
normally not needed)
"""

def __init__(self, id=None, style=None, on_resize=None, on_draw=None, factory=None):
def __init__(
self,
id: str = None,
style=None,
on_resize: callable = None,
on_draw: callable = None,
):
"""Create a new matplotlib chart.

:param id: An identifier for this widget.
:param style: An optional style object. If no style is provided then a new one
will be created for the widget.
:param on_resize: Handler to invoke when the chart is resized. The default
resize handler will draw the chart on every resize; generally, you won't
need to override this default behavior.
:param on_draw: Handler to invoke when the chart needs to be drawn. This
performs the matplotlib drawing operations that will be displayed on the
chart.
"""
self.on_draw = on_draw
if on_resize is None:
on_resize = self._on_resize

self.canvas = Canvas(style=style, on_resize=on_resize, factory=factory)
# The Chart widget that the user interacts with is a subclass of Widget, not
# Canvas; this subclass acts as a facade over the underlying Canvas
# implementation (mostly so that the redraw() method of the Chart is independent
# of the Canvas redraw() method). The _impl of the Chart is set to the Canvas
# _impl so that functionally, the widget behaves as a Canvas.
self.canvas = Canvas(style=style, on_resize=on_resize)

super().__init__(id=id, style=style)

super().__init__(id=id, style=style, factory=factory)
self._impl = self.canvas._impl

def _set_app(self, app):
@Widget.app.setter
def app(self, app):
# Invoke the superclass property setter
Widget.app.fset(self, app)
# Point the canvas to the same app
self.canvas.app = app

def _set_window(self, window):
@Widget.window.setter
def window(self, window):
# Invoke the superclass property setter
Widget.window.fset(self, window)
# Point the canvas to the same window
self.canvas.window = window

@property
Expand All @@ -52,22 +68,19 @@ def layout(self):
def layout(self, value):
self.canvas.layout = value

def _draw(self, figure):
"""Draws the matplotlib figure onto the canvas
def _draw(self, figure: Figure):
"""Draw the matplotlib figure onto the canvas.

Args:
figure (figure): matplotlib figure to draw
:param figure: The matplotlib figure to draw
"""
l, b, w, h = figure.bbox.bounds
matplotlib_canvas = MatplotlibCanvasProxy(figure=figure, canvas=self.canvas)
renderer = ChartRenderer(matplotlib_canvas, w, h)
renderer = ChartRenderer(self.canvas, w, h)

# Invoke the on_draw handler (if present).
# Invoke the on_draw handler.
# This is where the user adds the matplotlib draw instructions
# to construct the chart, so it needs to happen before the
# figure is rendered onto the canvas.
if self.on_draw:
self.on_draw(self, figure=figure)
self.on_draw(figure=figure)

figure.draw(renderer)

Expand All @@ -79,66 +92,32 @@ def redraw(self):
# 100 is the default DPI for figure at time of writing.
dpi = 100
figure = Figure(
figsize=(self.layout.content_width / dpi, self.layout.content_height / dpi)
figsize=(
self.layout.content_width / dpi,
self.layout.content_height / dpi,
),
)
self._draw(figure)

@property
def on_draw(self):
"""The handler to invoke when the canvas needs to be drawn.

Returns:
The handler that is invoked on canvas draw.
"""
def on_draw(self) -> callable:
"""The handler to invoke when the canvas needs to be drawn."""
return self._on_draw

@on_draw.setter
def on_draw(self, handler):
"""Set the handler to invoke when the canvas is drawn.

Args:
handler (:obj:`callable`): The handler to invoke when the canvas is drawn.
"""
def on_draw(self, handler: callable):
self._on_draw = wrapped_handler(self, handler)


class MatplotlibCanvasProxy(FigureCanvasBase):
def __init__(self, figure, canvas: Canvas):
super().__init__(figure)
self.canvas = canvas

def fill(self, color):
return self.canvas.fill(color=color)

def stroke(self, color, line_width, line_dash):
return self.canvas.stroke(
color=color, line_width=line_width, line_dash=line_dash
)

def measure_text(self, text, font):
return self.canvas.measure_text(text=text, font=font)

def translate(self, tx, ty):
return self.canvas.translate(tx, ty)

def rotate(self, radians):
return self.canvas.rotate(radians)

def reset_transform(self):
return self.canvas.reset_transform()


class ChartRenderer(RendererBase):
"""
The renderer handles drawing/rendering operations.

Args:
canvas (:obj:`Canvas`): canvas to render onto
width (int): width of canvas
height (int): height of canvas
"""
def __init__(self, canvas: Canvas, width: int, height: int):
"""
The matplotlib handler for drawing/rendering operations.

def __init__(self, canvas, width, height):
:param canvas: The canvas to render onto
:param width: Width of canvas
:param height: height of canvas
"""
self.width = width
self.height = height
self._canvas = canvas
Expand All @@ -157,25 +136,30 @@ def draw_path(self, gc, path, transform, rgbFace=None):
color = parse_color(rgba(r * 255, g * 255, b * 255, a))

if rgbFace is not None:
stroke_fill_context = self._canvas.fill(color=color)
stroke_fill_context = self._canvas.context.Fill(color=color)
else:
offset, sequence = gc.get_dashes()
stroke_fill_context = self._canvas.stroke(
color=color, line_width=gc.get_linewidth(), line_dash=sequence
stroke_fill_context = self._canvas.context.Stroke(
color=color,
line_width=gc.get_linewidth(),
line_dash=sequence,
)

transform = transform + Affine2D().scale(1.0, -1.0).translate(0.0, self.height)

with stroke_fill_context as context:
with context.context() as path_segments:
with context.Context() as path_segments:
for points, code in path.iter_segments(transform):
if code == Path.MOVETO:
path_segments.move_to(points[0], points[1])
elif code == Path.LINETO:
path_segments.line_to(points[0], points[1])
elif code == Path.CURVE3:
path_segments.quadratic_curve_to(
points[0], points[1], points[2], points[3]
points[0],
points[1],
points[2],
points[3],
)
elif code == Path.CURVE4:
path_segments.bezier_curve_to(
Expand All @@ -187,7 +171,7 @@ def draw_path(self, gc, path, transform, rgbFace=None):
points[5],
)
elif code == Path.CLOSEPOLY:
path_segments.closed_path(points[0], points[1])
path_segments.ClosedPath(points[0], points[1])

def draw_image(self, gc, x, y, im):
pass
Expand Down Expand Up @@ -217,12 +201,14 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
gc.set_linewidth(0.75)
self.draw_path(gc, path, transform, rgbFace=color)
else:
self._canvas.translate(x, y)
self._canvas.rotate(-math.radians(angle))
with self._canvas.fill(color=self.to_toga_color(*gc.get_rgb())) as fill:
self._canvas.context.translate(x, y)
self._canvas.context.rotate(-math.radians(angle))
with self._canvas.context.Fill(
color=self.to_toga_color(*gc.get_rgb())
) as fill:
font = self.get_font(prop)
fill.write_text(s, x=0, y=0, font=font)
self._canvas.reset_transform()
self._canvas.context.reset_transform()

def flipy(self):
return True
Expand All @@ -231,23 +217,16 @@ def get_canvas_width_height(self):
return self.width, self.height

def get_text_width_height_descent(self, s, prop, ismath):
"""
get the width and height in display coords of the string s
with FontPropertry prop
"""Get the width and height in display coords of the string s
with FontProperty prop
"""
font = self.get_font(prop)
w, h = self._canvas.measure_text(s, font)
return w, h, 1

def get_font(self, prop):
if prop.get_family()[0] == SANS_SERIF:
font_family = SANS_SERIF
elif prop.get_family()[0] == CURSIVE:
font_family = CURSIVE
elif prop.get_family()[0] == FANTASY:
font_family = FANTASY
elif prop.get_family()[0] == MONOSPACE:
font_family = MONOSPACE
if prop.get_family()[0] in {SANS_SERIF, CURSIVE, FANTASY, MONOSPACE}:
font_family = prop.get_family()[0]
else:
font_family = SERIF

Expand Down