[gimp/gimp-2-10] Issue #4326 - Add visual tab to spyrogimp plugin



commit 81ea68660c502619c9c63117e6b3936a93f5bcd0
Author: Elad Shahar <dawn ever gmail com>
Date:   Sat Dec 14 19:39:12 2019 +0200

    Issue #4326 - Add visual tab to spyrogimp plugin

 plug-ins/pygimp/plug-ins/spyro_plus.py | 456 +++++++++++++++++++++++++++++----
 1 file changed, 407 insertions(+), 49 deletions(-)
---
diff --git a/plug-ins/pygimp/plug-ins/spyro_plus.py b/plug-ins/pygimp/plug-ins/spyro_plus.py
index f8f03ab742..42d0f087d7 100644
--- a/plug-ins/pygimp/plug-ins/spyro_plus.py
+++ b/plug-ins/pygimp/plug-ins/spyro_plus.py
@@ -22,8 +22,9 @@ import gimpplugin
 import gimpui
 import gobject
 import gtk
+gdk = gtk.gdk
 
-from math import pi, sin, cos, atan, atan2, fmod, radians
+from math import pi, sin, cos, atan, atan2, fmod, radians, sqrt
 import gettext
 import fractions
 import time
@@ -43,7 +44,7 @@ two_pi, half_pi = 2 * pi, pi / 2
 layer_name = _("Spyro Layer")
 
 # "Enums"
-GEAR_NOTATION, TOY_KIT_NOTATION = range(2)       # Pattern notations
+GEAR_NOTATION, TOY_KIT_NOTATION, VISUAL_NOTATION = range(3)       # Pattern notations
 
 # Mapping of pattern notation to the corresponding tab in the pattern notation notebook.
 pattern_notation_page = {}
