Skip to content

Commit

Permalink
Merge pull request #54 from beeware/audit-cleanup
Browse files Browse the repository at this point in the history
Cleanup following toga.Canvas audit
  • Loading branch information
freakboy3742 authored Nov 3, 2023
2 parents e44c162 + bb840ec commit 3e531d2
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 108 deletions.
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

0 comments on commit 3e531d2

Please sign in to comment.