community icon indicating copy to clipboard operation
community copied to clipboard

RecycleView Label redraws to wrong text_size height ONLY when rv.data is repopulated with SAME items

Open allhavebrainimplantsandmore opened this issue 3 years ago • 9 comments

Software Versions

  • Python: 3.8
  • OS: archlinux (latest rolling)
  • Kivy: 2.0.0rc4
  • Kivy installation method: PyCharm

Describe the bug The script has a textinput:

  • runs an sqlite query to each text change,
  • pulls items and
  • inserts them into a recycleview data struct.

It works fine except when the SelectedLabel class settings are set to allow for item word wrap to extend the item label text_size vertically to fit longer strings. Even in this case, it does work most of the time, expanding the items vertically to fit the text.

But then I found one situation that creates a problem. When the sql query returns the same set of strings and recycle view is updated with the exactly same data struct (after making self.rv.data = ''). The list item label size defaults to one line height on the items that were bigger height due to word wrap.

Workaround and a hint to what may be wrong: I set it so that if the sql query is the same as the last one, the self.rv.data does not get reset and repopulated, leaving the view unchanged/unrefreshed, everything works as expected.

Expected behavior

Word wrap and increasing label height should occur even when the recyclevew data is repopulated with the same data.

To Reproduce In the code below, just need to specify a sqlite db with Sheet1 table where the column upon which the search runs returns the same result upon subsequent character entry into the textinput, and at least one of the first results is too long for one line so it has to wrap. CODE BELOW:

from functools import partial
from kivy.app import App
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.metrics import sp
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.uix.recycleview.views import RecycleDataViewBehavior

import sqlite3
kv = """
<Test>:
    canvas:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        Rectangle:
            size: self.size
            pos: self.pos
    rv: rv 
    orientation: 'vertical'
    BoxLayout:
        orientation: 'vertical'
        BoxLayout:
            Label:
                id: found_text
                font_size: sp(30)
                padding: sp(10), sp(10)
                text_size: self.width, None
                halign: 'center'
                on_text: root.setTextToFit(self.text,30)
        BoxLayout:
            orientation: 'vertical'
            RecycleView:
                id: rv
                viewclass: 'SelectableLabel'
                scroll_y: 0                                             
                effect_cls: "ScrollEffect"                              
                text_selected: ""
                translated_fontsize: sp(30)
                SelectableRecycleBoxLayout:
                    default_size: None, sp(40)
                    default_size_hint: 1, None
                    size_hint_y: None
                    height: self.minimum_height
                    orientation: 'vertical'
                    spacing: dp(2)
                    multiselect: True
                    touch_multiselect: True
            AnchorLayout:
                anchor_x: 'center'
                anchor_y: 'center'
                size_hint_y: None
                height: 90
                GridLayout:
                    cols: 1
                    rows: 1
                    height: self.minimum_height 
                    AnchorLayout:
                        anchor_x: 'center'
                        TextInput:
                            id: search_word
                            hint_text: 'Input word...'
                            font_size: sp(25)
                            size_hint: None,None
                            width: sp(300)
                            height: self.minimum_height 
                            halign: 'center'
                            multiline: False
                            on_text: root.search_update()
                            on_focus: root.on_focus(*args)  
<SelectableLabel>:
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        Rectangle:
            size: self.size
            pos: self.pos
    font_size: sp(25)
    text_size: root.width, None
    size: self.texture_size
    halign: 'center'
    padding_y: sp(5)
    padding_x: sp(10)
"""
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, RecycleBoxLayout):
    pass


class SelectableLabel(RecycleDataViewBehavior, Label):
    index = None

    def refresh_view_attrs(self, rv, index, data):
        self.index = index
        return super(SelectableLabel, self).refresh_view_attrs(
            rv, index, data)

    def on_touch_down(self, touch):
        if super(SelectableLabel, self).on_touch_down(touch):
            return True
        if self.collide_point(*touch.pos):
            return self.parent.select_with_touch(self.index, touch)

    def apply_selection(self, rv, index, is_selected):
        self.selected = is_selected
        if is_selected:
          print("Do something sexy")

