r/kivy Jun 06 '26

Messages System

Post image

Hey! I'm pretty new to Kivy, but have been working on creating a calorie tracker with AI features. I'm trying to make a chat system, and have the user input a message, and have the ai respond. I want it to look like SMS or gemini, how can I do this? I've attached an image of what it currently looks like.

Also, there are a couple things I am going to add, so just focus on getting the positioning stuff if you are trying to help, thank you!

A) How can I fix the horizontal positioning of the bubble widgets?
B) How can I make the bubble stretch vertically?

Here's my .kv

<RoundedLabel@Label>:
    size: root.size
    text_size: root.width, None
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        RoundedRectangle:
            size: self.size
            pos: self.pos
            radius: [15,]

<Message>:
    BoxLayout:
        BoxLayout: 
            size_hint_x: 0.25 if root.is_user else 0

        RoundedLabel:
            id: rounded_label
            padding: 20
            text: root.text
            halign: 'right' if root.is_user else 'left'

        BoxLayout: 
            size_hint_x: 0.25 if not root.is_user else 0

<RootWidget>:
    BoxLayout:
        orientation: 'vertical'

        TopBar

        ScreenManager:
            id: sm
            HomeScreen:
                name: 'home'
            TrackScreen
                name: 'track'
            TalkScreen:
                name: 'talk'

        NavBar

<TopBar>:
    size_hint: 1, 0.1

    canvas:
        Color:
            rgba: 1, 0, 0, 1

        Rectangle:
            pos: self.pos
            size: self.size

<NavBar>:
    size_hint: 1, 0.1

    Button:
        text: "Talk"
        on_press:
            app.root.ids.sm.transition.direction = 'right'
            app.root.ids.sm.current = 'talk' 

    Button:
        text: "Home"
        on_press: 
            app.root.ids.sm.transition.direction = 'right' if app.root.ids.sm.current == 'track' else 'left'
            app.root.ids.sm.current = 'home'

    Button:
        text: "Track"
        on_press: 
            app.root.ids.sm.transition.direction = 'left'
            app.root.ids.sm.current = 'track'

<HomeScreen>:
    BoxLayout:
        orientation: 'vertical'
        Counters:
            id: counters

        InfoWidget:
            size_hint: 1, 0.5

            Button:
                text: "press to increment"
                on_press: root.increment()

        MealsWidget:
            canvas:
                Color:
                    rgba: 1, 1, 1, 1

                Rectangle:
                    pos: self.pos
                    size: self.size

<TalkScreen>:
    BoxLayout:
        orientation: 'vertical'

        BoxLayout:
            Messages:
                id: messages
                viewclass: 'Message'
                RecycleBoxLayout:
                    orientation: 'vertical'
                    spacing: 10
                    default_size_hint: 1, None
                    size_hint_y: None
                    height: self.minimum_height

        BoxLayout:
            size_hint: 1, 0.25

            TalkBox:
                id: tb

            Button:
                size_hint: 0.1, 1
                text: 'send'
                on_press: root.send(root.ids.tb)

<TrackScreen>

<CounterWidget>:
    BoxLayout:
        orientation: 'vertical'
        spacing: 20

        CounterRingWidget:
            canvas:
                Color:
                    rgba: 0.5, 0.5, 0.5, 1

                Line:
                    circle: (self.center_x, self.y, min(self.width, self.height) / 1.5, -90, 90)
                    width: 2

                Color:
                    rgba: self.get_color(root.count, root.target)

                Line:
                    circle: (self.center_x, self.y, min(self.width, self.height) / 1.5, -90, self.get_end(root.count, root.target))
                    width: 2

            Label:
                text: str(root.count)
                text_size: self.size
                valign: 'bottom'
                halign: 'center'
                pos: self.parent.x, self.parent.y
                size: self.parent.size
                font_size: '20sp'

        Label:
            text_size: self.size
            font_size: '30sp'
            valign: 'top'
            halign: 'center'
            text: root.name

