Eoin Brennan
Eoin Brennan

Reputation: 151

Kivy Custom Shape Button still is rectangular widgt

I am trying to create a number of irregular rectangular buttons stacked on top of each other. I have achieved most of the task and the code is below, the code creates and saves .pngs for the various shapes then uses these shapes in the kivy app. I got the sizing and positioning the way I want it but the problem I am having is that the widget that is the image seems to be rectangular, so therefore the widgets overlap even though the shapes dont. The problem that this creates is the the on_press() function is not always triggering the correct button especially near the edges as can be seen by the image here. (The red box was hand drawn on the screenshot to show the shape of the widget!)

ScreenShot

The idea I am trying to achieve is that pressing inside the shape will active the button. Any ideas how I can reduce the area to just inside the shape.

Thanks

from PIL import Image, ImageDraw
from kivy.app import App
from kivy.uix.image import Image as Img
from kivy.uix.behaviors import ButtonBehavior
from kivy.uix.floatlayout import FloatLayout


def calculate_hw(tuple_list, ht = False, wt = False):
    'using the coordinates of the shapes, returns the height and/or width'

    if not tuple_list:
        return None
    
    min_x = min(tuple_list, key=lambda tup: tup[0])[0]
    max_x = max(tuple_list, key=lambda tup: tup[0])[0]
    
    min_y = min(tuple_list, key=lambda tup: tup[1])[1]
    max_y = max(tuple_list, key=lambda tup: tup[1])[1]
    
    h = max_y - min_y
    w = max_x - min_x
    
    if ht and wt:
        return h, w
    elif ht:
        return h
    elif wt:
        return w

def calculate_y_difference(tuple_list):
    '''
    returns the height of the objecy at its widest part
    used to calculate the value of the pos_y of the next button
    '''

    sorted_by_x = sorted(tuple_list, key=lambda tup: tup[0], reverse=True)    
    y_diff = sorted_by_x[0][1] - sorted_by_x[1][1]    
    return abs(y_diff)

shapes = {
    '1': [(500.0, 0.0), (500.0, 93.38888888888945), (0.0, 25.0), (0.0, 0.0), (500.0, 0.0)], 
    '2': [(500.0, 93.38888888888945), (500.0, 133.33333333333357), (0.0, 75.0), (0.0, 25.0), (500.0, 93.38888888888945)], 
    '3': [(500.0, 133.33333333333357), (500.0, 188.3333333333333), (0.0, 125.0), (0.0, 75.0), (500.0, 133.33333333333357)]}

shapes_height = {k: calculate_hw(shapes[k], ht = True) for k in shapes.keys() }
shape_pos_height = {k: calculate_y_difference(shapes[k]) for k in shapes.keys() }
shape_width = {k: calculate_hw(shapes[k], wt = True) for k in shapes.keys() }

#Loop through each shape, create it, crop it and save it as a .png
for shape_name, vertices in shapes.items():
    # Create a new transparent image
    width, height = 4000, 4000
    background_color = (0, 0, 0, 0)  # Transparent background

    image_normal = Image.new("RGBA", (width, height), background_color)
    image_down = Image.new("RGBA", (width, height), background_color)

    # Create a drawing object
    draw_normal = ImageDraw.Draw(image_normal)
    draw_down = ImageDraw.Draw(image_down)

    # Draw the irregular shape
    draw_normal.polygon(vertices, ) #fill=(255, 0, 0, 128))  # Fill color with transparency
    draw_down.polygon(vertices, fill=(0, 255, 0, 128))  # Fill color with transparency

    # Find the bounding box of the polygon
    min_x = min(point[0] for point in vertices)
    min_y = min(point[1] for point in vertices)
    max_x = max(point[0] for point in vertices)
    max_y = max(point[1] for point in vertices)

    #crop the image
    image_normal = image_normal.crop((min_x, min_y, max_x, max_y))
    image_down = image_down.crop((min_x, min_y, max_x, max_y))

    # Save the image as a PNG file
    image_normal.save(f"{shape_name}.png", "PNG", dpi=(2000, 2000), resolution_unit="in")
    image_down.save(f"{shape_name}_down.png", "PNG", dpi=(2000, 2000), resolution_unit="in")


class MyButton(ButtonBehavior, Img):
    '''
    a very basic button with an image used instead of a Label
    '''
    def __init__(self,normal, down, **kwargs):
        super(MyButton, self).__init__(**kwargs)

        self.normal = normal
        self.down = down
        self.source = self.normal

    def on_press(self):
        self.source = self.down
    def on_release(self):
        self.source = self.normal