class Test(BoxLayout):
    #These two routines below needed to properly select all text upon focus to textinput
    def focus_callback(self, *largs):
        self.ids.search_word.select_all()
    def on_focus(self, instance, value):
        if value:
            Clock.schedule_once(partial(self.focus_callback))


    def search_update(self):
        typed = self.ids.search_word.text
        if typed != '':
            con = sqlite3.connect('dict.db')
            mycur = con.cursor()
            mycur.execute("SELECT * From Sheet1 WHERE blah LIKE ? LIMIT 10;", (typed + "%",))
            results = (mycur.fetchall())
            con.close()
            self.rv.data = []
            self.rv.data = [{'x'*300} for x in range(100)]
            for xx in results:
                if typed.lower() == xx[1][0:len(typed)].lower():
                    self.rv.data.insert(0, {'text': xx[2] or ''})
                    self.rv.scroll_y = 0.0
                elif typed.lower() == xx[2][0:len(typed)].lower():
                    self.rv.data.insert(0,{'text': xx[2] or ''})
                    self.rv.scroll_y = 0.0

    def setTextToFit(self,text,startsize):
        # for long names, reduce font size until it fits in its widget
        m=1 
        self.ids.found_text.font_size= sp(startsize) 
        self.ids.found_text.texture_update()
        while m>0.1 and self.ids.found_text.texture_size[1]>self.ids.found_text.height:
            m=m-0.025
            self.ids.found_text.font_size=self.ids.found_text.font_size*m
            self.ids.found_text.texture_update()

class TestApp(App):
    def build(self):
        w = Builder.load_string(kv)
        Window.softinput_mode = "below_target"
        return Test()


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

Code and Logs and screenshots Screenshots: https://ibb.co/Ms8QD9z https://ibb.co/MgCT0JZ

Additional context Let me know if there's anything else I can provide.

(edited the post to fix code formating)

tshirtman avatar Nov 02 '20 12:11 tshirtman

Thank you for that

The example is not runable. Please make a minimum runable example showing the issue.

matham avatar Nov 02 '20 17:11 matham

I modified to so that there's some actual data in the list. You can see the behavior reproduced if you type into the text box sequentially "h", then "e", then "l", and then press "Backspace" and "Backspace" again.

The issue can be compounded further if you change the 3rd item in the "results" list from *20 to *100 to make it longer. The first recycleview list draw after typing 'h' is formatted correctly, when typed 'e' and updating it with the same rv.data, the consequent redraws all have broken wrapping on the labels, it seems, and all items in this case require wrapping.

So it appears that this normal formatting behavior is rescued by drawing a list without a single wrapped list item after having it broken, or a list with at least one non-wrapped item after redrawing it with non-identical rv.data (if this specific list is loaded:

            results = [{'text':'hello'+'x'*100},
                    {'text': 'homies'+'y'*100},
                    {'text':'help'+'z'*20
                    }]

and 'h', then 'e', then 'l' is entered into the textinput, formatting is restored, but if this list is loaded:

            results = [{'text':'hello'+'x'*100},
                    {'text': 'homies'+'y'*100},
                    {'text':'help'+'z'*100
                    }]

then the formatting never returns to normal again. ) Run this code, and type 'h' in the textinput box and then 'e', then 'l', then 'p', and then Backspace.

from functools import partial
from kivy.app import App
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.metrics import sp
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.label import Label
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.uix.recycleview.views import RecycleDataViewBehavior

import sqlite3
kv = """
<Test>:
    canvas:
        Color:
            rgba: 0.5, 0.5, 0.5, 1
        Rectangle:
            size: self.size
            pos: self.pos
    rv: rv 
    orientation: 'vertical'
    BoxLayout:
        orientation: 'vertical'
        BoxLayout:
            Label:
                id: found_text
                font_size: sp(30)
                padding: sp(10), sp(10)
                text_size: self.width, None
                halign: 'center'
                on_text: root.setTextToFit(self.text,30)
        BoxLayout:
            orientation: 'vertical'
            RecycleView:
                id: rv
                viewclass: 'SelectableLabel'
                scroll_y: 0                                             
                effect_cls: "ScrollEffect"                              
                text_selected: ""
                translated_fontsize: sp(30)
                SelectableRecycleBoxLayout:
                    default_size: None, sp(40)
                    default_size_hint: 1, None
                    size_hint_y: None
                    height: self.minimum_height
                    orientation: 'vertical'
                    spacing: dp(2)
                    multiselect: True
                    touch_multiselect: True
            AnchorLayout:
                anchor_x: 'center'
                anchor_y: 'center'
                size_hint_y: None
                height: 90
                GridLayout:
                    cols: 1
                    rows: 1
                    height: self.minimum_height 
                    AnchorLayout:
                        anchor_x: 'center'
                        TextInput:
                            id: search_word
                            hint_text: 'Input word...'
                            font_size: sp(25)
                            size_hint: None,None
                            width: sp(300)
                            height: self.minimum_height 
                            halign: 'center'
                            multiline: False
                            on_text: root.search_update()
                            on_focus: root.on_focus(*args)  
<SelectableLabel>:
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.7, 1
        Rectangle:
            size: self.size
            pos: self.pos
    font_size: sp(25)
    text_size: root.width, None
    size: self.texture_size
    halign: 'center'
    padding_y: sp(5)
    padding_x: sp(10)
"""
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, RecycleBoxLayout):
    pass