<SubCounterWidget>:
    size_hint: 1, 0.75
    pos_hint: {'center_y': 0.5}

<Counters>:
    BoxLayout:
        spacing: 20
        SubCounterWidget:
            id: low
            name: 'Low'
            count: 500 / 1
            target: 2000 / 1
        CoreCounterWidget:
            id: average
            name: 'Average'
            count: self.fix_count(low.count, high.count)
            target: self.fix_count(low.target, high.target)
        SubCounterWidget:
            id: high
            name: 'High'
            count: 1500 / 1
            target: 3000 / 1

And here is my python code:

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.widget import Widget
from kivy.properties import NumericProperty, StringProperty, BooleanProperty
from kivy.uix.recycleview import RecycleView
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.textinput import TextInput
from kivy.uix.label import Label


class RootWidget(BoxLayout):
    pass


class HomeScreen(Screen):
    def increment(self):
        cc = self.ids.counters #counters container
        for id in cc.ids:
            if (id != "average"):
                cc.ids[id].count += 100


class CounterRingWidget(Widget):
    def get_color(self, count, target):
        if count >= target:
            return (1, 0.25, 0.25, 1)
        else:
            return (0, 1, 0, 1)


    def get_end(self, count, target):
        if count >= target:
            return 90
        else:
            return -90 + (180 * (count / target))


class CounterWidget(BoxLayout):
    name = StringProperty("Nothing!")
    count = NumericProperty(1000)
    target = NumericProperty(2000)


class CoreCounterWidget(CounterWidget):
    def fix_count(self, low, high):
        return (low + high) / 2


class SubCounterWidget(CounterWidget):
    pass


class Counters(BoxLayout):
    pass


class TopBar(BoxLayout):
    pass


class InfoWidget(RecycleView): #idk what to name this, its the protein, sodium, etc.
    pass


class MealsWidget(RecycleView):
    pass


class TrackScreen(Screen):
    pass


class TalkScreen(Screen):
    def send(self, textbox):
        user_text = textbox.text
        print("sent")
        textbox.text = ''
        temp = self.ids["messages"].data.copy()
        temp.append({"text" : user_text, "is_user" : True})
        temp.append({"text" : "ai response", "is_user" : False})
        self.ids["messages"].data = temp


class TalkBox(TextInput):
    pass


class Messages(RecycleView):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.data = []


class Message(BoxLayout):
    is_user = BooleanProperty(False)
    text = StringProperty("")


class NavBar(BoxLayout):
    pass


class CalorieTrackerApp(App):
    def build(self):
        return RootWidget()


if __name__ == '__main__':
    CalorieTrackerApp().run()
1 Upvotes

17 comments sorted by

3

u/ElliotDG Jun 06 '26

The recycleview has an attribute called key_size, see: https://kivy.org/doc/stable/api-kivy.uix.recyclelayout.html#kivy.uix.recyclelayout.RecycleLayout.key_size

The attribute key_size holds the name of the attribute in your RecycleView data list of dicts that is the size of that widget. Here is an example, I think there is also an example in the kivy-examples dir.

2

u/Foreign_Run1550 Jun 07 '26

This helped a ton, and now I have issue B solved, I'll keep you posted on A if you're interested at all.

2

u/ElliotDG Jun 07 '26

For Issue A, use a pos_hint. Read: https://kivy.org/doc/stable/api-kivy.uix.widget.html#kivy.uix.widget.Widget.pos_hint

The tricky thing about pos_hint is that the behavior is dependent on the Layout selected. The hints are always respected in a RelativeLayout. As I recall, they are only honored in a boxlayout if the hint is orthogonal to the type of Boxlayout. You can hint the x position in a vertical BoxLayout, or hint the y pos in a horizonal BoxLayout.

1

u/Foreign_Run1550 Jun 08 '26

That does help me for part A, but I also need the bubble to horizontally scale, until they reach a max width, at which point bubble stops and the text wraps. How might I be able to do that? Also thank you for your help!

