diff --git a/docs/reference/api/widgets/canvas.rst b/docs/reference/api/widgets/canvas.rst index 774878b322..d2d296abb9 100644 --- a/docs/reference/api/widgets/canvas.rst +++ b/docs/reference/api/widgets/canvas.rst @@ -15,16 +15,69 @@ The canvas is used for creating a blank widget that you can draw on. Usage ----- +Simple usage to draw a black circle on the screen using the arc drawing object: + .. code-block:: Python import toga + canvas = toga.Canvas(style=Pack(flex=1)) + box = toga.Box(children=[canvas]) + with canvas.fill() as fill: + fill.arc(50, 50, 15) + +More advanced usage for something like a vector drawing app where you would +want to modify the parameters of the drawing objects. Here we draw a black +circle and black rectangle. We then change the size of the circle, move the +rectangle, and finally delete the rectangle. + +.. code-block:: Python + + import toga + canvas = toga.Canvas(style=Pack(flex=1)) + box = toga.Box(children=[canvas]) + with canvas.fill() as fill: + arc1 = fill.arc(x=50, y=50, radius=15) + rect1 = fill.rect(x=50, y=50, width=15, height=15) + + arc1.x, arc1.y, arc1.radius = (25, 25, 5) + rect1.x = 75 + fill.remove(rect1) + +Use of drawing contexts, for example with a platformer game. Here you would +want to modify the x/y coordinate of a drawing context that draws each +character on the canvas. First, we create a hero context. Next, we create a +black circle and a black outlined rectangle for the hero's body. Finally, we +move the hero by 10 on the x-axis. + +.. code-block:: Python + + import toga + canvas = toga.Canvas(style=Pack(flex=1)) + box = toga.Box(children=[canvas]) + with canvas.context() as hero: + with hero.fill() as body: + body.arc(50, 50, 15) + with hero.stroke() as outline: + outline.rect(50, 50, 15, 15) + + hero.translate(10, 0) - canvas = toga.Canvas() Reference --------- +Main Interface +^^^^^^^^^^^^^^ + .. autoclass:: toga.widgets.canvas.Canvas :members: :undoc-members: :inherited-members: + :exclude-members: canvas, add_draw_obj + +Lower-Level Classes +^^^^^^^^^^^^^^^^^^^ + +.. automodule:: toga.widgets.canvas + :members: + :exclude-members: Canvas, add_draw_obj diff --git a/docs/tutorial/tutorial-4.rst b/docs/tutorial/tutorial-4.rst index 17591290df..daff79f211 100644 --- a/docs/tutorial/tutorial-4.rst +++ b/docs/tutorial/tutorial-4.rst @@ -12,22 +12,26 @@ One of the main capabilities needed to create many types of GUI applications is the ability to draw and manipulate lines, shapes, text, and other graphics. To do this in Toga, we use the Canvas Widget. -Utilizing the Canvas is easy as determining the drawing operations you want to -perform, placing them in a function, and then creating a new Canvas while -passing the function to the on_draw parameter. +Utilizing the Canvas is as easy as determining the drawing operations you want to +perform and then creating a new Canvas. All drawing objects that are created +with one of the drawing operations are returned so that they can be modified or +removed. 1. We first define the drawing operations we want to perform in a new function:: - def draw_tiberius(self, canvas, context): - self.canvas.set_context(context) - self.fill_head() + def draw_eyes(self): + with self.canvas.fill(color=WHITE) as eye_whites: + eye_whites.arc(58, 92, 15) + eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) -The function you want to draw with should also be defined to include canvas and -context arguments, and make use of the set_context method. +Notice that we also created and used a new fill context called eye_whites. The +"with" keyword that is used for the fill operation causes everything draw using +the context to be filled with a color. In this example we filled two circular +eyes with the color white. -2. Next we create a new Canvas, and pass in the draw_tiberius method:: +2. Next we create a new Canvas:: - self.canvas = toga.Canvas(on_draw=self.draw_tiberius) + self.canvas = toga.Canvas(style=Pack(flex=1)) That's all there is to! In this example we also add our canvas to the MainWindow through use of the Box Widget:: @@ -35,10 +39,10 @@ through use of the Box Widget:: box = toga.Box(children=[self.canvas]) self.main_window.content = box -You'll also notice in the full example below that some of the drawing operations -use the "with" keyword to utilize context managers including closed_path, fill, -and stroke. This reduces the repetition of commands while utilizing these basic -drawing capabilities. +You'll also notice in the full example below that the drawing operations utilize +contexts in addition to fill including context, closed_path, and stroke. This +reduces the repetition of commands as well as groups drawing operations so that +they can be modified together. .. image:: screenshots/tutorial-4.png @@ -47,7 +51,4 @@ Here's the source code .. literalinclude:: /../examples/tutorial4/tutorial/app.py :language: python -Although not shown in this tutorial, it is also possible to directly do simple -draw operations without passing in the on_draw callable function. - In this example, we see a new Toga widget - :class:`.Canvas`. diff --git a/examples/canvas/canvas/app.py b/examples/canvas/canvas/app.py index e0dec7ac46..b3ebd8f72c 100644 --- a/examples/canvas/canvas/app.py +++ b/examples/canvas/canvas/app.py @@ -15,9 +15,10 @@ def startup(self): # Show the main window self.main_window.show() - with canvas.stroke(): - with canvas.closed_path(50, 50): - canvas.line_to(100, 100) + with canvas.stroke() as stroker: + with stroker.closed_path(50, 50) as closer: + closer.line_to(100, 100) + closer.line_to(100, 50) def main(): diff --git a/examples/tutorial4/tutorial/app.py b/examples/tutorial4/tutorial/app.py index 5a54c34b19..2b2b73d50a 100644 --- a/examples/tutorial4/tutorial/app.py +++ b/examples/tutorial4/tutorial/app.py @@ -1,17 +1,20 @@ import math import toga +from toga.color import WHITE, rgb +from toga.font import SANS_SERIF from toga.style import Pack class StartApp(toga.App): def startup(self): - # Window class - # Main window of the application with title and size + # Main window of the application with title and size self.main_window = toga.MainWindow(title=self.name, size=(148, 200)) - self.canvas = toga.Canvas(on_draw=self.draw_tiberius, style=Pack(flex=1)) + # Create canvas and draw tiberius on it + self.canvas = toga.Canvas(style=Pack(flex=1)) box = toga.Box(children=[self.canvas]) + self.draw_tiberius() # Add the content on the main window self.main_window.content = box @@ -20,91 +23,77 @@ def startup(self): self.main_window.show() def fill_head(self): - with self.canvas.fill(): - self.canvas.fill_style('rgba(149.0, 119, 73, 1)') - self.canvas.move_to(112, 103) - self.canvas.line_to(112, 113) - self.canvas.ellipse(73, 114, 39, 47, 0, 0, math.pi) - self.canvas.line_to(35, 84) - self.canvas.arc(65, 84, 30, math.pi, 3 * math.pi / 2) - self.canvas.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + with self.canvas.fill(color=rgb(149, 119, 73)) as head_filler: + head_filler.move_to(112, 103) + head_filler.line_to(112, 113) + head_filler.ellipse(73, 114, 39, 47, 0, 0, math.pi) + head_filler.line_to(35, 84) + head_filler.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + head_filler.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) def stroke_head(self): - with self.canvas.stroke(): - with self.canvas.closed_path(112, 103): - self.canvas.line_width(4.0) - self.canvas.stroke_style() - self.canvas.line_to(112, 113) - self.canvas.ellipse(73, 114, 39, 47, 0, 0, math.pi) - self.canvas.line_to(35, 84) - self.canvas.arc(65, 84, 30, math.pi, 3 * math.pi / 2) - self.canvas.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) + with self.canvas.stroke(line_width=4.0) as head_stroker: + with head_stroker.closed_path(112, 103) as closed_head: + closed_head.line_to(112, 113) + closed_head.ellipse(73, 114, 39, 47, 0, 0, math.pi) + closed_head.line_to(35, 84) + closed_head.arc(65, 84, 30, math.pi, 3 * math.pi / 2) + closed_head.arc(82, 84, 30, 3 * math.pi / 2, 2 * math.pi) def draw_eyes(self): - self.canvas.line_width(4.0) - with self.canvas.fill(): - self.canvas.fill_style('rgba(255, 255, 255, 1)') - self.canvas.arc(58, 92, 15) - self.canvas.arc(88, 92, 15, math.pi, 3 * math.pi) - with self.canvas.stroke(): - self.canvas.stroke_style('rgba(0, 0, 0, 1)') - self.canvas.arc(58, 92, 15) - self.canvas.arc(88, 92, 15, math.pi, 3 * math.pi) - with self.canvas.fill(): - self.canvas.arc(58, 97, 3) - self.canvas.arc(88, 97, 3) + with self.canvas.fill(color=WHITE) as eye_whites: + eye_whites.arc(58, 92, 15) + eye_whites.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.stroke(line_width=4.0) as eye_outline: + eye_outline.arc(58, 92, 15) + eye_outline.arc(88, 92, 15, math.pi, 3 * math.pi) + with self.canvas.fill() as eye_pupils: + eye_pupils.arc(58, 97, 3) + eye_pupils.arc(88, 97, 3) def draw_horns(self): - # Right horn - with self.canvas.fill(): - self.canvas.fill_style('rgba(212, 212, 212, 1)') - self.canvas.move_to(112, 99) - self.canvas.quadratic_curve_to(145, 65, 139, 36) - self.canvas.quadratic_curve_to(130, 60, 109, 75) - with self.canvas.stroke(): - self.canvas.stroke_style() - self.canvas.move_to(112, 99) - self.canvas.quadratic_curve_to(145, 65, 139, 36) - self.canvas.quadratic_curve_to(130, 60, 109, 75) - # Left horn - with self.canvas.fill(): - self.canvas.fill_style('rgba(212, 212, 212, 1)') - self.canvas.move_to(35, 99) - self.canvas.quadratic_curve_to(2, 65, 6, 36) - self.canvas.quadratic_curve_to(17, 60, 37, 75) - with self.canvas.stroke(): - self.canvas.stroke_style() - self.canvas.move_to(35, 99) - self.canvas.quadratic_curve_to(2, 65, 6, 36) - self.canvas.quadratic_curve_to(17, 60, 37, 75) + with self.canvas.context() as r_horn: + with r_horn.fill(color=rgb(212, 212, 212)) as r_horn_filler: + r_horn_filler.move_to(112, 99) + r_horn_filler.quadratic_curve_to(145, 65, 139, 36) + r_horn_filler.quadratic_curve_to(130, 60, 109, 75) + with r_horn.stroke(line_width=4.0) as r_horn_stroker: + r_horn_stroker.move_to(112, 99) + r_horn_stroker.quadratic_curve_to(145, 65, 139, 36) + r_horn_stroker.quadratic_curve_to(130, 60, 109, 75) + with self.canvas.context() as l_horn: + with l_horn.fill(color=rgb(212, 212, 212)) as l_horn_filler: + l_horn_filler.move_to(35, 99) + l_horn_filler.quadratic_curve_to(2, 65, 6, 36) + l_horn_filler.quadratic_curve_to(17, 60, 37, 75) + with l_horn.stroke(line_width=4.0) as l_horn_stroker: + l_horn_stroker.move_to(35, 99) + l_horn_stroker.quadratic_curve_to(2, 65, 6, 36) + l_horn_stroker.quadratic_curve_to(17, 60, 37, 75) def draw_nostrils(self): - with self.canvas.fill(): - self.canvas.fill_style('rgba(212, 212, 212, 1)') - self.canvas.move_to(45, 145) - self.canvas.bezier_curve_to(51, 123, 96, 123, 102, 145) - self.canvas.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) - with self.canvas.fill(): - self.canvas.fill_style() - self.canvas.arc(63, 140, 3) - self.canvas.arc(83, 140, 3) - with self.canvas.stroke(): - self.canvas.move_to(45, 145) - self.canvas.bezier_curve_to(51, 123, 96, 123, 102, 145) + with self.canvas.fill(color=rgb(212, 212, 212)) as nose_filler: + nose_filler.move_to(45, 145) + nose_filler.bezier_curve_to(51, 123, 96, 123, 102, 145) + nose_filler.ellipse(73, 114, 39, 47, 0, math.pi / 4, 3 * math.pi / 4) + with self.canvas.fill() as nostril_filler: + nostril_filler.arc(63, 140, 3) + nostril_filler.arc(83, 140, 3) + with self.canvas.stroke(line_width=4.0) as nose_stroker: + nose_stroker.move_to(45, 145) + nose_stroker.bezier_curve_to(51, 123, 96, 123, 102, 145) def draw_text(self): x = 32 y = 185 - font = toga.Font(family='sans-serif', size=20) + font = toga.Font(family=SANS_SERIF, size=20) width, height = font.measure('Tiberius', tight=True) - with self.canvas.stroke(): - self.canvas.rect(x - 10, y - height + 2, width, height + 2) - with self.canvas.fill(): - self.canvas.fill_style('rgba(149.0, 119, 73, 1)') - self.canvas.write_text('Tiberius', x, y, font) - - def draw_tiberius(self, canvas, context): - self.canvas.set_context(context) + with self.canvas.stroke(line_width=4.0) as rect_stroker: + rect_stroker.rect(x - 10, y - height + 2, width, height + 2) + with self.canvas.fill(color=rgb(149, 119, 73)) as text_filler: + text_filler.write_text('Tiberius', x, y, font) + + def draw_tiberius(self): self.fill_head() self.draw_eyes() self.draw_horns() diff --git a/src/core/tests/widgets/test_canvas.py b/src/core/tests/widgets/test_canvas.py index 6f2830d9b6..d01ae761f3 100644 --- a/src/core/tests/widgets/test_canvas.py +++ b/src/core/tests/widgets/test_canvas.py @@ -1,10 +1,10 @@ -import math +from math import pi, cos, sin import toga -from toga.font import SANS_SERIF - import toga_dummy +from toga.font import SANS_SERIF, SERIF from toga_dummy.utils import TestCase +from toga.color import REBECCAPURPLE, BLANCHEDALMOND, CRIMSON, rgb class CanvasTests(TestCase): @@ -16,182 +16,541 @@ def setUp(self): def test_widget_created(self): self.assertEqual(self.testing_canvas._impl.interface, self.testing_canvas) - self.assertActionPerformed(self.testing_canvas, 'create Canvas') - - def test_canvas_on_draw(self): - self.assertIsNone(self.testing_canvas._on_draw) - - # set a new callback - def callback(widget, **extra): - return 'called {} with {}'.format(type(widget), extra) - - self.testing_canvas.on_draw = callback - self.assertValueSet(self.testing_canvas, 'on_draw', self.testing_canvas.on_draw) + self.assertActionPerformed(self.testing_canvas, "create Canvas") def test_basic_drawing(self): - def drawing(widget): - self.testing_canvas.rect(-3, -3, 6, 6) - self.assertActionPerformedWith(self.testing_canvas, 'rect', x=-3, y=-3, width=6, height=6) - self.testing_canvas.fill_style('rgba(0, 0.5, 0, 0.4)') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(0, 0.5, 0, 0.4)') - self.testing_canvas.fill(preserve=True) - self.assertActionPerformedWith(self.testing_canvas, 'fill', preserve=True) - self.testing_canvas.stroke_style('rgba(0.25, 0.25, 0.25, 0.6)') - self.assertActionPerformedWith(self.testing_canvas, 'stroke style', color='rgba(0.25, 0.25, 0.25, 0.6)') - self.testing_canvas.line_width(1) - self.assertActionPerformedWith(self.testing_canvas, 'line width', width=1) - self.testing_canvas.stroke() - self.assertActionPerformedWith(self.testing_canvas, 'stroke') - - self.testing_canvas.on_draw = drawing + with self.testing_canvas.context() as basic_context: + with basic_context.fill( + color="rgba(0, 0, 0, 0.4)", preserve=True + ) as fill_test: + with fill_test.stroke( + color="rgba(0, 0, 0, 0.6)", line_width=1 + ) as stroke_test: + rect = stroke_test.rect(-3, -3, 6, 6) + self.assertIn(rect, stroke_test.drawing_objects) + self.assertIn(stroke_test, fill_test.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "stroke") + self.assertActionPerformedWith( + self.testing_canvas, "rect", x=-3, y=-3, width=6, height=6 + ) + self.assertActionPerformedWith(self.testing_canvas, "new path") + self.assertIn(fill_test, basic_context.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "fill") + self.assertIn(basic_context, self.testing_canvas.drawing_objects) def test_self_oval_path(self): xc = 50 yc = 60 xr = 25 yr = 30 - self.testing_canvas.translate(xc, yc) - self.assertActionPerformedWith(self.testing_canvas, 'translate', tx=xc, ty=yc) - self.testing_canvas.scale(1.0, yr / xr) - self.assertActionPerformedWith(self.testing_canvas, 'scale', sx=1.0, sy=yr/xr) - with self.testing_canvas.closed_path(xr, 0.0): - self.testing_canvas.arc(0, 0, xr, 0, 2 * math.pi) + translate = self.testing_canvas.translate(xc, yc) + self.assertIn(translate, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "translate", tx=xc, ty=yc) + scale = self.testing_canvas.scale(1.0, yr / xr) + self.assertIn(scale, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "scale", sx=1.0, sy=yr / xr) + with self.testing_canvas.closed_path(xr, 0.0) as closed: + self.assertActionPerformedWith(self.testing_canvas, "move to", x=xr, y=0.0) + arc = closed.arc(0, 0, xr, 0, 2 * pi) + self.assertIn(arc, closed.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "arc", + x=0, + y=0, + radius=xr, + startangle=0, + endangle=2 * pi, + anticlockwise=False, + ) + self.assertActionPerformedWith(self.testing_canvas, "closed path") def test_fill_checks(self): - def drawing(widget): - CHECK_SIZE = 32 - x = 10 - y = -10 - width = 200 - height = 200 - self.testing_canvas.rect(x, y, width, height) - self.assertActionPerformedWith(self.testing_canvas, 'rect', x=10, y=-10, width=200, height=200) - self.testing_canvas.fill_style('rgba(0.4, 0.4, 0.4, 1)') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(0.4, 0.4, 0.4, 1)') - self.testing_canvas.fill() - self.assertActionPerformedWith(self.testing_canvas, 'fill') - - # Only works for CHECK_SIZE a power of 2 - for j in range(x & -CHECK_SIZE, height, CHECK_SIZE): - for i in range(y & -CHECK_SIZE, width, CHECK_SIZE): - if (i / CHECK_SIZE + j / CHECK_SIZE) % 2 == 0: - self.testing_canvas.rect(i, j, CHECK_SIZE, CHECK_SIZE) - self.assertActionPerformedWith(self.testing_canvas, 'rect', x=i, y=j, width=CHECK_SIZE, height=CHECK_SIZE) - - self.testing_canvas.fill_style('rgba(0.7, 0.7, 0.7, 1)') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(0.7, 0.7, 0.7, 1)') - self.testing_canvas.fill() - self.assertActionPerformedWith(self.testing_canvas, 'fill') - - self.testing_canvas.on_draw = drawing + check_size = 32 + x = 10 + y = -10 + width = 200 + height = 200 + with self.testing_canvas.fill(color="rgba(1, 1, 1, 1)") as fill1: + rect = fill1.rect(x, y, width, height) + self.assertIn(rect, fill1.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, "rect", x=10, y=-10, width=200, height=200 + ) + self.assertActionPerformedWith(self.testing_canvas, "fill") + + with self.testing_canvas.fill(color="rgba(1, 1, 1, 1)") as fill2: + # Only works for check_size a power of 2 + for j in range(x & -check_size, height, check_size): + for i in range(y & -check_size, width, check_size): + if (i / check_size + j / check_size) % 2 == 0: + rect = fill2.rect(i, j, check_size, check_size) + self.testing_canvas.redraw() + self.assertIn(rect, fill2.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "rect", + x=i, + y=j, + width=check_size, + height=check_size, + ) + self.assertActionPerformedWith(self.testing_canvas, "fill") def test_draw_3circles(self): - def drawing(widget): - xc = 100 - yc = 150 - radius = 0.5 * 50 - 10 - alpha = 0.8 - subradius = radius * (2 / 3. - 0.1) - - self.testing_canvas.fill_style('rgba(1, 0, 0, ' + str(alpha) + ')') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(1, 0, 0, ' + str(alpha) + ')') - self.testing_canvas.ellipse(self.testing_canvas, - xc + radius / 3. * math.cos(math.pi * 0.5), - yc - radius / 3. * math.sin(math.pi * 0.5), - subradius, subradius, 2.0 * math.pi) - self.assertActionPerformedWith(self.testing_canvas, 'ellipse', - x=xc + radius / 3. * math.cos(math.pi * 0.5), - y=yc - radius / 3. * math.sin(math.pi * 0.5), - radiusx=subradius, radiusy=subradius, rotation=2.0*math.pi) - - self.testing_canvas.fill() - self.assertActionPerformedWith(self.testing_canvas, 'fill') - - self.testing_canvas.fill_style('rgba(0, 1, 0, ' + str(alpha) + ')') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(0, 1, 0, ' + str(alpha) + ')') - self.testing_canvas.ellipse(self.testing_canvas, - xc + radius / 3. * math.cos(math.pi * (0.5 + 2 / .3)), - yc - radius / 3. * math.sin(math.pi * (0.5 + 2 / .3)), - subradius, subradius) - self.assertActionPerformedWith(self.testing_canvas, 'ellipse', - x=xc + radius / 3. * math.cos(math.pi * (0.5 + 2 / .3)), - y=yc - radius / 3. * math.sin(math.pi * (0.5 + 2 / .3)), - radiusx=subradius, radiusy=subradius) - self.testing_canvas.fill() - self.assertActionPerformedWith(self.testing_canvas, 'fill') - - self.testing_canvas.fill_style('rgba(0, 0, 1, ' + str(alpha) + ')') - self.assertActionPerformedWith(self.testing_canvas, 'fill style', color='rgba(0, 0, 1, ' + str(alpha) + ')') - self.testing_canvas.ellipse(self.testing_canvas, - xc + radius / 3. * math.cos(math.pi * (0.5 + 4 / .3)), - yc - radius / 3. * math.sin(math.pi * (0.5 + 4 / .3)), - subradius, subradius) - self.assertActionPerformedWith(self.testing_canvas, 'ellipse', - x=xc + radius / 3. * math.cos(math.pi * (0.5 + 4 / .3)), - y=yc - radius / 3. * math.sin(math.pi * (0.5 + 4 / .3)), - radiusx=subradius, radiusy=subradius) - self.testing_canvas.fill() - self.assertActionPerformedWith(self.testing_canvas, 'fill') - - self.testing_canvas.on_draw = drawing + xc = 100 + yc = 150 + radius = 0.5 * 50 - 10 + alpha = 0.8 + subradius = radius * (2 / 3. - 0.1) + + with self.testing_canvas.fill( + color="rgba(1, 0, 0, " + str(alpha) + ")" + ) as fill1: + ellipse1 = fill1.ellipse( + xc + radius / 3. * cos(pi * 0.5), + yc - radius / 3. * sin(pi * 0.5), + subradius, + subradius, + 2.0 * pi, + ) + self.assertIn(ellipse1, fill1.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "ellipse", + x=xc + radius / 3. * cos(pi * 0.5), + y=yc - radius / 3. * sin(pi * 0.5), + radiusx=subradius, + radiusy=subradius, + rotation=2.0 * pi, + ) + self.assertActionPerformedWith(self.testing_canvas, "fill") + + with self.testing_canvas.fill( + color="rgba(0, 1, 0, " + str(alpha) + ")" + ) as fill2: + ellipse2 = fill2.ellipse( + xc + radius / 3. * cos(pi * (0.5 + 2 / .3)), + yc - radius / 3. * sin(pi * (0.5 + 2 / .3)), + subradius, + subradius, + ) + self.assertIn(ellipse2, fill2.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "ellipse", + x=xc + radius / 3. * cos(pi * (0.5 + 2 / .3)), + y=yc - radius / 3. * sin(pi * (0.5 + 2 / .3)), + radiusx=subradius, + radiusy=subradius, + ) + self.assertActionPerformedWith(self.testing_canvas, "fill") + + with self.testing_canvas.fill( + color="rgba(0, 0, 1, " + str(alpha) + ")" + ) as fill3: + ellipse3 = fill3.ellipse( + xc + radius / 3. * cos(pi * (0.5 + 4 / .3)), + yc - radius / 3. * sin(pi * (0.5 + 4 / .3)), + subradius, + subradius, + ) + self.assertIn(ellipse3, fill3.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "ellipse", + x=xc + radius / 3. * cos(pi * (0.5 + 4 / .3)), + y=yc - radius / 3. * sin(pi * (0.5 + 4 / .3)), + radiusx=subradius, + radiusy=subradius, + ) + self.assertActionPerformedWith(self.testing_canvas, "fill") def test_draw_triangle(self): - def drawing(widget): - with self.testing_canvas.closed_path(32, 0): - self.assertActionPerformedWith(self.testing_canvas, 'move to', x=32, y=0) - self.testing_canvas.line_to(32, 64) - self.assertActionPerformedWith(self.testing_canvas, 'line to', x=32, y=64) - self.testing_canvas.line_to(-64, 0) - self.assertActionPerformedWith(self.testing_canvas, 'line to', x=-64, y=0) - self.assertActionPerformedWith(self.testing_canvas, 'close path') - - self.testing_canvas.on_draw = drawing - - def test_move_to(self): - self.testing_canvas.move_to(5, 7) - self.assertActionPerformedWith(self.testing_canvas, 'move to', x=5, y=7) - self.testing_canvas.move_to(-5, 20.0) - self.assertActionPerformedWith(self.testing_canvas, 'move to', x=-5, y=20.0) - - def test_line_to(self): - self.testing_canvas.line_to(2, 3) - self.assertActionPerformedWith(self.testing_canvas, 'line to', x=2, y=3) - - def test_bezier_curve_to(self): - self.testing_canvas.bezier_curve_to(1, 1, 2, 2, 5, 5) - self.assertActionPerformedWith(self.testing_canvas, 'bezier curve to', cp1x=1, cp1y=1, cp2x=2, cp2y=2, x=5, y=5) - - def test_quadratic_curve_to(self): - self.testing_canvas.quadratic_curve_to(1, 1, 5, 5) - self.assertActionPerformedWith(self.testing_canvas, 'quadratic curve to', cpx=1, cpy=1, x=5, y=5) - - def test_arc(self): - self.testing_canvas.arc(-10, -10, 10, math.pi / 2, 0, True) - self.assertActionPerformedWith(self.testing_canvas, 'arc', x=-10, y=-10, radius=10, startangle=math.pi / 2, - endangle=0, anticlockwise=True) - - def test_ellipse(self): - self.testing_canvas.ellipse(1, 1, 50, 20, 0, math.pi, 2 * math.pi, False) - self.assertActionPerformedWith(self.testing_canvas, 'ellipse', x=1, y=1, radiusx=50, radiusy=20, rotation=0, - startangle=math.pi, endangle=2 * math.pi, anticlockwise=False) - - def test_rotate(self): - self.testing_canvas.rotate(math.pi) - self.assertActionPerformedWith(self.testing_canvas, 'rotate', radians=math.pi) - - def test_scale(self): - self.testing_canvas.scale(2, 1.5) - self.assertActionPerformedWith(self.testing_canvas, 'scale', sx=2, sy=1.5) - - def test_translate(self): - self.testing_canvas.translate(5, 3.5) - self.assertActionPerformedWith(self.testing_canvas, 'translate', tx=5, ty=3.5) - - def test_reset_transform(self): - self.testing_canvas.reset_transform() - self.assertActionPerformedWith(self.testing_canvas, 'reset transform') - - def test_write_text(self): + with self.testing_canvas.closed_path(32, 0) as closed: + self.assertActionPerformedWith(self.testing_canvas, "move to", x=32, y=0) + line_to1 = closed.line_to(32, 64) + self.assertIn(line_to1, closed.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "line to", x=32, y=64) + self.assertActionPerformedWith(self.testing_canvas, "closed path") + + def test_context_repr(self): + with self.testing_canvas.context() as context: + self.assertEqual(repr(context), "Context()") + + def test_new_path_simple(self): + new_path = self.testing_canvas.new_path() + self.assertIn(new_path, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "new path") + + def test_new_path_remove(self): + new_path = self.testing_canvas.new_path() + self.testing_canvas.remove(new_path) + self.assertNotIn(new_path, self.testing_canvas.drawing_objects) + + def test_new_path_repr(self): + new_path = self.testing_canvas.new_path() + self.assertEqual(repr(new_path), "NewPath()") + + def test_closed_path_modify(self): + with self.testing_canvas.closed_path(0, -5) as closed: + closed.line_to(10, 10) + closed.line_to(10, 0) + closed.x = 0 + closed.y = 0 + closed.redraw() + self.assertActionPerformedWith(self.testing_canvas, "move to", x=0, y=0) + + def test_closed_path_repr(self): + with self.testing_canvas.closed_path(0.5, -0.5) as closed: + self.assertEqual(repr(closed), "ClosedPath(x=0.5, y=-0.5)") + + def test_move_to_simple(self): + move_to1 = self.testing_canvas.move_to(5, 7) + self.assertIn(move_to1, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "move to", x=5, y=7) + + def test_move_to_modify(self): + move_to2 = self.testing_canvas.move_to(-5, 20.0) + move_to2.x, move_to2.y = (0, -10) + self.testing_canvas.redraw() + self.assertActionPerformedWith(self.testing_canvas, "move to", x=0, y=-10) + + def test_move_to_repr(self): + move_to3 = self.testing_canvas.move_to(x=0.5, y=1000) + self.assertEqual(repr(move_to3), "MoveTo(x=0.5, y=1000)") + + def test_line_to_simple(self): + line_to = self.testing_canvas.line_to(2, 3) + self.assertIn(line_to, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "line to", x=2, y=3) + + def test_line_to_modify(self): + line_to = self.testing_canvas.line_to(-40.5, 50.5) + line_to.x = 0 + line_to.y = 5 + self.testing_canvas.redraw() + self.assertActionPerformedWith(self.testing_canvas, "line to", x=0, y=5) + + def test_line_to_repr(self): + line_to = self.testing_canvas.line_to(x=1.5, y=-1000) + self.assertEqual(repr(line_to), "LineTo(x=1.5, y=-1000)") + + def test_bezier_curve_to_simple(self): + bezier = self.testing_canvas.bezier_curve_to(1, 1, 2, 2, 5, 5) + self.assertIn(bezier, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "bezier curve to", + cp1x=1, + cp1y=1, + cp2x=2, + cp2y=2, + x=5, + y=5, + ) + + def test_bezier_curve_to_modify(self): + bezier = self.testing_canvas.bezier_curve_to(0, 0, -2, -2, 5.5, 5.5) + bezier.cp1x, bezier.cp1y, bezier.cp2x, bezier.cp2y, bezier.x, bezier.y = ( + 6, + -5, + 2.0, + 0, + -2, + -3, + ) + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, + "bezier curve to", + cp1x=6, + cp1y=-5, + cp2x=2.0, + cp2y=0, + x=-2, + y=-3, + ) + + def test_bezier_curve_to_repr(self): + bezier = self.testing_canvas.bezier_curve_to( + cp1x=2.0, cp1y=2.0, cp2x=4.0, cp2y=4.0, x=10, y=10 + ) + self.assertEqual( + repr(bezier), + "BezierCurveTo(cp1x=2.0, cp1y=2.0, cp2x=4.0, cp2y=4.0, x=10, y=10)", + ) + + def test_quadratic_curve_to_simple(self): + quad = self.testing_canvas.quadratic_curve_to(1, 1, 5, 5) + self.assertIn(quad, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, "quadratic curve to", cpx=1, cpy=1, x=5, y=5 + ) + + def test_quadratic_curve_to_modify(self): + quad = self.testing_canvas.quadratic_curve_to(-1, -1, -5, -5) + quad.cpx = 0 + quad.cpy = 0.5 + quad.x = -0.4 + quad.y = 1000 + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, "quadratic curve to", cpx=0, cpy=0.5, x=-0.4, y=1000 + ) + + def test_quadratic_curve_to_repr(self): + quad = self.testing_canvas.quadratic_curve_to(1020.2, 1, -5, 0.5) + self.assertEqual(repr(quad), "QuadraticCurveTo(cpx=1020.2, cpy=1, x=-5, y=0.5)") + + def test_arc_simple(self): + arc = self.testing_canvas.arc(-10, -10, 10, pi / 2, 0, True) + self.assertIn(arc, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "arc", + x=-10, + y=-10, + radius=10, + startangle=pi / 2, + endangle=0, + anticlockwise=True, + ) + + def test_arc_modify(self): + arc = self.testing_canvas.arc(10, 10, 10.0, 2, pi, False) + arc.x, arc.y, arc.radius = (1000, 2000, 0.1) + arc.startangle, arc.endangle, arc.anticlockwise = (pi, 2 * pi, False) + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, + "arc", + x=1000, + y=2000, + radius=0.1, + startangle=pi, + endangle=2 * pi, + anticlockwise=False, + ) + + def test_arc_repr(self): + arc = self.testing_canvas.arc(1, 2, 3, 2, -3.141592, False) + self.assertEqual( + repr(arc), + "Arc(x=1, y=2, radius=3, startangle=2, endangle=-3.141592, anticlockwise=False)", + ) + + def test_remove_arc(self): + arc = self.testing_canvas.arc(-10, -10, 10, pi / 2, 0, True) + self.assertIn(arc, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "arc", + x=-10, + y=-10, + radius=10, + startangle=pi / 2, + endangle=0, + anticlockwise=True, + ) + self.testing_canvas.remove(arc) + self.assertNotIn(arc, self.testing_canvas.drawing_objects) + + def test_ellipse_simple(self): + ellipse = self.testing_canvas.ellipse(1, 1, 50, 20, 0, pi, 2 * pi, False) + self.assertIn(ellipse, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "ellipse", + x=1, + y=1, + radiusx=50, + radiusy=20, + rotation=0, + startangle=pi, + endangle=2 * pi, + anticlockwise=False, + ) + + def test_ellipse_modify(self): + ellipse = self.testing_canvas.ellipse(0, -1, -50, 20.2, pi, pi, 2 * pi, False) + ellipse.x = 1 + ellipse.y = 0 + ellipse.radiusx = 0.1 + ellipse.radiusy = 1000 + ellipse.rotation = 2 * pi + ellipse.startangle = 0 + ellipse.endangle = pi + self.testing_canvas.redraw() + + def test_ellipse_repr(self): + ellipse = self.testing_canvas.ellipse(1.0, 1.0, 0, 0, 0, 2, 3.1415, False) + self.assertEqual( + repr(ellipse), + "Ellipse(x=1.0, y=1.0, radiusx=0, radiusy=0, rotation=0, startangle=2, endangle=3.1415, " + "anticlockwise=False)", + ) + + def test_rect_modify(self): + rect = self.testing_canvas.rect(-5, 5, 10, 15) + rect.x, rect.y, rect.width, rect.height = (5, -5, 0.5, -0.5) + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, "rect", x=5, y=-5, width=0.5, height=-0.5 + ) + + def test_rect_repr(self): + rect = self.testing_canvas.rect(x=1000.2, y=2000, width=3000, height=-4000.0) + self.assertEqual( + repr(rect), "Rect(x=1000.2, y=2000, width=3000, height=-4000.0)" + ) + + def test_fill_modify(self): + with self.testing_canvas.fill( + color="rgb(0, 255, 0)", fill_rule="nonzero", preserve=False + ) as filler: + filler.color = REBECCAPURPLE + filler.fill_rule = "evenodd" + filler.preserve = True + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, + "fill", + color=rgb(102, 51, 153), + fill_rule="evenodd", + preserve=True, + ) + + def test_fill_repr(self): + with self.testing_canvas.fill( + color=CRIMSON, fill_rule="evenodd", preserve=True + ) as filler: + self.assertEqual( + repr(filler), + "Fill(color=rgb(220, 20, 60), fill_rule=evenodd, preserve=True)", + ) + + def test_stroke_modify(self): + with self.testing_canvas.stroke( + color=BLANCHEDALMOND, line_width=5.0 + ) as stroker: + stroker.color = REBECCAPURPLE + stroker.line_width = 1 + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, "stroke", color=rgb(102, 51, 153), line_width=1 + ) + + def test_stroke_repr(self): + with self.testing_canvas.stroke() as stroker: + self.assertEqual( + repr(stroker), "Stroke(color=rgb(0, 0, 0), line_width=2.0)" + ) + + def test_rotate_simple(self): + rotate = self.testing_canvas.rotate(pi) + self.assertIn(rotate, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "rotate", radians=pi) + + def test_rotate_modify(self): + rotate = self.testing_canvas.rotate(radians=-2 * pi) + rotate.radians = 3 * pi / 2 + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, "rotate", radians=3 * pi / 2 + ) + + def test_rotate_repr(self): + rotate = self.testing_canvas.rotate(0.1) + self.assertEqual(repr(rotate), "Rotate(radians=0.1)") + + def test_scale_simple(self): + scale = self.testing_canvas.scale(2, 1.5) + self.assertIn(scale, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "scale", sx=2, sy=1.5) + + def test_scale_modify(self): + scale = self.testing_canvas.scale(sx=-2, sy=0) + scale.sx = -2.0 + scale.sy = 3.0 + self.testing_canvas.redraw() + self.assertActionPerformedWith(self.testing_canvas, "scale", sx=-2.0, sy=3.0) + + def test_scale_repr(self): + scale = self.testing_canvas.scale(sx=500, sy=-500) + self.assertEqual(repr(scale), "Scale(sx=500, sy=-500)") + + def test_translate_simple(self): + translate = self.testing_canvas.translate(5, 3.5) + self.assertIn(translate, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "translate", tx=5, ty=3.5) + + def test_translate_modify(self): + translate = self.testing_canvas.translate(tx=2.3, ty=-2) + translate.tx = 0 + translate.ty = -500 + self.testing_canvas.redraw() + self.assertActionPerformedWith(self.testing_canvas, "translate", tx=0, ty=-500) + + def test_translate_repr(self): + translate = self.testing_canvas.translate(tx=0, ty=-3.2) + self.assertEqual(repr(translate), "Translate(tx=0, ty=-3.2)") + + def test_reset_transform_simple(self): + reset_transform = self.testing_canvas.reset_transform() + self.assertIn(reset_transform, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith(self.testing_canvas, "reset transform") + + def test_reset_transform_repr(self): + reset_transform = self.testing_canvas.reset_transform() + self.assertEqual(repr(reset_transform), "ResetTransform()") + + def test_write_text_simple(self): test_font = toga.Font(family=SANS_SERIF, size=15) - self.testing_canvas.write_text('test text', 0, 0, test_font) - self.assertActionPerformedWith(self.testing_canvas, 'write text', text='test text', x=0, y=0, font=test_font) + write_text = self.testing_canvas.write_text("test text", 0, 0, test_font) + self.assertIn(write_text, self.testing_canvas.drawing_objects) + self.assertActionPerformedWith( + self.testing_canvas, + "write text", + text="test text", + x=0, + y=0, + font=test_font, + ) + + def test_write_text_default(self): + write_text = self.testing_canvas.write_text("test text") + self.assertActionPerformedWith( + self.testing_canvas, "write text", text="test text" + ) + self.assertEqual( + repr(write_text), + "WriteText(text=test text, x=0, y=0, font=)", + ) + + def test_write_text_modify(self): + write_text = self.testing_canvas.write_text("test text") + modify_font = toga.Font(family=SERIF, size=1.2) + write_text.text, write_text.x, write_text.y, write_text.font = ( + "hello again", + 10, + -1999, + modify_font, + ) + self.testing_canvas.redraw() + self.assertActionPerformedWith( + self.testing_canvas, + "write text", + text="hello again", + x=10, + y=-1999, + font=modify_font, + ) + + def test_write_text_repr(self): + font = toga.Font(family=SERIF, size=4) + write_text = self.testing_canvas.write_text("hello", x=10, y=-4.2, font=font) + self.assertEqual( + repr(write_text), + "WriteText(text=hello, x=10, y=-4.2, font=)", + ) diff --git a/src/core/toga/widgets/canvas.py b/src/core/toga/widgets/canvas.py index 6b5ef8ecaf..c66d205000 100644 --- a/src/core/toga/widgets/canvas.py +++ b/src/core/toga/widgets/canvas.py @@ -2,334 +2,1001 @@ from math import pi from .base import Widget +from ..color import BLACK +from ..color import color as parse_color +from ..font import Font, SYSTEM -class Canvas(Widget): - """Create new canvas +class Context: + """The user-created :class:`Context ` drawing object to populate a + drawing with visual context. + + The top left corner of the canvas must be painted at the origin of the + context and is sized using the rehint() method. - 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_draw (``callable``): Function to draw on the canvas. - 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_draw=None, factory=None): - super().__init__(id=id, style=style, factory=factory) + def __init__(self, *args, **kwargs): # kwargs used to support multiple inheritance + super().__init__(*args, **kwargs) + self._canvas = None + self.drawing_objects = [] - # Create a platform specific implementation of Canvas - self._impl = self.factory.Canvas(interface=self) - self.on_draw = on_draw + def __repr__(self): + return "{}()".format(self.__class__.__name__) - def set_context(self, context): - """The context of the Canvas to pass through from a callable function + def _draw(self, impl, *args, **kwargs): + """Draw all drawing objects that are on the context or canvas. - Args: - context (:obj:'context'): The context to pass + + This method is used by the implementation to tell the interface canvas + to draw all objects on it, and used by a context to draw all the + objects that are on the context. """ - self._impl.set_context(context) + for obj in self.drawing_objects: + obj._draw(impl, *args, **kwargs) + + ########################################################################### + # Methods to keep track of the canvas, automatically redraw it + ########################################################################### @property - def on_draw(self): - """The callable function to draw on a Canvas + def canvas(self): + """The canvas property of the current context. - Creates a context or saves the current context, draws the operations - using the passed function, then renders the drawing, and finally - restores the saved context. The top left corner of the canvas must be - painted at the origin of the context and is sized using the rehint() - method. + Returns: + The canvas node. Returns self if this node *is* the canvas node. """ - return self._on_draw + return self._canvas if self._canvas else self - @on_draw.setter - def on_draw(self, handler): - self._on_draw = handler + @canvas.setter + def canvas(self, value): + """Set the canvas of the context. - if self._on_draw: - self._impl.set_on_draw(self._on_draw) + Args: + value: The canvas to set. - # Line Styles + """ + self._canvas = value - def line_width(self, width=2.0): - """Set width of lines + def add_draw_obj(self, draw_obj): + """A drawing object to add to the drawing object stack on a context Args: - width (float): line width + draw_obj: (:obj:`Drawing Object`): The drawing object to add """ - self._impl.line_width(width) + self.drawing_objects.append(draw_obj) - # Fill and Stroke Styles + # Only redraw if drawing to canvas directly + if self.canvas is self: + self.redraw() - def fill_style(self, color=None): - """Color to use inside shapes + return draw_obj + + def redraw(self): + """Force a redraw of the Canvas + + The Canvas will be automatically redrawn after adding or remove a + drawing object. If you modify a drawing object, this method is used to + force a redraw. + + """ + self.canvas._impl.redraw() + + ########################################################################### + # Operations on drawing objects + ########################################################################### + + def remove(self, drawing_object): + """Remove a drawing object - Currently supports color, in the future could support gradient and - pattern. A named color or RGBA value must be passed, or default - to black. Args: - color (str): CSS color value or in rgba(0, 0, 0, 1) format + drawing_object (:obj:'Drawing Object'): The drawing object to remove """ - self._impl.fill_style(color) + self.drawing_objects.remove(drawing_object) + self.redraw() + + ########################################################################### + # Contexts to draw with + ########################################################################### - def stroke_style(self, color=None): - """Color to use for lines around shapes + @contextmanager + def context(self): + """Constructs and returns a :class:`Context `. - Currently supports color, in the future could support gradient and - pattern. A named color or RGBA value must be passed, or default to - black. If using RGBA values, RGB are in the range 0-255, A is in the - range 0-1. + Makes use of an existing context. The top left corner of the canvas must + be painted at the origin of the context and is sized using the rehint() + method. + + Yields: + :class:`Context ` object. + + """ + context = Context() + self.add_draw_obj(context) + context.canvas = self.canvas + yield context + self.redraw() + + @contextmanager + def fill(self, color=BLACK, fill_rule="nonzero", preserve=False): + """Constructs and yields a :class:`Fill `. + + A drawing operator that fills the current path according to the current + fill rule, (each sub-path is implicitly closed before being filled). Args: - color (str): CSS color value or in rgba(0, 0, 0, 1) format + fill_rule (str, optional): 'nonzero' is the non-zero winding rule and + 'evenodd' is the even-odd winding rule. + preserve (bool, optional): Preserves the path within the Context. + color (str, optional): color value in any valid color format, + default to black. + + Yields: + :class:`Fill ` object. """ - self._impl.stroke_style(color) + if fill_rule is "evenodd": + fill = Fill(color, fill_rule, preserve) + else: + fill = Fill(color, "nonzero", preserve) + fill.canvas = self.canvas + yield self.add_draw_obj(fill) + self.redraw() - # Paths + @contextmanager + def stroke(self, color=BLACK, line_width=2.0): + """Constructs and yields a :class:`Stroke `. + + Args: + color (str, optional): color value in any valid color format, + default to black. + line_width (float, optional): stroke line width, default is 2.0. + + Yields: + :class:`Stroke ` object. + + """ + stroke = Stroke(color, line_width) + stroke.canvas = self.canvas + yield self.add_draw_obj(stroke) + self.redraw() @contextmanager def closed_path(self, x, y): - """Creates a new path and then closes it + """Calls move_to(x,y) and then constructs and yields a + :class:`ClosedPath `. - Yields: None + Args: + x (float): The x axis of the beginning point. + y (float): The y axis of the beginning point. + + Yields: + :class:`ClosedPath ` object. + + """ + closed_path = ClosedPath(x, y) + closed_path.canvas = self.canvas + yield self.add_draw_obj(closed_path) + self.redraw() + + ########################################################################### + # Paths to draw with + ########################################################################### + + def new_path(self): + """Constructs and returns a :class:`NewPath `. + + Returns: + :class: `NewPath ` object. """ - self._impl.move_to(x, y) - yield - self._impl.close_path() + new_path = NewPath() + return self.add_draw_obj(new_path) def move_to(self, x, y): - """Moves the starting point of a new sub-path to the (x, y) coordinates. + """Constructs and returns a :class:`MoveTo `. Args: - x (float): The x axis of the point - y (float): The y axis of the point + x (float): The x axis of the point. + y (float): The y axis of the point. + + Returns: + :class:`MoveTo ` object. """ - self._impl.move_to(x, y) + move_to = MoveTo(x, y) + return self.add_draw_obj(move_to) def line_to(self, x, y): - """Connects the last point with a line. - - Connects the last point in the sub-path to the (x, y) coordinates - with a straight line (but does not actually draw it). + """Constructs and returns a :class:`LineTo `. Args: - x (float): The x axis of the coordinate for the end of the line - y (float): The y axis of the coordinate for the end of the line + x (float): The x axis of the coordinate for the end of the line. + y (float): The y axis of the coordinate for the end of the line. + + Returns: + :class:`LineTo ` object. """ - self._impl.line_to(x, y) + line_to = LineTo(x, y) + return self.add_draw_obj(line_to) def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - """Adds a cubic Bézier curve to the path. - - It requires three points. The first two points are control points - and the third one is the end point. The starting point is the last - point in the current path, which can be changed using move_to() before - creating the Bézier curve. + """Constructs and returns a :class:`BezierCurveTo `. Args: - cp1x (float): x coordinate for the first control point - cp1y (float): y coordinate for first control point - cp2x (float): x coordinate for the second control point - cp2y (float): y coordinate for the second control point - x (float): x coordinate for the end point - y (float): y coordinate for the end point + cp1x (float): x coordinate for the first control point. + cp1y (float): y coordinate for first control point. + cp2x (float): x coordinate for the second control point. + cp2y (float): y coordinate for the second control point. + x (float): x coordinate for the end point. + y (float): y coordinate for the end point. + + Returns: + :class:`BezierCurveTo ` object. """ - self._impl.bezier_curve_to(cp1x, cp1y, cp2x, cp2y, x, y) + bezier_curve_to = BezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) + return self.add_draw_obj(bezier_curve_to) def quadratic_curve_to(self, cpx, cpy, x, y): - """Adds a quadratic Bézier curve to the path. + """Constructs and returns a :class:`QuadraticCurveTo `. + + Args: + cpx (float): The x axis of the coordinate for the control point. + cpy (float): The y axis of the coordinate for the control point. + x (float): The x axis of the coordinate for the end point. + y (float): The y axis of the coordinate for the end point. - It requires two points. The first point is a control point and the - second one is the end point. The starting point is the last point in the - current path, which can be changed using moveTo() before creating the - quadratic Bézier curve. + Returns: + :class:`QuadraticCurveTo ` object. + + """ + quadratic_curve_to = QuadraticCurveTo(cpx, cpy, x, y) + return self.add_draw_obj(quadratic_curve_to) + + def arc(self, x, y, radius, startangle=0.0, endangle=2 * pi, anticlockwise=False): + """Constructs and returns a :class:`Arc `. Args: - cpx (float): The x axis of the coordinate for the control point - cpy (float): The y axis of the coordinate for the control point - x (float): The x axis of the coordinate for the end point - y (float): he y axis of the coordinate for the end point + x (float): The x coordinate of the arc's center. + y (float): The y coordinate of the arc's center. + radius (float): The arc's radius. + startangle (float, optional): The angle (in radians) at which the + arc starts, measured clockwise from the positive x axis, + default 0.0. + endangle (float, optional): The angle (in radians) at which the arc ends, + measured clockwise from the positive x axis, default 2*pi. + anticlockwise (bool, optional): If true, causes the arc to be drawn + counter-clockwise between the two angles instead of clockwise, + default false. + + Returns: + :class:`Arc ` object. """ - self._impl.quadratic_curve_to(cpx, cpy, x, y) + arc = Arc(x, y, radius, startangle, endangle, anticlockwise) + return self.add_draw_obj(arc) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation=0.0, + startangle=0.0, + endangle=2 * pi, + anticlockwise=False, + ): + """Constructs and returns a :class:`Ellipse `. - def arc(self, x, y, radius, startangle=0, endangle=2 * pi, anticlockwise=False): - """Adds an arc to the path. + Args: + x (float): The x axis of the coordinate for the ellipse's center. + y (float): The y axis of the coordinate for the ellipse's center. + radiusx (float): The ellipse's major-axis radius. + radiusy (float): The ellipse's minor-axis radius. + rotation (float, optional): The rotation for this ellipse, expressed in radians, default 0.0. + startangle (float, optional): The starting point in radians, measured from the x + axis, from which it will be drawn, default 0.0. + endangle (float, optional): The end ellipse's angle in radians to which it will + be drawn, default 2*pi. + anticlockwise (bool, optional): If true, draws the ellipse + anticlockwise (counter-clockwise) instead of clockwise, default false. + + Returns: + :class:`Ellipse ` object. - The arc is centered at (x, y) position with radius r starting at - startAngle and ending at endAngle going in the given direction by - anticlockwise (defaulting to clockwise). + """ + ellipse = Ellipse( + x, y, radiusx, radiusy, rotation, startangle, endangle, anticlockwise + ) + self.add_draw_obj(ellipse) + return ellipse + + def rect(self, x, y, width, height): + """Constructs and returns a :class:`Rect `. Args: - x (float): The x coordinate of the arc's center - y (float): The y coordinate of the arc's center - radius (float): The arc's radius - startangle (float): The angle (in radians) at which the arc starts, - measured clockwise from the positive x axis - endangle (float): The angle (in radians) at which the arc ends, - measured clockwise from the positive x axis - anticlockwise (bool): Optional, if true, causes the arc to be drawn - counter-clockwise between the two angles instead of clockwise + x (float): x coordinate for the rectangle starting point. + y (float): y coordinate for the rectangle starting point. + width (float): The rectangle's width. + height (float): The rectangle's width. + + Returns: + :class:`Rect ` object. """ - self._impl.arc(x, y, radius, startangle, endangle, anticlockwise) + rect = Rect(x, y, width, height) + return self.add_draw_obj(rect) - def ellipse(self, x, y, radiusx, radiusy, rotation=0, startangle=0, endangle=2 * pi, anticlockwise=False): - """Adds an ellipse to the path. + ########################################################################### + # Text drawing + ########################################################################### - The ellipse is centered at (x, y) position with the radii radiusx and radiusy - starting at startAngle and ending at endAngle going in the given - direction by anticlockwise (defaulting to clockwise). + def write_text(self, text, x=0, y=0, font=None): + """Constructs and returns a :class:`WriteText `. + + Writes a given text at the given (x,y) position. If no font is provided, + then it will use the font assigned to the Canvas Widget, if it exists, + or use the default font if there is no font assigned. Args: - x (float): The x axis of the coordinate for the ellipse's center - y (float): The y axis of the coordinate for the ellipse's center - radiusx (float): The ellipse's major-axis radius - radiusy (float): The ellipse's minor-axis radius - rotation (float): The rotation for this ellipse, expressed in radians - startangle (float): The starting point in radians, measured from the x - axis, from which it will be drawn - endangle (float): The end ellipse's angle in radians to which it will - be drawn - anticlockwise (bool): Optional, if true, draws the ellipse - anticlockwise (counter-clockwise) instead of clockwise + text (string): The text to fill. + x (float, optional): The x coordinate of the text. Default to 0. + y (float, optional): The y coordinate of the text. Default to 0. + font (:class:`toga.Font`, optional): The font to write with. + + Returns: + :class:`WriteText ` object. """ - self._impl.ellipse(x, y, radiusx, radiusy, rotation, startangle, endangle, anticlockwise) + if font is None: + font = Font(family=SYSTEM, size=self._canvas.style.font_size) + write_text = WriteText(text, x, y, font) + return self.add_draw_obj(write_text) - def rect(self, x, y, width, height): - """ Creates a path for a rectangle. - The rectangle is at position (x, y) with a size that is determined by - width and height. Those four points are connected by straight lines and - the sub-path is marked as closed, so that you can fill or stroke this - rectangle. +class Fill(Context): + """A user-created :class:`Fill ` drawing object for a fill context. - Args: - x (float): x coordinate for the rectangle starting point - y (float): y coordinate for the rectangle starting point - width (float): The rectangle's width - height (float): The rectangle's width + A drawing object that fills the current path according to the current + fill rule, (each sub-path is implicitly closed before being filled). + + Args: + color (str, optional): Color value in any valid color format, + default to black. + fill_rule (str, optional): 'nonzero' if the non-zero winding rule and + 'evenodd' if the even-odd winding rule. + preserve (bool, optional): Preserves the path within the Context. + + """ + + def __init__(self, color=BLACK, fill_rule="nonzero", preserve=False): + super().__init__() + self.color = color + self.fill_rule = fill_rule + self.preserve = preserve + + def __repr__(self): + return "{}(color={}, fill_rule={}, preserve={})".format( + self.__class__.__name__, self.color, self.fill_rule, self.preserve + ) + + def _draw(self, impl, *args, **kwargs): + """Used by parent to draw all objects that are part of the context. """ - self._impl.rect(x, y, width, height) + impl.new_path(*args, **kwargs) + for obj in self.drawing_objects: + obj._draw(impl, *args, **kwargs) + impl.fill(self.color, self.fill_rule, self.preserve, *args, **kwargs) - # Drawing Paths + @property + def color(self): + return self._color - @contextmanager - def fill(self, fill_rule='nonzero', preserve=False): - """Fills the subpaths with the current fill style + @color.setter + def color(self, value): + if value is None: + self._color = None + else: + self._color = parse_color(value) - A drawing operator that fills the current path according to the current - fill rule, (each sub-path is implicitly closed before being filled). - Args: - fill_rule (str): 'nonzero' is the non-zero winding rule and - 'evenodd' is the even-odd winding rule - preserve (bool): Preserves the path within the Context +class Stroke(Context): + """A user-created :class:`Stroke ` drawing object for a stroke context. + + A drawing operator that strokes the current path according to the + current line style settings. + + Args: + color (str, optional): Color value in any valid color format, + default to black. + line_width (float, optional): Stroke line width, default is 2.0. + + """ + + def __init__(self, color=BLACK, line_width=2.0): + super().__init__() + self._color = None + self.color = color + self.line_width = line_width + + def __repr__(self): + return "{}(color={}, line_width={})".format( + self.__class__.__name__, self.color, self.line_width + ) + + def _draw(self, impl, *args, **kwargs): + """Used by parent to draw all objects that are part of the context. """ - self._impl.new_path() - yield - if fill_rule is 'evenodd': - self._impl.fill(fill_rule, preserve) + for obj in self.drawing_objects: + obj._draw(impl, *args, **kwargs) + impl.stroke(self.color, self.line_width, *args, **kwargs) + + @property + def color(self): + return self._color + + @color.setter + def color(self, value): + if value is None: + self._color = None else: - self._impl.fill('nonzero', preserve) + self._color = parse_color(value) - @contextmanager - def stroke(self): - """Strokes the subpaths with the current stroke style - A drawing operator that strokes the current path according to the - current line style settings. +class ClosedPath(Context): + """A user-created :class:`ClosedPath ` drawing object for a + closed path context. + + Creates a new path and then closes it. + + Args: + x (float): The x axis of the beginning point. + y (float): The y axis of the beginning point. + + """ + + def __init__(self, x, y): + super().__init__() + self.x = x + self.y = y + + def __repr__(self): + return "{}(x={}, y={})".format(self.__class__.__name__, self.x, self.y) + + def _draw(self, impl, *args, **kwargs): + """Used by parent to draw all objects that are part of the context. """ - yield - self._impl.stroke() + impl.move_to(self.x, self.y, *args, **kwargs) + for obj in self.drawing_objects: + obj._draw(impl, *args, **kwargs) + impl.closed_path(self.x, self.y, *args, **kwargs) - # Transformations - def rotate(self, radians): - """Moves the transformation matrix by the angle +class Canvas(Context, Widget): + """Create new canvas. + + 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. + factory (:obj:`module`): A python module that is capable to return a + implementation of this class with the same name. (optional & + normally not needed) + """ - Modifies the current transformation matrix (CTM) by rotating the - user-space axes by angle radians. The rotation of the axes takes places - after any existing transformation of user space. The rotation center - point is always the canvas origin. To change the center point, move the - canvas by using the translate() method. + def __init__(self, id=None, style=None, factory=None): + super().__init__(id=id, style=style, factory=factory) + self._canvas = self + + # Create a platform specific implementation of Canvas + self._impl = self.factory.Canvas(interface=self) + + ########################################################################### + # Transformations of a canvas + ########################################################################### + + def rotate(self, radians): + """Constructs and returns a :class:`Rotate `. Args: - radians (float): The angle to rotate clockwise in radians + radians (float): The angle to rotate clockwise in radians. + + Returns: + :class:`Rotate ` object. """ - self._impl.rotate(radians) + rotate = Rotate(radians) + return self.add_draw_obj(rotate) def scale(self, sx, sy): - """Adds a scaling transformation to the canvas - - Modifies the current transformation matrix (CTM) by scaling the X and Y - user-space axes by sx and sy respectively. The scaling of the axes takes - place after any existing transformation of user space. + """Constructs and returns a :class:`Scale `. Args: - sx (float): scale factor for the X dimension - sy (float): scale factor for the Y dimension + sx (float): scale factor for the X dimension. + sy (float): scale factor for the Y dimension. + + Returns: + :class:`Scale ` object. """ - self._impl.scale(sx, sy) + scale = Scale(sx, sy) + return self.add_draw_obj(scale) def translate(self, tx, ty): - """Moves the canvas and its origin - - Modifies the current transformation matrix (CTM) by translating the - user-space origin by (tx, ty). This offset is interpreted as a - user-space coordinate according to the CTM in place before the new call - to translate(). In other words, the translation of the user-space origin - takes place after any existing transformation. + """Constructs and returns a :class:`Translate `. Args: - tx (float): X value of coordinate - ty (float): Y value of coordinate + tx (float): X value of coordinate. + ty (float): Y value of coordinate. + + Returns: + :class:`Translate ` object. """ - self._impl.translate(tx, ty) + translate = Translate(tx, ty) + return self.add_draw_obj(translate) def reset_transform(self): - """Reset the current transform by the identity matrix + """Constructs and returns a :class:`ResetTransform `. - Resets the current transformation Matrix (CTM) by setting it equal to - the identity matrix. That is, the user-space and device-space axes will - be aligned and one user-space unit will transform to one device-space - unit. + Returns: + :class:`ResetTransform ` object. """ - self._impl.reset_transform() + reset_transform = ResetTransform() + return self.add_draw_obj(reset_transform) - # Text - def write_text(self, text, x=0, y=0, font=None): - """Writes a given text +class MoveTo: + """A user-created :class:`MoveTo ` drawing object which moves the + start of the next operation to a point. - Writes a given text at the given (x,y) position. If no font is provided, - then it will use the font assigned to the Canvas Widget, if it exists, - or use the default font if there is no font assigned. + Moves the starting point of a new sub-path to the (x, y) coordinates. - Args: - text (string): The text to fill. - x (float, optional): The x coordinate of the text. Default to 0. - y (float, optional): The y coordinate of the text. Default to 0. - font (:class:`toga.Font`, optional): The font to write with. + + Args: + x (float): The x axis of the point. + y (float): The y axis of the point. + + """ + + def __init__(self, x, y): + self.x = x + self.y = y + + def __repr__(self): + return "{}(x={}, y={})".format(self.__class__.__name__, self.x, self.y) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.move_to(self.x, self.y, *args, **kwargs) + + +class LineTo: + """A user-created :class:`LineTo ` drawing object which draws a line + to a point. + + Connects the last point in the sub-path to the (x, y) coordinates + with a straight line (but does not actually draw it). + + Args: + x (float): The x axis of the coordinate for the end of the line. + y (float): The y axis of the coordinate for the end of the line. + + """ + + def __init__(self, x, y): + self.x = x + self.y = y + + def __repr__(self): + return "{}(x={}, y={})".format(self.__class__.__name__, self.x, self.y) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.line_to(self.x, self.y, *args, **kwargs) + + +class BezierCurveTo: + """A user-created :class:`BezierCurveTo ` drawing + object which adds a Bézier curve. + + It requires three points. The first two points are control points + and the third one is the end point. The starting point is the last + point in the current path, which can be changed using move_to() before + creating the Bézier curve. + + Args: + cp1x (float): x coordinate for the first control point. + cp1y (float): y coordinate for first control point. + cp2x (float): x coordinate for the second control point. + cp2y (float): y coordinate for the second control point. + x (float): x coordinate for the end point. + y (float): y coordinate for the end point. + + """ + + def __init__(self, cp1x, cp1y, cp2x, cp2y, x, y): + self.cp1x = cp1x + self.cp1y = cp1y + self.cp2x = cp2x + self.cp2y = cp2y + self.x = x + self.y = y + + def __repr__(self): + return "{}(cp1x={}, cp1y={}, cp2x={}, cp2y={}, x={}, y={})".format( + self.__class__.__name__, + self.cp1x, + self.cp1y, + self.cp2x, + self.cp2y, + self.x, + self.y, + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.bezier_curve_to( + self.cp1x, self.cp1y, self.cp2x, self.cp2y, self.x, self.y, *args, **kwargs + ) + + +class QuadraticCurveTo: + """A user-created :class:`QuadraticCurveTo ` drawing + object which adds a quadratic curve. + + It requires two points. The first point is a control point and the + second one is the end point. The starting point is the last point in the + current path, which can be changed using moveTo() before creating the + quadratic Bézier curve. + + Args: + cpx (float): The x axis of the coordinate for the control point. + cpy (float): The y axis of the coordinate for the control point. + x (float): The x axis of the coordinate for the end point. + y (float): he y axis of the coordinate for the end point. + + """ + + def __init__(self, cpx, cpy, x, y): + self.cpx = cpx + self.cpy = cpy + self.x = x + self.y = y + + def __repr__(self): + return "{}(cpx={}, cpy={}, x={}, y={})".format( + self.__class__.__name__, self.cpx, self.cpy, self.x, self.y + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.quadratic_curve_to(self.cpx, self.cpy, self.x, self.y, *args, **kwargs) + + +class Ellipse: + """A user-created :class:`Ellipse ` drawing object which adds an ellipse. + + The ellipse is centered at (x, y) position with the radii radiusx and radiusy + starting at startAngle and ending at endAngle going in the given + direction by anticlockwise (defaulting to clockwise). + + Args: + x (float): The x axis of the coordinate for the ellipse's center. + y (float): The y axis of the coordinate for the ellipse's center. + radiusx (float): The ellipse's major-axis radius. + radiusy (float): The ellipse's minor-axis radius. + rotation (float, optional): The rotation for this ellipse, expressed in radians, default 0.0. + startangle (float, optional): The starting point in radians, measured from the x + axis, from which it will be drawn, default 0.0. + endangle (float, optional): The end ellipse's angle in radians to which it will + be drawn, default 2*pi. + anticlockwise (bool, optional): If true, draws the ellipse anticlockwise + (counter-clockwise) instead of clockwise, default false. + + """ + + def __init__( + self, + x, + y, + radiusx, + radiusy, + rotation=0.0, + startangle=0.0, + endangle=2 * pi, + anticlockwise=False, + ): + self.x = x + self.y = y + self.radiusx = radiusx + self.radiusy = radiusy + self.rotation = rotation + self.startangle = startangle + self.endangle = endangle + self.anticlockwise = anticlockwise + + def __repr__(self): + return "{}(x={}, y={}, radiusx={}, radiusy={}, rotation={}, startangle={}, endangle={}, anticlockwise={})".format( + self.__class__.__name__, + self.x, + self.y, + self.radiusx, + self.radiusy, + self.rotation, + self.startangle, + self.endangle, + self.anticlockwise, + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.ellipse( + self.x, + self.y, + self.radiusx, + self.radiusy, + self.rotation, + self.startangle, + self.endangle, + self.anticlockwise, + *args, + **kwargs + ) + + +class Arc: + """A user-created :class:`Arc ` drawing object which adds an arc. + + The arc is centered at (x, y) position with radius r starting at startangle + and ending at endangle going in the given direction by anticlockwise + (defaulting to clockwise). + + Args: + x (float): The x coordinate of the arc's center. + y (float): The y coordinate of the arc's center. + radius (float): The arc's radius. + startangle (float, optional): The angle (in radians) at which the + arc starts, measured clockwise from the positive x axis, + default 0.0. + endangle (float, optional): The angle (in radians) at which the arc ends, + measured clockwise from the positive x axis, default 2*pi. + anticlockwise (bool, optional): If true, causes the arc to be drawn + counter-clockwise between the two angles instead of clockwise, + default false. + + """ + + def __init__( + self, x, y, radius, startangle=0.0, endangle=2 * pi, anticlockwise=False + ): + self.x = x + self.y = y + self.radius = radius + self.startangle = startangle + self.endangle = endangle + self.anticlockwise = anticlockwise + + def __repr__(self): + return "{}(x={}, y={}, radius={}, startangle={}, endangle={}, anticlockwise={})".format( + self.__class__.__name__, + self.x, + self.y, + self.radius, + self.startangle, + self.endangle, + self.anticlockwise, + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.arc( + self.x, + self.y, + self.radius, + self.startangle, + self.endangle, + self.anticlockwise, + *args, + **kwargs + ) + + +class Rect: + """A user-created :class:`Rect ` drawing object which adds a rectangle. + + The rectangle is at position (x, y) with a size that is determined by + width and height. Those four points are connected by straight lines and + the sub-path is marked as closed, so that you can fill or stroke this + rectangle. + + Args: + x (float): x coordinate for the rectangle starting point. + y (float): y coordinate for the rectangle starting point. + width (float): The rectangle's width. + height (float): The rectangle's width. + + """ + + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + def __repr__(self): + return "{}(x={}, y={}, width={}, height={})".format( + self.__class__.__name__, self.x, self.y, self.width, self.height + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.rect(self.x, self.y, self.width, self.height, *args, **kwargs) + + +class Rotate: + """A user-created :class:`Rotate ` to add canvas rotation. + + Modifies the canvas by rotating the canvas by angle radians. The rotation + center point is always the canvas origin which is in the upper left of the + canvas. To change the center point, move the canvas by using the + translate() method. + + Args: + radians (float): The angle to rotate clockwise in radians. + + """ + + def __init__(self, radians): + self.radians = radians + + def __repr__(self): + return "{}(radians={})".format(self.__class__.__name__, self.radians) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.rotate(self.radians, *args, **kwargs) + + +class Scale: + """A user-created :class:`Scale ` to add canvas scaling. + + Modifies the canvas by scaling the X and Y canvas axes by sx and sy. + + Args: + sx (float): scale factor for the X dimension. + sy (float): scale factor for the Y dimension. + + """ + + def __init__(self, sx, sy): + self.sx = sx + self.sy = sy + + def __repr__(self): + return "{}(sx={}, sy={})".format(self.__class__.__name__, self.sx, self.sy) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.scale(self.sx, self.sy, *args, **kwargs) + + +class Translate: + """A user-created :class:`Translate ` to translate the canvas. + + Modifies the canvas by translating the canvas origin by (tx, ty). + + Args: + tx (float): X value of coordinate. + ty (float): Y value of coordinate. + + """ + + def __init__(self, tx, ty): + self.tx = tx + self.ty = ty + + def __repr__(self): + return "{}(tx={}, ty={})".format(self.__class__.__name__, self.tx, self.ty) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.translate(self.tx, self.ty, *args, **kwargs) + + +class ResetTransform: + """A user-created :class:`ResetTransform ` to reset the + canvas. + + Resets the canvas by setting it equal to the canvas with no + transformations. + + """ + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.reset_transform(*args, **kwargs) + + +class WriteText: + """A user-created :class:`WriteText ` to add text. + + Writes a given text at the given (x,y) position. If no font is provided, + then it will use the font assigned to the Canvas Widget, if it exists, + or use the default font if there is no font assigned. + + Args: + text (string): The text to fill. + x (float, optional): The x coordinate of the text. Default to 0. + y (float, optional): The y coordinate of the text. Default to 0. + font (:class:`toga.Font`, optional): The font to write with. + + """ + + def __init__(self, text, x, y, font): + self.text = text + self.x = x + self.y = y + self.font = font + + def __repr__(self): + return "{}(text={}, x={}, y={}, font={})".format( + self.__class__.__name__, self.text, self.x, self.y, self.font + ) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. + + """ + impl.write_text(self.text, self.x, self.y, self.font, *args, **kwargs) + + +class NewPath: + """A user-created :class:`NewPath ` to add a new path. + + """ + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + def _draw(self, impl, *args, **kwargs): + """Draw the drawing object using the implementation. """ - self._impl.write_text(text, x, y, font) + impl.new_path(*args, **kwargs) diff --git a/src/dummy/toga_dummy/widgets/canvas.py b/src/dummy/toga_dummy/widgets/canvas.py index f30640fd79..7fa945d3b2 100644 --- a/src/dummy/toga_dummy/widgets/canvas.py +++ b/src/dummy/toga_dummy/widgets/canvas.py @@ -1,99 +1,95 @@ -import re - from .base import Widget class Canvas(Widget): def create(self): - self._action('create Canvas') - - def set_on_draw(self, handler): - self._set_value('on_draw', handler) - - def set_context(self, context): - self._set_value('context', context) - - def line_width(self, width=2.0): - self._set_value('line_width', width) - - def fill_style(self, color=None): - if color is not None: - num = re.search('^rgba\((\d*\.?\d*), (\d*\.?\d*), (\d*\.?\d*), (\d*\.?\d*)\)$', color) - if num is not None: - r = num.group(1) - g = num.group(2) - b = num.group(3) - a = num.group(4) - rgba = str(r + ', ' + g + ', ' + b + ', ' + a) - self._set_value('fill_style', rgba) - else: - pass - # Support future colosseum versions - # for named_color, rgb in colors.NAMED_COLOR.items(): - # if named_color == color: - # exec('self._set_value('fill_style', color) - else: - # set color to black - self._set_value('fill_style', '0, 0, 0, 1') - - def stroke_style(self, color=None): - self.fill_style(color) - - def close_path(self): - self._action('close path') + self._action("create Canvas") + + def redraw(self): + self._action("redraw") + self.interface._draw(self) + + # Basic paths + + def new_path(self): + self._action("new path") def closed_path(self, x, y): - self._action('closed path', x=x, y=y) + self._action("closed path", x=x, y=y) def move_to(self, x, y): - self._action('move to', x=x, y=y) + self._action("move to", x=x, y=y) def line_to(self, x, y): - self._action('line to', x=x, y=y) + self._action("line to", x=x, y=y) + + # Basic shapes def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - self._action('bezier curve to', cp1x=cp1x, cp1y=cp1y, cp2x=cp2x, cp2y=cp2y, x=x, y=y) + self._action( + "bezier curve to", cp1x=cp1x, cp1y=cp1y, cp2x=cp2x, cp2y=cp2y, x=x, y=y + ) def quadratic_curve_to(self, cpx, cpy, x, y): - self._action('quadratic curve to', cpx=cpx, cpy=cpy, x=x, y=y) + self._action("quadratic curve to", cpx=cpx, cpy=cpy, x=x, y=y) def arc(self, x, y, radius, startangle, endangle, anticlockwise): - self._action('arc', x=x, y=y, radius=radius, startangle=startangle, endangle=endangle, anticlockwise=anticlockwise) - - def ellipse(self, x, y, radiusx, radiusy, rotation, startangle, endangle, anticlockwise): - self._action('ellipse', x=x, y=y, radiusx=radiusx, radiusy=radiusy, rotation=rotation, startangle=startangle, endangle=endangle, anticlockwise=anticlockwise) + self._action( + "arc", + x=x, + y=y, + radius=radius, + startangle=startangle, + endangle=endangle, + anticlockwise=anticlockwise, + ) + + def ellipse( + self, x, y, radiusx, radiusy, rotation, startangle, endangle, anticlockwise + ): + self._action( + "ellipse", + x=x, + y=y, + radiusx=radiusx, + radiusy=radiusy, + rotation=rotation, + startangle=startangle, + endangle=endangle, + anticlockwise=anticlockwise, + ) def rect(self, x, y, width, height): - self._action('rect', x=x, y=y, width=width, height=height) + self._action("rect", x=x, y=y, width=width, height=height) # Drawing Paths - def fill(self, fill_rule, preserve): - self._set_value('fill rule', fill_rule) - if preserve: - self._action('fill preserve') - else: - self._action('fill') + def fill(self, color, fill_rule, preserve): + self._action("fill", color=color, fill_rule=fill_rule, preserve=preserve) - def stroke(self): - self._action('stroke') + def stroke(self, color, line_width): + self._action("stroke", color=color, line_width=line_width) # Transformations def rotate(self, radians): - self._action('rotate', radians=radians) + self._action("rotate", radians=radians) def scale(self, sx, sy): - self._action('scale', sx=sx, sy=sy) + self._action("scale", sx=sx, sy=sy) def translate(self, tx, ty): - self._action('translate', tx=tx, ty=ty) + self._action("translate", tx=tx, ty=ty) def reset_transform(self): - self._action('reset transform') + self._action("reset transform") + + # Text def write_text(self, text, x, y, font): - self._action('write text', text=text, x=x, y=y, font=font) + self._action("write text", text=text, x=x, y=y, font=font) + + # Rehint def rehint(self): self._action('rehint Canvas') diff --git a/src/gtk/toga_gtk/widgets/canvas.py b/src/gtk/toga_gtk/widgets/canvas.py index 18783ad21b..e85b41036a 100644 --- a/src/gtk/toga_gtk/widgets/canvas.py +++ b/src/gtk/toga_gtk/widgets/canvas.py @@ -1,5 +1,3 @@ -import re - import gi gi.require_version("Gtk", "3.0") @@ -9,7 +7,6 @@ import cairo except ImportError: cairo = None - try: gi.require_version("Pango", "1.0") from gi.repository import Pango @@ -18,10 +15,8 @@ except ImportError: SCALE = 1024 -# TODO import colosseum once updated to support colors -# from colosseum import colors - from .base import Widget +from ..color import native_color class Canvas(Widget): @@ -33,113 +28,119 @@ def create(self): self.native = Gtk.DrawingArea() self.native.interface = self.interface - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, self.native.get_allocated_width(), - self.native.get_allocated_height()) - self.native.context = cairo.Context(surface) - self.native.font = None + self.native.connect("draw", self.gtk_draw_callback) - def set_on_draw(self, handler): - self.native.connect('draw', handler) + def gtk_draw_callback(self, canvas, gtk_context): + """Creates a draw callback - def set_context(self, context): - self.native.context = context + Gtk+ uses a drawing callback to draw on a DrawingArea. Assignment of the + callback function creates a Gtk+ canvas and Gtk+ context automatically + using the canvas and gtk_context function arguments. This method calls + the draw method on the interface Canvas to draw the objects. - def line_width(self, width=2.0): - self.native.context.set_line_width(width) + """ + self.interface._draw(self, draw_context=gtk_context) - def fill_style(self, color=None): - if color is not None: - num = re.search('^rgba\((\d*\.?\d*), (\d*\.?\d*), (\d*\.?\d*), (\d*\.?\d*)\)$', color) - if num is not None: - # Convert RGB values to be a float between 0 and 1 - r = float(num.group(1)) / 255 - g = float(num.group(2)) / 255 - b = float(num.group(3)) / 255 - a = float(num.group(4)) - self.native.context.set_source_rgba(r, g, b, a) - else: - pass - # Support future colosseum versions - # for named_color, rgb in colors.NAMED_COLOR.items(): - # if named_color == color: - # exec('self.native.set_source_' + str(rgb)) - else: - # set color to black - self.native.context.set_source_rgba(0, 0, 0, 1) + def redraw(self): + pass - def stroke_style(self, color=None): - self.fill_style(color) + # Basic paths - def new_path(self): - self.native.context.new_path() + def new_path(self, draw_context): + draw_context.new_path() - def close_path(self): - self.native.context.close_path() + def closed_path(self, x, y, draw_context): + draw_context.close_path() - def move_to(self, x, y): - self.native.context.move_to(x, y) + def move_to(self, x, y, draw_context): + draw_context.move_to(x, y) - def line_to(self, x, y): - self.native.context.line_to(x, y) + def line_to(self, x, y, draw_context): + draw_context.line_to(x, y) - def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y): - self.native.context.curve_to(cp1x, cp1y, cp2x, cp2y, x, y) + # Basic shapes - def quadratic_curve_to(self, cpx, cpy, x, y): - self.native.context.curve_to(cpx, cpy, cpx, cpy, x, y) + def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y, draw_context): + draw_context.curve_to(cp1x, cp1y, cp2x, cp2y, x, y) - def arc(self, x, y, radius, startangle, endangle, anticlockwise): + def quadratic_curve_to(self, cpx, cpy, x, y, draw_context): + draw_context.curve_to(cpx, cpy, cpx, cpy, x, y) + + def arc(self, x, y, radius, startangle, endangle, anticlockwise, draw_context): if anticlockwise: - self.native.context.arc_negative(x, y, radius, startangle, endangle) + draw_context.arc_negative(x, y, radius, startangle, endangle) else: - self.native.context.arc(x, y, radius, startangle, endangle) - - def ellipse(self, x, y, radiusx, radiusy, rotation, startangle, endangle, anticlockwise): - self.native.context.save() - self.translate(x, y) + draw_context.arc(x, y, radius, startangle, endangle) + + def ellipse( + self, + x, + y, + radiusx, + radiusy, + rotation, + startangle, + endangle, + anticlockwise, + draw_context, + ): + draw_context.save() + draw_context.translate(x, y) if radiusx >= radiusy: - self.scale(1, radiusy / radiusx) - self.arc(0, 0, radiusx, startangle, endangle, anticlockwise) - elif radiusy > radiusx: - self.scale(radiusx / radiusy, 1) - self.arc(0, 0, radiusy, startangle, endangle, anticlockwise) - self.rotate(rotation) - self.reset_transform() - self.native.context.restore() - - def rect(self, x, y, width, height): - self.native.context.rectangle(x, y, width, height) + draw_context.scale(1, radiusy / radiusx) + self.arc(0, 0, radiusx, startangle, endangle, anticlockwise, draw_context) + else: + draw_context.scale(radiusx / radiusy, 1) + self.arc(0, 0, radiusy, startangle, endangle, anticlockwise, draw_context) + draw_context.rotate(rotation) + draw_context.identity_matrix() + draw_context.restore() + + def rect(self, x, y, width, height, draw_context): + draw_context.rectangle(x, y, width, height) # Drawing Paths - def fill(self, fill_rule, preserve): - if fill_rule is 'evenodd': - self.native.context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + def apply_color(self, color, draw_context): + if color is not None: + draw_context.set_source_rgba(*native_color(color)) + else: + # set color to black + draw_context.set_source_rgba(0, 0, 0, 1.0) + + def fill(self, color, fill_rule, preserve, draw_context): + self.apply_color(color, draw_context) + if fill_rule is "evenodd": + draw_context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) else: - self.native.context.set_fill_rule(cairo.FILL_RULE_WINDING) + draw_context.set_fill_rule(cairo.FILL_RULE_WINDING) if preserve: - self.native.context.fill_preserve() + draw_context.fill_preserve() else: - self.native.context.fill() + draw_context.fill() - def stroke(self): - self.native.context.stroke() + def stroke(self, color, line_width, draw_context): + self.apply_color(color, draw_context) + draw_context.set_line_width(line_width) + draw_context.stroke() # Transformations - def rotate(self, radians): - self.native.context.rotate(radians) + def rotate(self, radians, draw_context): + draw_context.rotate(radians) - def scale(self, sx, sy): - self.native.context.scale(sx, sy) + def scale(self, sx, sy, draw_context): + draw_context.scale(sx, sy) - def translate(self, tx, ty): - self.native.context.translate(tx, ty) + def translate(self, tx, ty, draw_context): + draw_context.translate(tx, ty) - def reset_transform(self): - self.native.context.identity_matrix() + def reset_transform(self, draw_context): + draw_context.identity_matrix() - def write_text(self, text, x, y, font): + # Text + + def write_text(self, text, x, y, font, draw_context): # Set font family and size if font: write_font = font @@ -147,28 +148,32 @@ def write_text(self, text, x, y, font): write_font = self.native.font write_font.family = self.native.font.get_family() write_font.size = self.native.font.get_size() / SCALE - self.native.context.select_font_face(write_font.family) - self.native.context.set_font_size(write_font.size) + draw_context.select_font_face(write_font.family) + draw_context.set_font_size(write_font.size) # Support writing multiline text for line in text.splitlines(): width, height = write_font.measure(line) - self.native.context.move_to(x, y) - self.native.context.text_path(line) + draw_context.move_to(x, y) + draw_context.text_path(line) y += height - def measure_text(self, text, font): + def measure_text(self, text, font, draw_context): # Set font family and size if font: - self.native.context.select_font_face(font.family) - self.native.context.set_font_size(font.size) + draw_context.select_font_face(font.family) + draw_context.set_font_size(font.size) elif self.native.font: - self.native.context.select_font_face(self.native.font.get_family()) - self.native.context.set_font_size(self.native.font.get_size() / SCALE) + draw_context.select_font_face(self.native.font.get_family()) + draw_context.set_font_size(self.native.font.get_size() / SCALE) - x_bearing, y_bearing, width, height, x_advance, y_advance = self.native.context.text_extents(text) + x_bearing, y_bearing, width, height, x_advance, y_advance = draw_context.text_extents( + text + ) return width, height + # Rehint + def rehint(self): # print("REHINT", self, self.native.get_preferred_width(), self.native.get_preferred_height()) width = self.native.get_preferred_width()