class SelectableLabel(RecycleDataViewBehavior, Label):
    index = None

    def refresh_view_attrs(self, rv, index, data):
        self.index = index
        return super(SelectableLabel, self).refresh_view_attrs(
            rv, index, data)

    def on_touch_down(self, touch):
        if super(SelectableLabel, self).on_touch_down(touch):
            return True
        if self.collide_point(*touch.pos):
            return self.parent.select_with_touch(self.index, touch)

    def apply_selection(self, rv, index, is_selected):
        self.selected = is_selected
        if is_selected:
          print("Do something sexy")

class Test(BoxLayout):
    #These two routines below needed to properly select all text upon focus to textinput
    def focus_callback(self, *largs):
        self.ids.search_word.select_all()
    def on_focus(self, instance, value):
        if value:
            Clock.schedule_once(partial(self.focus_callback))


    def search_update(self):
        typed = self.ids.search_word.text
        self.rv.data = []
        if typed == "":
            results = []
        else:
            results = [{'text':'hello'+'x'*100},
                   {'text':'hedonists'+'y'*100},
                   {'text':'help'+'z'*20
                    }]
        for xx in results:
            if typed.lower() == xx['text'][0:len(typed)].lower():
                self.rv.data.insert(0, {'text': xx['text'] or ''})
                self.rv.scroll_y = 0.0
            elif typed.lower() == xx['text'][0:len(typed)].lower():
                self.rv.data.insert(0,{'text': xx['text'] or ''})
                self.rv.scroll_y = 0.0

    def setTextToFit(self,text,startsize):
        # for long names, reduce font size until it fits in its widget
        m=1
        self.ids.found_text.font_size= sp(startsize)
        self.ids.found_text.texture_update()
        while m>0.1 and self.ids.found_text.texture_size[1]>self.ids.found_text.height:
            m=m-0.025
            self.ids.found_text.font_size=self.ids.found_text.font_size*m
            self.ids.found_text.texture_update()

class TestApp(App):
    def build(self):
        w = Builder.load_string(kv)
        Window.softinput_mode = "below_target"
        return Test()