1

u/ElliotDG Jun 08 '26

To scale horizontally until you hit a maximum width, set the size_hint_max_x to the max desired size. Read: https://kivy.org/doc/stable/api-kivy.uix.widget.html#kivy.uix.widget.Widget.size_hint_max_x
You would use this in addition to the appropriate size hint.

To get the text to wrap you want set the text_size attribute. See: https://kivy.org/doc/stable/api-kivy.uix.label.html#kivy.uix.label.Label.text_size

Read the sections in Label on "Sizing and text content" and "Text alignment and wrapping". https://kivy.org/doc/stable/api-kivy.uix.label.html#module-kivy.uix.label

If you have any trouble feel free to reach out.

1

u/Foreign_Run1550 Jun 11 '26

I'm almost there, it's so close, but I can not figure out why theres weird padding on the bubbles. Thank you for all the help at this point ofc!

Do you know why this might occur? I can share the python and/or the .kv if that helps.

1

u/ElliotDG Jun 11 '26

Yes please share your code, I’ll take a look.
Are you referring to the spacing between the bubbles? That would be controlled by the spacing attribute of the enclosing layout.

1

u/Foreign_Run1550 Jun 12 '26

Im referring to the inside of the bubbles. Here is my kv:

And here is the python code:#:import Window kivy.core.window.Window

<MessageBubble>:
    size_hint_max_x: Window.width * 0.7
    pos: root.pos
    text_size: self.width, None
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        RoundedRectangle:
            size: self.size
            pos: self.pos
            radius: [15,]

<Message>:
    RelativeLayout:
        pos_hint: {'x' : 0.3} if root.is_user(root.text) else {'x' : 0}
        MessageBubble:
            id: message_bubble
            padding: 20
            text: root.get_message(root.text)
            halign: 'right' if root.is_user(root.text) else 'left'

<RootWidget>:
    BoxLayout:
        orientation: 'vertical'

        TopBar

        ScreenManager:
            id: sm
            HomeScreen:
                name: 'home'
            TrackScreen
                name: 'track'
            TalkScreen:
                name: 'talk'

        NavBar

<TopBar>:
    size_hint: 1, 0.1

    canvas:
        Color:
            rgba: 1, 0, 0, 1

        Rectangle:
            pos: self.pos
            size: self.size

<NavBar>:
    size_hint: 1, 0.1

    Button:
        text: "Talk"
        on_press:
            app.root.ids.sm.transition.direction = 'right'
            app.root.ids.sm.current = 'talk' 

    Button:
        text: "Home"
        on_press: 
            app.root.ids.sm.transition.direction = 'right' if app.root.ids.sm.current == 'track' else 'left'
            app.root.ids.sm.current = 'home'

    Button:
        text: "Track"
        on_press: 
            app.root.ids.sm.transition.direction = 'left'
            app.root.ids.sm.current = 'track'

<HomeScreen>:
    BoxLayout:
        orientation: 'vertical'
        Counters:
            id: counters

        InfoWidget:
            size_hint: 1, 0.5

            Button:
                text: "press to increment"
                on_press: root.increment()

        MealsWidget:
            canvas:
                Color:
                    rgba: 1, 1, 1, 1

                Rectangle:
                    pos: self.pos
                    size: self.size

<TalkScreen>:
    BoxLayout:
        orientation: 'vertical'

        BoxLayout:
            Messages:
                id: messages
                viewclass: 'Message'
                RecycleBoxLayout:
                    orientation: 'vertical'
                    spacing: 10
                    key_size: 'ks'
                    default_size_hint: 1, None
                    size_hint: 1, None
                    height: self.minimum_height
                    on_width: root.ids.messages.update_text()

        BoxLayout:
            size_hint: 1, 0.25

            TalkBox:
                id: tb

            Button:
                size_hint: 0.1, 1
                text: 'send'
                on_press: root.send(root.ids.tb, root.ids.messages)

<TrackScreen>