class Main(FloatLayout):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        
        y = 300    
        for shape in shapes:                #Loop through the shape names
            y -= shape_pos_height[shape]    #the y pos of the first shape, this is reduced by the width of the shape to achieve a stacking effect
            
            #add the custom budget
            self.add_widget(MyButton(
                down = f'{shape}_down.png', 
                normal = f'{shape}.png', 
                size_hint = (None, None),
                size = (shape_width[shape], shapes_height[shape]),
                pos = (100,y)  ))


class MainApp(App):
    def build(self):
        return Main()
        

MainApp().run()

Upvotes: 0

Views: 114

Answers (2)

EGarbus
EGarbus

Reputation: 291

Rather than rely on ButtonBehavior, you need to create your own function to test for collision. Here is a complete example that uses a circle. I have also added creating and dispatching the on_release and on_press events.

from kivy.app import App
from kivy.lang import Builder
from kivy.properties import ColorProperty, NumericProperty
from kivy.uix.label import Label

kv = """
<CircleButton>:
    size_hint: None, None
    size: self.radius * 2, self.radius * 2
    canvas.before:
        Color:
            rgba: self._button_color
        Ellipse:
            size: self.size
            pos: self.pos

AnchorLayout:
    CircleButton:
        text: 'Push'
        radius: dp(75)
        on_press: print('Button Pressed')
        on_release: print('Button Released')
"""


class CircleButton(Label):
    radius = NumericProperty(50)
    normal_color = ColorProperty('gray')
    down_color = ColorProperty('blue')
    _button_color = ColorProperty('gray')

    def __init__(self, **kwargs):
        self.register_event_type('on_release')
        self.register_event_type('on_press')
        super().__init__(**kwargs)

    def on_press(self):
        pass

    def on_release(self):
        pass

    def is_inside_circle(self, touch_x, touch_y):
        dx = abs(touch_x - self.center_x)
        dy = abs(touch_y - self.center_y)
        return dx ** 2 + dy ** 2 <= self.radius ** 2

    def on_touch_down(self, touch):
        if self.is_inside_circle(*touch.pos):
            touch.grab(self)
            self._button_color = self.down_color
            self.dispatch('on_press')
            return True
        super().on_touch_down(touch)

    def on_touch_up(self, touch):
        if touch.grab_current is self:
            self._button_color = self.normal_color
            self.dispatch('on_release')
            touch.ungrab(self)
            return True
        return super().on_touch_up(touch)


class CircleButtonExampleApp(App):
    def build(self):
        return Builder.load_string(kv)


CircleButtonExampleApp().run()

Upvotes: 1

Eoin Brennan
Eoin Brennan

Reputation: 151

So, after some inspiration from the above answer I got what I was trying to do!!

Not sure if it was the best way to do it but I wrote a new on_touch_down and on_touch_up method to override the same in the ButtonBehaviour class. The shapley module has a method to check if a point is inside a polygon and as I knew the coordinates for my shapes so I was able to use this. In the new on_touch_down method I added a line..

touch_point = Point(touch.x, touch.y)                  
if shape.contains(touch_point):
    (do the normal button stuff)
    return True
else:
    return False

                  

The one problem I had was that the buttons were stacked so the coordinates used to draw the shapes did not line up with the coordinates for the touch, so I used an x_offset and y_offset value to position the buttons and then alter the coordinates appropriately. Might need more work when I go to use it in the project but for now this seems to work as a nice example of irregular shaped buttons!!!

Thanks for the help!

from PIL import Image, ImageDraw
from kivy.app import App
from kivy.uix.image import Image as Img
from kivy.uix.behaviors import ButtonBehavior
from kivy.uix.floatlayout import FloatLayout
from time import time
from kivy.clock import Clock
from shapely.geometry import Polygon, Point


def calculate_hw(tuple_list, ht = False, wt = False):
    'using the coordinates of the shapes, returns the height and/or width'

    if not tuple_list:
        return None
    
    min_x = min(tuple_list, key=lambda tup: tup[0])[0]
    max_x = max(tuple_list, key=lambda tup: tup[0])[0]
    
    min_y = min(tuple_list, key=lambda tup: tup[1])[1]
    max_y = max(tuple_list, key=lambda tup: tup[1])[1]
    
    h = max_y - min_y
    w = max_x - min_x
    
    if ht and wt:
        return h, w
    elif ht:
        return h
    elif wt:
        return w

def calculate_y_difference(tuple_list):
    '''
    returns the height of the objecy at its widest part
    used to calculate the value of the pos_y of the next button
    '''

    sorted_by_x = sorted(tuple_list, key=lambda tup: tup[0], reverse=True)    
    y_diff = sorted_by_x[0][1] - sorted_by_x[1][1]    
    return abs(y_diff)

