diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6336753..0ec1166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bd106b..e9de341 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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/ diff --git a/changes/24.bugfix.rst b/changes/24.bugfix.rst new file mode 100644 index 0000000..b357145 --- /dev/null +++ b/changes/24.bugfix.rst @@ -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. diff --git a/examples/chart/app.py b/examples/chart/app.py index b6a698e..644ab04 100644 --- a/examples/chart/app.py +++ b/examples/chart/app.py @@ -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) diff --git a/setup.cfg b/setup.cfg index ab378a5..72bcceb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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= @@ -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] @@ -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 diff --git a/src/toga_chart/chart.py b/src/toga_chart/chart.py index 20b8b9b..6adaeba 100644 --- a/src/toga_chart/chart.py +++ b/src/toga_chart/chart.py @@ -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 @@ -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 @@ -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) @@ -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 @@ -157,17 +136,19 @@ 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]) @@ -175,7 +156,10 @@ def draw_path(self, gc, path, transform, rgbFace=None): 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( @@ -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 @@ -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 @@ -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