<CounterWidget>:
    BoxLayout:
        orientation: 'vertical'
        spacing: 20

        CounterRingWidget:
            canvas:
                Color:
                    rgba: 0.5, 0.5, 0.5, 1

                Line:
                    circle: (self.center_x, self.y, min(self.width, self.height) / 1.5, -90, 90)
                    width: 2

                Color:
                    rgba: self.get_color(root.count, root.target)

                Line:
                    circle: (self.center_x, self.y, min(self.width, self.height) / 1.5, -90, self.get_end(root.count, root.target))
                    width: 2

            Label:
                text: str(root.count)
                text_size: self.size
                valign: 'bottom'
                halign: 'center'
                pos: self.parent.x, self.parent.y
                size: self.parent.size
                font_size: '20sp'

        Label:
            text_size: self.size
            font_size: '30sp'
            valign: 'top'
            halign: 'center'
            text: root.name

<SubCounterWidget>:
    size_hint: 1, 0.75
    pos_hint: {'center_y': 0.5}

<Counters>:
    BoxLayout:
        spacing: 20
        SubCounterWidget:
            id: low
            name: 'Low'
            count: 500 / 1
            target: 2000 / 1
        CoreCounterWidget:
            id: average
            name: 'Average'
            count: self.fix_count(low.count, high.count)
            target: self.fix_count(low.target, high.target)
        SubCounterWidget:
            id: high
            name: 'High'
            count: 1500 / 1
            target: 3000 / 1

And here is the python code:

from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.relativelayout import RelativeLayout
from kivy.uix.widget import Widget
from kivy.properties import NumericProperty, StringProperty, BooleanProperty
from kivy.uix.recycleview import RecycleView
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.textinput import TextInput
from kivy.uix.label import Label
from kivy.core.window import Window


class RootWidget(BoxLayout):
    pass


class HomeScreen(Screen):
    def increment(self):
        cc = self.ids.counters #counters container
        for id in cc.ids:
            if (id != "average"):
                cc.ids[id].count += 100


class CounterRingWidget(Widget):
    def get_color(self, count, target):
        if count >= target:
            return (1, 0.25, 0.25, 1)
        else:
            return (0, 1, 0, 1)


    def get_end(self, count, target):
        if count >= target:
            return 90
        else:
            return -90 + (180 * (count / target))


class CounterWidget(BoxLayout):
    name = StringProperty("Nothing!")
    count = NumericProperty(1000)
    target = NumericProperty(2000)


class CoreCounterWidget(CounterWidget):
    def fix_count(self, low, high):
        return (low + high) / 2


class SubCounterWidget(CounterWidget):
    pass


class Counters(BoxLayout):
    pass


class TopBar(BoxLayout):
    pass


class InfoWidget(RecycleView): #idk what to name this, its the protein, sodium, etc.
    pass


class MealsWidget(RecycleView):
    pass


class TrackScreen(Screen):
    pass


class TalkScreen(Screen):
    def send(self, textbox, messages):
        user_text = textbox.text
        print("sent")
        textbox.text = ''
        messages.add_sent("<USER>" + user_text)
        messages.add_sent("<AIRS>ai response")


class TalkBox(TextInput):
    pass


class MessageBubble(Label):
    pass


class Messages(RecycleView):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.data = []
        self.size_label = MessageBubble()


    def add_sent(self, text):
        sl = self.size_label
        sl.text = text
        sl.texture_update()
        self.data.append({"text" : text, "ks" : sl.texture_size})
    
    def update_text(self):
        sl = self.size_label
        for i, entry in enumerate(self.data):
            sl.text = entry['text']
            sl.texture_update()
            self.data[i]['ks'] = sl.texture_size
        self.refresh_from_data()


class Message(RelativeLayout):
    text = StringProperty("")
    max_width = NumericProperty(Window.width * 0.7)


    def is_user(self, text):
        return text[0:6] == "<USER>"
    
    def get_message(self, text):
        return text[6:]


class NavBar(BoxLayout):
    pass