shapes = {
    '1': [(500.0, 0.0), (500.0, 93.38888888888945), (0.0, 25.0), (0.0, 0.0), (500.0, 0.0)], 
    '2': [(500.0, 93.38888888888945), (500.0, 133.33333333333357), (0.0, 75.0), (0.0, 25.0), (500.0, 93.38888888888945)], 
    '3': [(500.0, 133.33333333333357), (500.0, 188.3333333333333), (0.0, 125.0), (0.0, 75.0), (500.0, 133.33333333333357)]}

shapes_height = {k: calculate_hw(shapes[k], ht = True) for k in shapes.keys() }
shape_pos_height = {k: calculate_y_difference(shapes[k]) for k in shapes.keys() }
shape_width = {k: calculate_hw(shapes[k], wt = True) for k in shapes.keys() }

#Loop through each shape, create it, crop it and save it as a .png
for shape_name, vertices in shapes.items():
    # Create a new transparent image
    width, height = 4000, 4000
    background_color = (0, 0, 0, 0)  # Transparent background

    image_normal = Image.new("RGBA", (width, height), background_color)
    image_down = Image.new("RGBA", (width, height), background_color)

    # Create a drawing object
    draw_normal = ImageDraw.Draw(image_normal)
    draw_down = ImageDraw.Draw(image_down)

    # Draw the irregular shape
    draw_normal.polygon(vertices, ) #fill=(255, 0, 0, 128))  # Fill color with transparency
    draw_down.polygon(vertices, fill=(0, 255, 0, 128))  # Fill color with transparency

    # Find the bounding box of the polygon
    min_x = min(point[0] for point in vertices)
    min_y = min(point[1] for point in vertices)
    max_x = max(point[0] for point in vertices)
    max_y = max(point[1] for point in vertices)

    #crop the image
    image_normal = image_normal.crop((min_x, min_y, max_x, max_y))
    image_down = image_down.crop((min_x, min_y, max_x, max_y))

    # Save the image as a PNG file
    image_normal.save(f"{shape_name}.png", "PNG", dpi=(2000, 2000), resolution_unit="in")
    image_down.save(f"{shape_name}_down.png", "PNG", dpi=(2000, 2000), resolution_unit="in")


class MyButton(ButtonBehavior, Img):
    '''
    a very basic button with an image used instead of a Label
    '''
    def __init__(self, vert, normal, down, **kwargs):
        super(MyButton, self).__init__(**kwargs)
        self.vertices = vert
        self.normal = normal
        self.down = down
        self.source = self.normal

    def on_press(self):
        self.source = self.down
    def on_release(self):
        self.source = self.normal

    def on_touch_down(self, touch):
        
        shape = Polygon(self.vertices)                          # Create a Shapely Polygon object from the vertices   

        if super(ButtonBehavior, self).on_touch_down(touch):
            return True
        if touch.is_mouse_scrolling:
            return False
        if not self.collide_point(touch.x, touch.y):
            return False
        if self in touch.ud:
            return False
        
        touch_point = Point(touch.x, touch.y)                   #Create a Shaply Touch Point from the touch
        if shape.contains(touch_point):                         #Take the touch if inside the shape, else return False
            touch.grab(self)
            touch.ud[self] = True
            self.last_touch = touch
            self.__touch_time = time()
            self._do_press()
            self.dispatch('on_press')
            return True
        else:
            return False
        
    def on_touch_up(self, touch):
        if touch.grab_current is not self:
            return super(ButtonBehavior, self).on_touch_up(touch)
        assert(self in touch.ud)
        touch.ungrab(self)
        self.last_touch = touch

        if (not self.always_release and
                not self.collide_point(*touch.pos)):
            self._do_release()
            return

        touchtime = time() - self.__touch_time
        if touchtime < self.min_state_time:
            self.__state_event = Clock.schedule_once(
                self._do_release, self.min_state_time - touchtime)
        else:
            self._do_release()
        self.dispatch('on_release')
        return True

class Main(FloatLayout):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        x_offset = 100
        y_offset = 300
        initial_y_offset = y_offset    
        for shape in shapes:                #Loop through the shape names
            y_offset -= shape_pos_height[shape]    #the y pos of the first shape, this is reduced by the width of the shape to achieve a stacking effect
            
            #add the custom budget
            B = (MyButton(
                vert  =  [(tup[0]+x_offset, initial_y_offset - tup[1]) for tup in shapes[shape]],   #alter the vertices of the shape based on the x_offset, y_offset
                down = f'{shape}_down.png', 
                normal = f'{shape}.png', 
                size_hint = (None, None),
                size = (shape_width[shape], shapes_height[shape]),
                pos = (x_offset,y_offset)))
            self.add_widget(B)

class MainApp(App):
    def build(self):
        return Main()
        
MainApp().run()

Upvotes: 0

Related Questions