if __name__ == '__main__':
    TestApp().run()`

It's generally best for the example code to be the smallest possible reproducer of the issue so it's easier for us to figure out the issue. That said, here's the core part that reproduces the problem:

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout

kv = """
<Test>:
    rv: rv
    orientation: 'vertical'
    RecycleView:
        id: rv
        viewclass: 'SelectableLabel'
        RecycleBoxLayout:
            default_size: None, sp(20)
            default_size_hint: 1, None
            size_hint_y: None
            height: self.minimum_height
            orientation: 'vertical'
            spacing: dp(2)
    TextInput:
        id: search_word
        hint_text: 'Input word...'
        font_size: sp(25)
        size_hint: None,None
        width: sp(300)
        height: self.minimum_height
        halign: 'center'
        multiline: False
        on_text: root.search_update()

<SelectableLabel@Label>:
    canvas.before:
        Color:
            rgba: 0.5, 0.5, 0.7, 1
        Rectangle:
            size: self.size
            pos: self.pos
    font_size: sp(25)
    text_size: self.width, None
    height: self.texture_size[1]
    halign: 'center'
    padding_y: sp(5)
    padding_x: sp(10)
"""

class Test(BoxLayout):

    def search_update(self):
        typed = self.ids.search_word.text
        self.rv.data = []
        if typed == "":
            results = []
        else:
            results = [{'text':'hello'+'x'*100},
                   {'text':'hedonists'+'y'*100},
                   {'text':'help'+'z'*20
                    }]
        self.rv.data = results


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


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

The issue is as follows:

  • The text of the label for the new label widget is set from data and corresponding KV rules are triggered.
  • default_size sets the height of the labels to 20.
  • The width is updated for whatever reason causing the text_size to be re-computed and therefore texture_size is also to be updated, which causes height to be updated setting the label height.
  • Everything looks ok.
  • data is cleared and set again to the values.
  • The rv sees data was updated so takes the cached labels, sets their new text, which happens to be the same as the old text for the middle label. The label's text_size and texture_size is also the same as before because the width and text are unchanged.
  • default_size sets the height of the labels to 20.
  • Because the middle label's size and text are unchanged, the KV rules of the label are not triggered, hence the height stays at 20.
  • Middle label is incorrectly sized.

The basic mistake here is the assumption that the KV rules are triggered whenever a label is re-used from cache, which it doesn't have to be (although here it actually is). And more importantly the assumption about how the sizing is managed in the rv. When you set the default_size_hint to None, you basically say you're taking over control of the sizing of the label and that there will be a size height key in your data (or the default will be used). However, in your data list you only set the text and assume when the rv updates the text, it'll happen after the default size set the widget's size. Or that the KV rules will somehow be triggered due to some upstream change in a property. But sometimes it will it will and sometimes it won't.

So, for your label's KV rules, we make no guarantee that height: self.texture_size[1] will be triggered always to make sure the height is the the same as texture_size. All we guarantee is that when the texture_size changes, the height will be set accordingly.

To summarize, the order of events are:

  • View widget is created or gotten from cache.
  • Properties from data is applied to the widget, excluding any sizing properties.
  • The sizing of the widget is computed from the widget's properties, the layout's properties, and any sizing info in the data and then applied.

KV rules may be executed at any of these steps according to the normal KV rules, but you when it comes to sizing, there's no additional guarantees that these rules will be re-triggered again, unless they changed and the KV rule happens to be executed.

So you have three options:

  • Don't set the height of the view widgets, let the layout do it, using size hints. This won't work here.
  • If you want to set the size with a default value, don't rely on the KV rules to keep them synced. Instead manage the size completely, add their values as they are updated to data so if the data is changed, the appropriate size is used.
  • Or the simplest solution, simply remove the line default_size: None, sp(20). That way, it will be set using the KV rules and the rv will never overwrite the sizing. This way you don't have to worry about whether the rv is overwriting kv rules.

I'm gonna leave this open as a doc issue, because we should really document these things, and perhaps some convenience structures for managing sizing.

matham avatar Nov 02 '20 23:11 matham

I am sorry, but I am still confused about all this; being new to kivy since this past Monday, I still don't quite get what happens where or when. I have been copying and pasting from here and there attempting to implement a Recycle View and I am having the same issue where I can't seem to be able to control cell height.

The first try at using all this code work for a 6-column table where each row only contained one line items. Well, it worked because the text fits, but actually the cell height is too large and I would like to reduce it; I guess I have not tried hard enough.

But my second application of this code is for a cell with a Lable and a 5-line Markup text; again, I can't seem to control the cell height and it is not high enough, not being able to show the entire text. .

What can I do? This is the portion of the kv code:

<SelectableLabel>:
    font_size: dp(56)
    text_size: root.width, None
    # size: self.texture_size                             # commented this out because program crashes saying there is no texture
    size_hint_y: None
    label1_text: 'label 1 text'

    # Draw a background to indicate selection
    canvas.before:
        Color:
            rgba: (0.30, 0.35, 0.39, .3) if self.selected else (1, 1, 1, 1)
        Rectangle:
            pos: self.pos
            size: self.size

    Label:
        id: id_label1
        text: root.label1_text
        text_size: self.size
        markup: True
        halign: 'left'
        color: 0,0,0,1

<LocalDepoRV>:
    viewclass: 'SelectableLabel'
    SelectableRecycleBoxLayout:
        # default_size: None, sp(56)                          # commented this as indicated in some previous post, up above.
        default_size_hint: 1, None
        size_hint_y: None
        height: self.minimum_height
        orientation: 'vertical'
        spacing: dp(2)

gsal avatar Nov 06 '20 03:11 gsal

Your example has multiple issues, including having a label be a child of a label, which is probably not what you wanted. I think it would be better if you ask for help on discord about this because github issues are not amenable to this kind of back and forth and the issues you are having is likely more about general kv stuff rather than this specific issue.

matham avatar Nov 06 '20 04:11 matham

I just stumbled onto this bug in my app too :(

@allhavebrainimplantsandmore did you come-up with a workaround?

Update 2024-03-19

I fixed my issue by changing this in my .kv file:

      RecycleView:
         id: rv
         viewclass: 'BusKillOptionItem'
         do_scroll_x: False
         container: content
         scroll_type: ['bars', 'content']
         bar_width: dp(10)

         RecycleGridLayout:
            default_size: None, dp(48)
            default_size_hint: 1, None
            size_hint_y: None
            height: self.minimum_height
            orientation: 'vertical'
            id: content
            cols: 1
            size_hint_y: None
            height: self.minimum_height

To this (note I commented-out the default_size line in RecycleGridLayout):

      RecycleView:
         id: rv
         viewclass: 'BusKillOptionItem'
         do_scroll_x: False
         container: content
         scroll_type: ['bars', 'content']
         bar_width: dp(10)

         RecycleGridLayout:
            #default_size: None, dp(48)
            default_size_hint: 1, None
            size_hint_y: None
            height: self.minimum_height
            orientation: 'vertical'
            id: content
            cols: 1
            size_hint_y: None
            height: self.minimum_height

Thank you for not putting this discussion on Discord, as that doesn't help future people like me. Better to have such discussions on a publicly accessible, durable, well-indexed place, such as GitHub or Stack Exchange

maltfield avatar Mar 20 '24 01:03 maltfield

@allhavebrainimplantsandmore did you come-up with a workaround?

@matham solved it, sort of.

Thank you for not putting this discussion on Discord, as that doesn't help future people like me. Better to have such discussions on a publicly accessible, durable, well-indexed place, such as GitHub or Stack Exchange

100%.