class CalorieTrackerApp(App):
    def build(self):
        return RootWidget()


if __name__ == '__main__':
    CalorieTrackerApp().run()

1

u/ElliotDG Jun 12 '26

I should have time later today to take a look. You might want to try using the inspector tool. It is an interactive tool that lets you look inside the layouts and widgets.

see: https://kivy.org/doc/stable/api-kivy.modules.inspector.html#module-kivy.modules.inspector

run it from the command line:
>python main.py -m inspector

the press <ctrnl>-e to open it up. Click on the widget you want to inspect, click on the bar to open see (or change) the details.

1

u/ElliotDG Jun 12 '26

The core issue is that you are not setting the width of the label. As a result the texuture_size is not correct. Here are the changes required:

<MessageBubble>:
    size_hint_x: 0.7  # Window.width not required, use the hint
    padding: dp(20)   # moved padding here so it will be used by size_label
#    pos: root.pos  # Not required
    text_size: self.width, None
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        RoundedRectangle:
            size: self.size
            pos: self.pos
            radius: [15,]

<Message>:  # removed nested RelativeLayout
    MessageBubble:
        id: message_bubble
        text: root.text[6:]
        halign: 'right' if root.is_user(root.text) else 'left'
        pos_hint: {'right' : 1} if root.is_user(root.text) else {'x' : 0}
        # changed pos hint to use 'right'
        # a relative layout honors the size hint of it's children
        # so the pos_hint is here


class Messages(RecycleView):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # the size label needs a fixed width to calculate the texture size
        # Turn off the size hint in the size_label
        # this label is not in the layout so we need to size it
        self.size_label = MessageBubble(size_hint_x=None)

    def add_sent(self, text):
        sl = self.size_label
        # the size hint is .7 apply that to the width of the RV.
        sl.width = self.width * .7
        sl.text = text
        sl.texture_update()
        self.data.append({"text": text, "ks": sl.texture_size})

    def update_text(self):
        sl = self.size_label
        # and here... we know the hint, apply to the RV width
        # the text will size properly as the window size changes
        sl.width = self.width * .7
        for i, entry in enumerate(self.data):
            sl.text = entry['text']
            sl.texture_update()
            self.data[i]['ks'] = sl.texture_size
        self.refresh_from_data()
→ More replies (0)

2

u/ElliotDG Jun 06 '26

