RecycleView Label redraws to wrong text_size height ONLY when rv.data is repopulated with SAME items
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)
Thank you for that
The example is not runable. Please make a minimum runable example showing the issue.
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
textof the label for the new label widget is set from data and corresponding KV rules are triggered. -
default_sizesets the height of the labels to 20. - The width is updated for whatever reason causing the
text_sizeto be re-computed and thereforetexture_sizeis also to be updated, which causesheightto be updated setting the label height. - Everything looks ok.
-
datais cleared and set again to the values. - The rv sees
datawas updated so takes the cached labels, sets their newtext, which happens to be the same as the old text for the middle label. The label'stext_sizeandtexture_sizeis also the same as before because the width and text are unchanged. -
default_sizesets 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
datais 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
dataand 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
dataso 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 thervwill 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.
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)
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.
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
@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%.