@@ -61,6 +62,11 @@ wheel = [
 wheel_teeth = [wh[0] for wh in wheel]
 
 
+def lcm(a, b):
+    """ Least common multiplier """
+    return a * b // fractions.gcd(a, b)
+
+
 ### Shapes
 
 
@@ -69,7 +75,7 @@ class CanRotateShape:
 
 
 class Shape:
-    def configure(self, img, pp, cp, drawing_no):
+    def configure(self, img, pp, cp):
         self.image, self.pp, self.cp = img, pp, cp
 
     def can_equal_w_h(self):
@@ -103,8 +109,8 @@ class CircleShape(Shape):
 
 class SidedShape(CanRotateShape, Shape):
 
-    def configure(self, img, pp, cp, drawing_no):
-        Shape.configure(self, img, pp, cp, drawing_no)
+    def configure(self, img, pp, cp):
+        Shape.configure(self, img, pp, cp)
         self.angle_of_each_side = two_pi / pp.sides
         self.half_angle = self.angle_of_each_side / 2.0
         self.cos_half_angle = cos(self.half_angle)
@@ -255,8 +261,8 @@ class AbstractShapeFromParts(Shape):
 class RackShape(CanRotateShape, AbstractShapeFromParts):
     name = _("Rack")
 
-    def configure(self, img, pp, cp, drawing_no):
-        Shape.configure(self, img, pp, cp, drawing_no)
+    def configure(self, img, pp, cp):
+        Shape.configure(self, img, pp, cp)
 
         round_teeth = 12
         side_teeth = (cp.fixed_gear_teeth - 2 * round_teeth) / 2
@@ -299,8 +305,8 @@ class RackShape(CanRotateShape, AbstractShapeFromParts):
 class FrameShape(AbstractShapeFromParts):
     name = _("Frame")
 
-    def configure(self, img, pp, cp, drawing_no):
-        Shape.configure(self, img, pp, cp, drawing_no)
+    def configure(self, img, pp, cp):
+        Shape.configure(self, img, pp, cp)
 
         x1, x2 = cp.x1 + cp.moving_gear_radius, cp.x2 - cp.moving_gear_radius
         y1, y2 = cp.y1 + cp.moving_gear_radius, cp.y2 - cp.moving_gear_radius
@@ -397,11 +403,11 @@ class SelectionShape(Shape):
         else:
             self.path.regenerate_path_if_selection_changed()
 
-    def configure(self, img, pp, cp, drawing_no):
+    def configure(self, img, pp, cp):
         """ Set bounds of pattern """
-        Shape.configure(self, img, pp, cp, drawing_no)
-        self.drawing_no = drawing_no
-        self.path.set_current_stroke(drawing_no)
+        Shape.configure(self, img, pp, cp)
+        self.drawing_no = cp.current_drawing
+        self.path.set_current_stroke(self.drawing_no)
 
     def get_num_drawings(self):
         return self.path.get_num_strokes()
@@ -619,6 +625,7 @@ class PatternParameters:
         # A value of 100 means the edge of the wheel.
         if not hasattr(self, 'hole_percent'):
             self.hole_percent = 100.0
+
         # Toy Kit parameters
         # Hole number in Toy Kit notation. Hole #1 is at the edge of the wheel, and the last hole is
         # near the center of the wheel, but not exactly at the center.
@@ -629,6 +636,16 @@ class PatternParameters:
         if not hasattr(self, 'kit_moving_gear_index'):
             self.kit_moving_gear_index = 1
 
+        # Visual notation parameters
+        if not hasattr(self, 'petals'):
+            self.petals = 5
+        if not hasattr(self, 'petal_skip'):
+            self.petal_skip = 2
+        if not hasattr(self, 'doughnut_hole'):
+            self.doughnut_hole = 50.0
+        if not hasattr(self, 'doughnut_width'):
+            self.doughnut_width = 50.0
+
         # Shape
         if not hasattr(self, 'shape_index'):
             self.shape_index = 0  # Index in the shapes array
@@ -678,12 +695,12 @@ class ComputedParameters:
     The results of these computations are used to perform the drawing.
     Having all these computations in one place makes it convenient to pass
     around as a parameter.
+
+    If the pattern parameters should result in multiple pattern to be drawn, the
+    compute parameters also stores which one is currently being drawn.
     """
-    def __init__(self, pp, x1, y1, x2, y2):
 
-        def lcm(a, b):
-            """ Least common multiplier """
-            return a * b // fractions.gcd(a, b)
+    def __init__(self, pp, img):
 
         def compute_gradients():
             self.use_gradient = self.pp.long_gradient and tools[self.pp.tool_index].can_color
@@ -732,30 +749,77 @@ class ComputedParameters:
             # Find the distance between the hole and the center of the inner circle.
             # To do this, we compute the size of the gears, by the number of teeth.
             # The circumference of the outer ring is 2 * pi * outer_R = #fixed_gear_teeth * tooth size.
-            self.outer_R = min(self.x_half_size, self.y_half_size)
-            size_of_tooth_in_pixels = two_pi * self.outer_R / self.fixed_gear_teeth
-            self.moving_gear_radius = size_of_tooth_in_pixels * self.moving_gear_teeth / two_pi
+            outer_R = min(self.x_half_size, self.y_half_size)
+            if self.pp.pattern_notation == VISUAL_NOTATION:
+                doughnut_width = self.pp.doughnut_width
+                if doughnut_width + self.pp.doughnut_hole > 100:
+                    doughnut_width = 100.0 - self.pp.doughnut_hole
+
+                # Let R, r be the radius of fixed and moving gear, and let hp be the hole percent.
+                # Let dwp, dhp be the doughnut width and hole in percents of R.
+                # The two sides of the following equation calculate how to reach the center of the moving
+                # gear from the center of the fixed gear:
+                #  I)     R * (dhp/100 + dwp/100/2) = R - r
+                # The following equation expresses which r and hp would generate a doughnut of width dw.
+                #  II)    R * dw/100 = 2 * r * hp/100
+                # We solve the two above equations to calculate hp and r:
+                self.hole_percent = doughnut_width / (2.0 * (1 - (self.pp.doughnut_hole + 
doughnut_width/2.0)/100.0))
+                self.moving_gear_radius = outer_R * doughnut_width / (2 * self.hole_percent)
+            else:
+                size_of_tooth_in_pixels = two_pi * outer_R / self.fixed_gear_teeth
+                self.moving_gear_radius = size_of_tooth_in_pixels * self.moving_gear_teeth / two_pi
+
             self.hole_dist_from_center = self.hole_percent / 100.0 * self.moving_gear_radius
 
         self.pp = pp
 
+        # Check if the shape is made of multiple shapes, as in using Selection as fixed gear.
+        if (isinstance(shapes[self.pp.shape_index], SelectionShape) and
+            curve_types[self.pp.curve_type].supports_shapes()):
+            shapes[self.pp.shape_index].process_selection(img)
+            pdb.gimp_displays_flush()
+            self.num_drawings = shapes[self.pp.shape_index].get_num_drawings()
+        else:
+            self.num_drawings = 1
+        self.current_drawing = 0
+
+        # Get bounds. We don't care weather a selection exists or not.
+        exists, x1, y1, x2, y2 = pdb.gimp_selection_bounds(img)
+
         # Combine different ways to specify patterns, into a unified set of computed parameters.
+        self.num_notation_drawings = 1
+        self.current_notation_drawing = 0
         if self.pp.pattern_notation == GEAR_NOTATION:
             self.fixed_gear_teeth = int(round(pp.outer_teeth))
             self.moving_gear_teeth = int(round(pp.inner_teeth))
+            self.petals = self.num_petals()
             self.hole_percent = pp.hole_percent
         elif self.pp.pattern_notation == TOY_KIT_NOTATION:
             self.fixed_gear_teeth = ring_teeth[pp.kit_fixed_gear_index]
             self.moving_gear_teeth = wheel[pp.kit_moving_gear_index][0]
+            self.petals = self.num_petals()
             # We want to map hole #1 to 100% and hole of max_hole_number to 2.5%
             # We don't want 0% because that would be the exact center of the moving gear,
             # and that would create a boring pattern.
             max_hole_number = wheel[pp.kit_moving_gear_index][1]
             self.hole_percent = (max_hole_number - pp.hole_number) / float(max_hole_number - 1) * 97.5 + 2.5
+        elif self.pp.pattern_notation == VISUAL_NOTATION:
+            self.petals = pp.petals
+            self.fixed_gear_teeth = pp.petals
+            self.moving_gear_teeth = pp.petals - pp.petal_skip
+            if self.moving_gear_teeth < 20:
+                self.fixed_gear_teeth *= 10
+                self.moving_gear_teeth *= 10
+            self.hole_percent = 100.0
+            self.num_notation_drawings = fractions.gcd(pp.petals, pp.petal_skip)
+            self.notation_drawings_rotation = two_pi/pp.petals
 
         # Rotations
         self.shape_rotation_radians = self.radians_from_degrees(pp.shape_rotation)
-        self.pattern_rotation_radians = self.radians_from_degrees(pp.pattern_rotation)
+        self.pattern_rotation_start_radians = self.radians_from_degrees(pp.pattern_rotation)
+        self.pattern_rotation_radians = self.pattern_rotation_start_radians
+        # Additional fixed pattern rotation for lissajous.
+        self.lissajous_rotation = two_pi/self.petals/4.0
 
         # Compute the total number of teeth we have to go over.
         # Another way to view it is the total of lines we are going to draw.
@@ -785,6 +849,10 @@ class ComputedParameters:
 
         compute_sizes()
 
+    def num_petals(self):
+        """ The number of 'petals' (or points) that will be produced by a spirograph drawing. """
+        return lcm(self.fixed_gear_teeth, self.moving_gear_teeth) / self.moving_gear_teeth
+
     def radians_from_degrees(self, degrees):
         positive_degrees = degrees if degrees >= 0 else degrees + 360
         return radians(positive_degrees)
@@ -792,6 +860,23 @@ class ComputedParameters:
     def get_color(self, n):
         return self.gradients[4*n:4*(n+1)]
 
+    def next_drawing(self):
+        """ Multiple drawings can be drawn either when the selection is used as a fixed
+            gear, and/or the visual tab is used, which causes multiple drawings
+            to be drawn at different rotations. """
+        if self.current_notation_drawing < self.num_notation_drawings - 1:
+            self.current_notation_drawing += 1
+            self.pattern_rotation_radians = self.pattern_rotation_start_radians + (
+                    self.current_notation_drawing * self.notation_drawings_rotation)
+        else:
+            self.current_drawing += 1
+            self.current_notation_drawing = 0
+            self.pattern_rotation_radians = self.pattern_rotation_start_radians
+
+    def has_more_drawings(self):
+        return (self.current_notation_drawing < self.num_notation_drawings - 1 or
+                self.current_drawing < self.num_drawings - 1)
+
 
 ### Curve types
 
@@ -806,7 +891,7 @@ class RouletteCurveType(CurveType):
     def get_strokes(self, p, cp):
         strokes = []
         for curr_tooth in range(cp.num_points):
-            iangle = curr_tooth * cp.iangle_factor
+            iangle = fmod(curr_tooth * cp.iangle_factor + cp.pattern_rotation_radians, two_pi)
             oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi)
 
             x, y = shapes[p.shape_index].get_center_of_moving_gear(oangle)
@@ -860,7 +945,10 @@ class LissaCurveType:
         strokes = []
         for curr_tooth in range(cp.num_points):
             iangle = curr_tooth * cp.iangle_factor
-            oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians, two_pi)
+            # Adding the cp.lissajous_rotation rotation makes the pattern have the same number of curves
+            # as the other curve types. Without it, many lissajous patterns would redraw the same lines 
twice,
+            # and thus look less dense than the other curves.
+            oangle = fmod(curr_tooth * cp.oangle_factor + cp.pattern_rotation_radians + 
cp.lissajous_rotation, two_pi)
 
             strokes.append(cp.x_center + cp.x_half_size * cos(oangle))
             strokes.append(cp.y_center + cp.y_half_size * cos(iangle))
@@ -881,12 +969,10 @@ class DrawingEngine:
     def __init__(self, img, p):
         self.img, self.p = img, p
         self.cp = None
-        self.num_drawings = 0
 
         # For incremental drawing
         self.strokes = []
         self.start = 0
-        self.current_drawing = 0
         self.chunk_size_lines = 600
         self.chunk_no = 0
         # We are aiming for the drawing time of a chunk to be no longer than max_time.
@@ -897,19 +983,7 @@ class DrawingEngine:
     def pre_draw(self):
         """ Needs to be called before starting to draw a pattern. """
 
-        self.current_drawing = 0
-
-        if isinstance(shapes[self.p.shape_index], SelectionShape) and 
curve_types[self.p.curve_type].supports_shapes():
-            shapes[self.p.shape_index].process_selection(self.img)
-            pdb.gimp_displays_flush()
-            self.num_drawings = shapes[self.p.shape_index].get_num_drawings()
-        else:
-            self.num_drawings = 1
-
-        # Get bounds. We don't care weather a selection exists or not.
-        exists, x1, y1, x2, y2 = pdb.gimp_selection_bounds(self.img)
-
-        self.cp = ComputedParameters(self.p, x1, y1, x2, y2)
+        self.cp = ComputedParameters(self.p, self.img)
 
     def draw_full(self, layer):
         """ Non incremental drawing. """
@@ -917,8 +991,7 @@ class DrawingEngine:
         self.pre_draw()
         self.img.undo_group_start()
 
-        for drawing_no in range(self.num_drawings):
-            self.current_drawing = drawing_no
+        while true:
             self.set_strokes()
 
             if self.cp.use_gradient:
@@ -927,6 +1000,11 @@ class DrawingEngine:
             else:
                 tools[self.p.tool_index].draw(layer, self.strokes)
 
+            if self.cp.has_more_drawings():
+                self.cp.next_drawing()
+            else:
+                break
+
         self.img.undo_group_end()
 
         pdb.gimp_displays_flush()
@@ -941,7 +1019,7 @@ class DrawingEngine:
     def set_strokes(self):
         """ Compute the strokes of the current pattern. The heart of the plugin. """
 
-        shapes[self.p.shape_index].configure(self.img, self.p, self.cp, drawing_no=self.current_drawing)
+        shapes[self.p.shape_index].configure(self.img, self.p, self.cp)
 
         self.strokes = curve_types[self.p.curve_type].get_strokes(self.p, self.cp)
 
@@ -974,8 +1052,8 @@ class DrawingEngine:
 
         # If self.strokes has ended, lets fetch strokes for the next drawing.
         if fetch_next_drawing and not self.has_more_strokes():
-            self.current_drawing += 1
-            if self.current_drawing < self.num_drawings:
+            if self.cp.has_more_drawings():
+                self.cp.next_drawing()
                 self.set_strokes()
 
         return result, color
@@ -1000,6 +1078,184 @@ class DrawingEngine:
             self.chunk_size_lines = min(1000, self.chunk_size_lines)
 
 
+# Constants for DoughnutWidget
+
+# Enum - When the mouse is pressed, which target value is being changed.
+TARGET_NONE, TARGET_HOLE, TARGET_WIDTH = range(3)
+
+CIRCLE_CENTER_X = 4
+RIGHT_MARGIN = 2
+TOTAL_MARGIN = CIRCLE_CENTER_X + RIGHT_MARGIN
+
+# A widget for displaying and setting the pattern of a spirograph, using a "doughnut" as
+# a visual metaphore.  This widget replaces two scale widgets.
+class DoughnutWidget(gtk.DrawingArea):
+    __gtype_name__ = 'DoughnutWidget'
+
+    def __init__(self, *args, **kwds):
+        super(DoughnutWidget, self).__init__(*args, **kwds)
+        self.set_size_request(80, 40)
+
+        self.add_events(
+            gdk.BUTTON1_MOTION_MASK |
+            gdk.BUTTON_PRESS_MASK |
+            gdk.BUTTON_RELEASE_MASK |
+            gdk.POINTER_MOTION_MASK
+        )
+
+        self.default_cursor = self.get_screen().get_root_window().get_cursor()
+        self.resize_cursor = gdk.Cursor(gdk.SB_H_DOUBLE_ARROW)
+
+        self.button_pressed = False
+        self.target = TARGET_NONE
+
+        self.hole_radius = 30
+        self.doughnut_width = 30
+        self.connect("expose-event", self.expose)
+
+    def set_hole_radius(self, hole_radius):
+        self.queue_draw()
+        self.hole_radius = hole_radius
+
+    def get_hole_radius(self):
+        return self.hole_radius
+
+    def set_width(self, width):
+        self.queue_draw()
+        self.doughnut_width = width
+
+    def get_width(self):
+        return self.doughnut_width
+
+    def compute_doughnut(self):
+        """ Compute the location of the doughnut circles.
+            Returns (circle center x, circle center y, radius of inner circle, radius of outer circle) """
+        allocation = self.get_allocation()
+        alloc_width = allocation.width - TOTAL_MARGIN
+        return (
+            CIRCLE_CENTER_X, allocation.height / 2,
+            alloc_width * self.hole_radius / 100.0,
+            alloc_width * min(self.hole_radius + self.doughnut_width, 100.0) / 100.0
+        )
+
+    def set_cursor_h_resize(self):
+        """Set the mouse to be a double arrow."""
+        gdk_window = self.get_window()
+        gdk_window.set_cursor(self.resize_cursor)
+
+    def set_default_cursor(self):
+        gdk_window = self.get_window()
+        gdk_window.set_cursor(self.default_cursor)
+
+    def get_target(self, x, y):
+        # Find out if x, y is over one of the circle edges.
+
+        center_x, center_y, hole_radius, outer_radius = self.compute_doughnut()
+
+        # Compute distance from circle center to point
+        dist = sqrt((center_x - x) ** 2 + (center_y - y) ** 2)
+
+        if abs(dist - hole_radius) <= 3:
+            return TARGET_HOLE
+        if abs(dist - outer_radius) <= 3:
+            return TARGET_WIDTH
+
+        return TARGET_NONE
+
+    def expose(self, widget, event):
+
+        cr = widget.window.cairo_create()
+        center_x, center_y, hole_radius, outer_radius = self.compute_doughnut()
+        fg_color = gtk.widget_get_default_style().fg[gtk.STATE_NORMAL]
+
+        # Draw doughnut interior
+        arc = pi * 3 / 2.0
+        cr.set_source_rgba(fg_color.red, fg_color.green, fg_color.blue, 0.5)
+        cr.arc(center_x, center_y, hole_radius, -arc, arc)
+        cr.arc_negative(center_x, center_y, outer_radius, arc, -arc)
+        cr.close_path()
+        cr.fill()
+
+        # Draw doughnut border.
+        cr.set_source_rgb(fg_color.red, fg_color.green, fg_color.blue)
+        cr.set_line_width(3)
+        cr.arc_negative(center_x, center_y, outer_radius, arc, -arc)
+        cr.stroke()
+        if hole_radius < 1.0:
+            # If the radius is too small, nothing will be drawn, so draw a small cross marker instead.
+            cr.set_line_width(2)
+            cr.move_to(center_x - 4, center_y)
+            cr.line_to(center_x + 4, center_y)
+            cr.move_to(center_x, center_y - 4)
+            cr.line_to(center_x, center_y + 4)
+        else:
+            cr.arc(center_x, center_y, hole_radius, -arc, arc)
+        cr.stroke()
+
+    def compute_new_radius(self, x):
+        """ This method is called during mouse dragging of the widget.
+            Compute the new radius based on the current x location of the mouse pointer. """
+        allocation = self.get_allocation()
+
+        # How much does a single pixel difference in x, change the radius?
+        # Note that: allocation.width - TOTAL_MARGIN = 100 radius units,
+        radius_per_pixel = 100.0 / (allocation.width - TOTAL_MARGIN)
+        new_radius = self.start_radius + (x - self.start_x) * radius_per_pixel
+
+        if self.target == TARGET_HOLE:
+            self.hole_radius = max(min(new_radius, 99.0), 0.0)
+        else:
+            self.doughnut_width = max(min(new_radius, 100.0), 1.0)
+
+        self.queue_draw()
+
+    def do_button_press_event(self, event):
+        self.button_pressed = True
+
+        # If we clicked on one of the doughnut borders, remember which
+        # border we clicked on, and setup variable to start dragging it.
+        target = self.get_target(event.x, event.y)
+        if target == TARGET_HOLE or target == TARGET_WIDTH:
+            self.target = target
+            self.start_x = event.x
+            self.start_radius = (
+                self.hole_radius if target == TARGET_HOLE else
+                self.doughnut_width
+            )
+
+    def do_button_release_event(self, event):
+        # If one the doughnut borders was being dragged, recompute the doughnut size.
+        if self.target != TARGET_NONE:
+            self.compute_new_radius(event.x)
+            # Clip the width, if it is too large to fit.
+            if self.hole_radius + self.doughnut_width > 100:
+                self.doughnut_width = 100 - self.hole_radius
+            self.emit("values_changed", self.hole_radius, self.doughnut_width)
+
+        self.button_pressed = False
+        self.target = TARGET_NONE
+
+    def do_motion_notify_event(self, event):
+        if self.button_pressed:
+            # We are dragging one of the doughnut borders; recompute its size.
+            if self.target != TARGET_NONE:
+                self.compute_new_radius(event.x)
+        else:
+            # Set cursor according to whether we are over one of the
+            # doughnut borders.
+            target = self.get_target(event.x, event.y)
+            if target == TARGET_NONE:
+                self.set_default_cursor()
+            else:
+                self.set_cursor_h_resize()
+
+
+# Create signal that returns change parameters.
+gobject.type_register(DoughnutWidget)
+gobject.signal_new("values_changed", DoughnutWidget, gobject.SIGNAL_RUN_LAST,
+                   gobject.TYPE_NONE, (gobject.TYPE_INT, gobject.TYPE_INT))
+
+
 class SpyroWindow(gtk.Window):
 
     # Define signal to catch escape key.
@@ -1042,22 +1298,33 @@ class SpyroWindow(gtk.Window):
             table.set_row_spacings(10)
             return table
 
-        def label_in_table(label_text, table, row, tooltip_text=None):
+        def label_in_table(label_text, table, row, tooltip_text=None, col=0, col_add=1):
             """ Create a label and set it in first col of table. """
             label = gtk.Label(label_text)
             label.set_alignment(xalign=0.0, yalign=1.0)
             if tooltip_text:
                 label.set_tooltip_text(tooltip_text)
-            table.attach(label, 0, 1, row, row + 1, xoptions=gtk.FILL, yoptions=0)
+            table.attach(label, col, col + col_add, row, row + 1, xoptions=gtk.FILL, yoptions=0)
             label.show()
 
-        def hscale_in_table(adj, table, row, callback, digits=0):
+        def spin_in_table(adj, table, row, callback, digits=0, col=0):
+            spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits)
+            spin.set_numeric(True)
+            spin.set_snap_to_ticks(True)
+            spin.set_max_length(5)
+            spin.set_width_chars(5)
+            table.attach(spin, col, col + 1, row, row + 1, xoptions=0, yoptions=0)
+            spin.show()
+            adj.connect("value_changed", callback)
+            return spin
+
+        def hscale_in_table(adj, table, row, callback, digits=0, col=1, cols=1):
             """ Create an hscale and a spinner using the same Adjustment, and set it in table. """
             scale = gtk.HScale(adj)
             scale.set_size_request(150, -1)
             scale.set_digits(digits)
             scale.set_update_policy(gtk.UPDATE_DISCONTINUOUS)
-            table.attach(scale, 1, 2, row, row + 1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0)
+            table.attach(scale, col, col + cols, row, row + 1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0)
             scale.show()
 
             spin = gtk.SpinButton(adj, climb_rate=0.5, digits=digits)
@@ -1065,7 +1332,7 @@ class SpyroWindow(gtk.Window):
             spin.set_snap_to_ticks(True)
             spin.set_max_length(5)
             spin.set_width_chars(5)
-            table.attach(spin, 2, 3, row, row + 1, xoptions=0, yoptions=0)
+            table.attach(spin, col + cols , col + cols + 1, row, row + 1, xoptions=0, yoptions=0)
             spin.show()
 
             adj.connect("value_changed", callback)
@@ -1201,7 +1468,50 @@ class SpyroWindow(gtk.Window):
             self.kit_hole_adj = gtk.Adjustment(self.p.hole_number, 1, self.p.kit_max_hole_number(), 1)
             self.kit_hole_myscale = hscale_in_table(self.kit_hole_adj, kit_table, row, self.kit_hole_changed)
 
-            # Add tables as childs of the pattern notebook
+            # "Visual" pattern notation.
+
+            visual_table = create_table(3, 5, 5)
+
+            row = 0
+            label_in_table(_("Flower Petals"), visual_table, row, _("The number of petals in the pattern."))
+            self.petals_adj = gtk.Adjustment(self.p.petals, 2, 100, 1)
+            hscale_in_table(self.petals_adj, visual_table, row, self.petals_changed, cols=3)
+
+            row += 1
+            label_in_table(_("Petal Skip"), visual_table, row,
+                           _("The number of petals to advance for drawing the next petal."))
+            self.petal_skip_adj = gtk.Adjustment(self.p.petal_skip, 1, 50, 1)
+            hscale_in_table(self.petal_skip_adj, visual_table, row, self.petal_skip_changed, cols=3)
+
+            row += 1
+            label_in_table(_("Hole Radius(%)"), visual_table, row,
+                           _("The radius of the hole in the center of the pattern "
+                             "where nothing will be drawn. Given as a percentage of the "
+                             "size of the pattern. A value of 0 will produce no hole. "
+                             "A Value of 99 will produce a thin line on the edge."))
+            self.doughnut_hole_adj = gtk.Adjustment(self.p.doughnut_hole, 0.0, 99.0, 0.1)
+            self.doughnut_hole_myscale = spin_in_table(self.doughnut_hole_adj,
+                                                       visual_table, row, self.doughnut_hole_changed, 1, 1)
+
+            self.doughnut = DoughnutWidget()
+            visual_table.attach(self.doughnut, 2, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=0)
+            self.doughnut.connect('values_changed', self.doughnut_changed)
+            self.doughnut.show()
+
+            label_in_table(_("Width(%)"), visual_table, row,
+                           _("The width of the pattern as a percentage of the "
+                             "size of the pattern. A Value of 1 will just draw a thin pattern. "
+                             "A Value of 100 will fill the entire fixed gear."), 3)
+            self.doughnut_width_adj = gtk.Adjustment(self.p.doughnut_width, 1.0, 100.0, 0.1)
+            self.doughnut_width_myscale = spin_in_table(self.doughnut_width_adj,
+                                                        visual_table, row, self.doughnut_width_changed, 1, 4)
+
+            # Add tables as children of the pattern notebook
+
+            pattern_notation_page[VISUAL_NOTATION] = self.pattern_notebook.append_page(visual_table)
+            self.pattern_notebook.set_tab_label_text(visual_table, _("Visual"))
+            self.pattern_notebook.set_tab_label_packing(visual_table, 0, 0, gtk.PACK_END)
+            visual_table.show()
 
             pattern_notation_page[TOY_KIT_NOTATION] = self.pattern_notebook.append_page(kit_table)
             self.pattern_notebook.set_tab_label_text(kit_table, _("Toy Kit"))
@@ -1500,6 +1810,14 @@ class SpyroWindow(gtk.Window):
         self.kit_hole_adj.set_value(self.p.hole_number)
         self.kit_inner_teeth_combo_side_effects()
 
+        self.petals_adj.set_value(self.p.petals)
+        self.petal_skip_adj.set_value(self.p.petal_skip)
+        self.doughnut_hole_adj.set_value(self.p.doughnut_hole)
+        self.doughnut.set_hole_radius(self.p.doughnut_hole)
+        self.doughnut_width_adj.set_value(self.p.doughnut_width)
+        self.doughnut.set_width(self.p.doughnut_width)
+        self.petals_changed_side_effects()
+
         self.shape_combo.set_active(self.p.shape_index)
         self.shape_combo_side_effects()
         self.sides_adj.set_value(self.p.sides)
@@ -1528,6 +1846,9 @@ class SpyroWindow(gtk.Window):
 
             self.hole_percent_myscale.set_sensitive(True)
             self.kit_hole_myscale.set_sensitive(True)
+
+            self.doughnut_hole_myscale.set_sensitive(True)
+            self.doughnut_width_myscale.set_sensitive(True)
         else:
             # Lissajous curves do not have shapes, or holes for moving gear
             self.shape_combo.set_sensitive(False)
@@ -1539,6 +1860,9 @@ class SpyroWindow(gtk.Window):
             self.hole_percent_myscale.set_sensitive(False)
             self.kit_hole_myscale.set_sensitive(False)
 
+            self.doughnut_hole_myscale.set_sensitive(False)
+            self.doughnut_width_myscale.set_sensitive(False)
+
     def curve_type_changed(self, val):
         self.p.curve_type = val.get_active()
         self.curve_type_side_effects()
@@ -1594,6 +1918,40 @@ class SpyroWindow(gtk.Window):
         self.p.pattern_rotation = val.value
         self.redraw()
 
+    # Callbacks: pattern changes using the Visual notation.
+
+    def petals_changed_side_effects(self):
+        max_petal_skip = int(self.p.petals/2)
+        if self.p.petal_skip > max_petal_skip:
+            self.p.petal_skip = max_petal_skip
+            self.petal_skip_adj.set_value(max_petal_skip)
+        self.petal_skip_adj.set_upper(max_petal_skip)
+
+    def petals_changed(self, val):
+        self.p.petals = int(val.value)
+        self.petals_changed_side_effects()
+        self.redraw()
+
+    def petal_skip_changed(self, val):
+        self.p.petal_skip = int(val.value)
+        self.redraw()
+
+    def doughnut_hole_changed(self, val):
+        self.p.doughnut_hole = val.value
+        self.doughnut.set_hole_radius(val.value)
+        self.redraw()
+
+    def doughnut_width_changed(self, val):
+        self.p.doughnut_width = val.value
+        self.doughnut.set_width(val.value)
+        self.redraw()
+
+    def doughnut_changed(self, widget, hole, width):
+        self.doughnut_hole_adj.set_value(hole)
+        self.doughnut_width_adj.set_value(width)
+        # We don't need to redraw, because the callbacks of the doughnut hole and
+        # width spinners will be triggered by the above lines.
+
     # Callbacks: Fixed gear
 
     def shape_combo_side_effects(self):


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]