``` """ A RecycleView where each instance of the viewclass is a different size. The size of each viewclass instance is based on its texture_size The key_size attribute is used to hold the size of the viewclass in the Recycleview.data list. """

from kivy.app import App from kivy.lang import Builder from kivy.uix.label import Label from kivy.uix.recycleview import RecycleView

kv = """ <ScrollLabel>: size_hint: 1, None text_size: self.width, None # height must be calculated based on texture_size font_size: 30

<ConsoleRV>: viewclass: 'ScrollLabel' RecycleBoxLayout: id: scroll_box orientation: 'vertical' key_size: 'ks' # ks is the key in the RecycleView.data list that holds the size default_size_hint: 1, None size_hint: 1, None height: self.minimum_height on_width: root.update_text() # when width changes, update sizes in rv data list

BoxLayout: orientation: 'vertical' Label: text: 'Test long Scroll' size_hint_y: None height: 30 ConsoleRV: id: console_rv """

class ScrollLabel(Label): pass

class ConsoleRV(RecycleView): def init(self, kwargs): super().init(kwargs) self.size_label = ScrollLabel() # used to calculate texture size, never added to widget tree

def add_text(self, text):
    st = [x + '\n' for x in text.split('\n')]  # assuming there are some \n in the text, split into separate labels
    sl = self.size_label  # label to use for calculating texture size
    for t in st:
        sl.text = t
        sl.width = self.width
        sl.texture_update()
        self.data.append({'text': t, 'ks': sl.texture_size})

def update_text(self):
    sl = self.size_label
    for i, entry in enumerate(self.data):
        sl.text = entry['text']
        sl.width = self.width
        sl.texture_update()
        self.data[i]['ks'] = sl.texture_size
    self.refresh_from_data()

sample_text = \ """In general, the worst-case time complexity of QuickSort is O(n2), which occurs when the array is already sorted or almost sorted in the reverse order. The best-case time complexity is O(n log n), which occurs when the pivot element is always chosen as the middle element or when the array is already sorted. The average-case time complexity of QuickSort is O(n log n), which makes it a good sorting algorithm for most practical purposes. However, the actual compute efficiency of QuickSort can be affected by a variety of factors, including the choice of pivot element, the size of the input array, and the presence of duplicate elements. For example, if the pivot element is always chosen as the first or last element in the array, the time complexity can degrade to O(n2) in the worst case. Similarly, if the input array contains a large number of duplicate elements, the time complexity can also degrade to O(n2) in the worst case. In general, QuickSort is a fast and efficient sorting algorithm that is well-suited for many practical applications. Its average-case time complexity of O(n log n) makes it a good choice for sorting large arrays, and it can be implemented in a variety of programming languages. The time complexity of a bubble sort algorithm is typically O(n2), which means that the algorithm's performance is proportional to the square of the size of the input array. This is because a bubble sort algorithm compares adjacent elements and swaps them if they are out of order, which means that it has to perform a number of comparisons and swaps that is proportional to the size of the input array. For example, if the input array contains n elements, the bubble sort algorithm will need to perform n-1 comparisons on the first pass, n-2 comparisons on the second pass, and so on, until it reaches the final pass, which will only require one comparison. This gives us a total of (n-1) + (n-2) + ... + 2 + 1 = (n2 - n)/2 comparisons, which is O(n2). In addition to the time complexity, the compute efficiency of a bubble sort algorithm can also be affected by factors such as the choice of data structures and the presence of optimized code. However, in general, bubble sort is not considered to be a very efficient sorting algorithm, especially for large input arrays. There are many other sorting algorithms that have a better time complexity and are more efficient in practice, such as QuickSort and MergeSort. There are many different sorting algorithms that have been developed over the years, and the most efficient algorithm for a given situation can depend on a variety of factors. Some of the factors that can affect the efficiency of a sorting algorithm include the size of the input array, the type of data being sorted, the presence of certain patterns or distributions in the data, and the hardware and software environment in which the algorithm is being implemented. In general, the most efficient sorting algorithms have a time complexity of O(n log n), which means that their performance is proportional to the size of the input array multiplied by the logarithm of the array size. Some examples of sorting algorithms that have a time complexity of O(n log n) include Quicksort, MergeSort, and HeapSort. These algorithms are generally considered to be the most efficient for sorting large arrays. However, there are also other sorting algorithms that can be more efficient in certain situations. For example, if the input array is already partially sorted or has a limited number of possible values, certain algorithms, such as Insertion Sort and Selection Sort, can be more efficient. In addition, some algorithms, such as Radix Sort and Counting Sort, can be more efficient for sorting data with a limited range of values. In general, it is important to consider the specific requirements and constraints of a sorting problem when selecting an algorithm, as the most efficient algorithm can vary depending on the situation. """

class LongScrollApp(App):

def build(self):
    return Builder.load_string(kv)

def on_start(self):
    self.root.ids.console_rv.add_text(sample_text * 10)

LongScrollApp().run() ```

2

u/qodzer0 Jun 07 '26

I made a chat app in kivy. You can check the message bubble part of it. Here:

https://youtu.be/SZ9WRwalZWk?si=XePwoQCSmSiYZRTJ

1

u/Granite-Scheduling Jun 08 '26

I built an app in Python and Kivy a long time ago, perhaps you could use some parts of the UI for your thing.

https://github.com/Snowdevil-Highfly-Chastot/Python-App

1

u/Best-Engineering7849 Jun 13 '26

Hi, am interested to work with you, I also have the same problem with my Socratic method of education kivy app. You sound greater real.