Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add property to map or unmap a Widget #8645

Open
ikus060 opened this issue Mar 13, 2024 · 19 comments
Open

Add property to map or unmap a Widget #8645

ikus060 opened this issue Mar 13, 2024 · 19 comments

Comments

@ikus060
Copy link
Contributor

ikus060 commented Mar 13, 2024

Is your feature request related to a problem? Please describe.

As I need to dynamically show/hide widget based on properties. Current implementation required to add or remove the widget which required alot of extra code and get error prone when you have many widget getting show or hide based on different condition.

Describe the solution you'd like

I would like to have a display property on all widget to control if the Widget should get display or not by the layout.

This is not the same as opacity which control if the widget is visible.

This display property is similar to CSS display property. When display is none, the HTML element is completely hidden, doesn't occupy space and user cannot interact with it.

Describe alternatives you've considered

I tried to make use of opacity and enabled disable, but widget still occupy spaces in the layout. Also updating 3 properties to hide a widget is getting very error prone. e.g.: opacity:0, height: 0, disable: True vs display: False

I Also tried to implement my own version of display property, but I'm quickly running into trouble. Keeping the ordering of the widget is difficult with add_widget and remove_widget if you have multiple widget that get hidden and shown based on different condition.

Additional context
N/A

@ElliotGarbus
Copy link
Contributor

ElliotGarbus commented Mar 13, 2024

As a workaround you could add a method to Widget:

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.widget import Widget

kv = """
BoxLayout:
    orientation: 'vertical'
    AnchorLayout:
        Button:
            id: button
            text: 'Make me disappear'
            size_hint: None, None
            size: dp(150), dp(48)
            on_release: print('Hello')
    ToggleButton:
        size_hint_y: None
        height: dp(48)
        text: 'display on/off'
        on_state: 
            if self.state == 'down': button.display('hide')
            if self.state == 'normal': button.display('show')
"""


def display(self, mode):
    if mode == 'hide':
        self._og_size_hint = self.size_hint.copy()
        self._og_size = self.size.copy()
        self.size_hint = None, None
        self.size = 0, 0
        self.disabled = True
        self.opacity = 0
    elif mode == 'show':
        self.size_hint = self._og_size_hint
        self.size = self._og_size
        self.disabled = False
        self.opacity = 1
    else:
        raise ValueError


Widget.display = display


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


AddMethodToWidgetApp().run()

Another option to consider depending on your use case, is to use a ScreenManager and Screens.

@ElliotGarbus
Copy link
Contributor

I wanted to make display a kivy property, so made the changed below. This is a very reasonable workaround.

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.widget import Widget

kv = """
<Widget>:
    display: True  # create a kivy property
    on_display: self.display_action(*args)

BoxLayout:
    orientation: 'vertical'
    AnchorLayout:
        Button:
            id: button
            text: 'Make me disappear'
            size_hint: None, None
            size: dp(150), dp(48)
            on_release: print('Hello')
    ToggleButton:
        size_hint_y: None
        height: dp(48)
        text: 'display on/off'
        on_state: 
            button.display = self.state == 'normal'
"""


def _display_action(self, obj, value):
    if not value:
        self._og_size_hint = self.size_hint
        self._og_size = self.size.copy()
        self.size_hint = None, None
        self.size = 0, 0
        self.disabled = True
        self.opacity = 0
    else:
        self.size_hint = self._og_size_hint
        self.size = self._og_size
        self.disabled = False
        self.opacity = 1

Widget.display_action = _display_action


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


AddMethodToWidgetApp().run()

@ikus060
Copy link
Contributor Author

ikus060 commented Mar 15, 2024

@ElliotGarbus
I also took this approach at first, and I have trouble to recover the properties between display: True and display:False if the kivy design language also define addition rule for width and height. In this example if you define height with minimum_height then it will replace the value enforce by _display_action. It also doesn't consider additional size settings like padding, etc.

After a couple trial and error, I came up with this solution where I create a chain list of widget when required. When a widget is set with display=False, I remove it from the parent (this effectively remove all the drawing related to it). When display=true, I'm adding it back.

class BaseDisplay(Widget):
    display = BooleanProperty(True)
    _parent = None
    _prev = None

    def on_parent(self, widget, value):
        # Keep reference to previous parent.
        if value:
            self._parent = value

    def on_display(self, widget, value):
        # If we don't have any parent. We don't have anything to do.
        if self._parent is None:
            return
        # Let remove/add Widget.
        if value and widget.parent is None:
            prev = self._prev
            while prev is not None and getattr(prev, 'parent', None) is None:
                prev = getattr(prev, '_prev', None)
            index = self._parent.children.index(prev) if prev else -1
            self._parent.add_widget(widget, index=index)
        elif not value and widget.parent is not None:
            index = self._parent.children.index(widget) - len(self._parent.children)
            # Keep reference to previous widget
            if self._prev is None:
                self._prev = self._parent.children[index + 1] if index < -1 else None
            next_sibling = self._parent.children[index - 1] if abs(index - 1) <= len(self._parent.children) else None
            if next_sibling and hasattr(next_sibling, 'display'):
                next_sibling._prev = widget
            self._parent.remove_widget(widget)

@ElliotGarbus
Copy link
Contributor

Glad to see you have something that works for your use case. I don't really understand your use case, but it seems like you could use a ScreenManager to make the widgets disappear.

@FilipeMarch
Copy link
Contributor

FilipeMarch commented Mar 15, 2024

@ElliotGarbus

Although your solution is good for some cases, I believe the display: False should actually not render the widget, not simply hide it. In a GridLayout, if you set the spacing, it will still show the spacing as if the widget were still there:

from kivy.app import App
from kivy.factory import Factory as F
from kivy.lang import Builder
from kivy.uix.widget import Widget

kv = Builder.load_string(
    """
<Widget>:
    display: True  # create a kivy property
    on_display: self.display_action(*args)

<MainScreen@Screen>:
    condition: True
    GridLayout:
        cols: 3 if root.condition else 2
        spacing: dp(100)
        Button:
            text: '1'
        Button:
            text: '2'
        Button:
            text: '3'
            display: False
"""
)


def _display_action(self, obj, value):
    if not value:
        self._og_size_hint = self.size_hint
        self._og_size = self.size.copy()
        self.size_hint = None, None
        self.size = 0, 0
        self.disabled = True
        self.opacity = 0
    else:
        self.size_hint = self._og_size_hint
        self.size = self._og_size
        self.disabled = False
        self.opacity = 1


Widget.display_action = _display_action


class MainApp(App):
    def build(self):
        return F.MainScreen()

    def some_function(self):
        print("Hello World")


MainApp().run()

image

Also, depending on the specifics of your Button rendering, the display: False will simply not work. For example, this CButton from kivy-widgets library:

from kivy.app import App
from kivy.factory import Factory as F
from kivy.lang import Builder
from kivy.uix.widget import Widget
from kivy_widgets.buttons import CButton

kv = Builder.load_string(
    """
<Widget>:
    display: True  # create a kivy property
    on_display: self.display_action(*args)

<MainScreen@Screen>:
    condition: True
    canvas.before:
        Color:
            rgba: 1, 0, 0, .1
        Rectangle:
            size: self.size
            pos: self.pos
    GridLayout:
        cols: 3 if root.condition else 2
        Button:
            text: '1'
        CButton:
            text: '2'
            display: False
        Button:
            text: '3'
"""
)


def _display_action(self, obj, value):
    if not value:
        self._og_size_hint = self.size_hint
        self._og_size = self.size.copy()
        self.size_hint = None, None
        self.size = 0, 0
        self.disabled = True
        self.opacity = 0
    else:
        self.size_hint = self._og_size_hint
        self.size = self._og_size
        self.disabled = False
        self.opacity = 1


Widget.display_action = _display_action


class MainApp(App):
    def build(self):
        return F.MainScreen()

    def some_function(self):
        print("Hello World")


MainApp().run()

image

@FilipeMarch
Copy link
Contributor

Glad to see you have something that works for your use case. I don't really understand your use case, but it seems like you could use a ScreenManager to make the widgets disappear.

Could you expand on that or provide an example?

@ElliotGarbus
Copy link
Contributor

@FilipeMarch Yes, I see your point, if you use a ScreenManager, the same problem exists. The space is still there. Here is an example using the ScreenManager to hide the widget.

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.screenmanager import ScreenManager, NoTransition
from kivy.properties import BooleanProperty


kv = """
<DisplayButton>:
    Screen:
        name: 'on_screen'
        Button:
            text: 'Make me disappear'
            on_release: print('Hello') 
    Screen:
        name: 'off_screen'
    
BoxLayout:
    orientation: 'vertical'
    AnchorLayout:
        DisplayButton:
            id: db
            size_hint: None, None
            size: dp(150), dp(48)
    ToggleButton:
        size_hint_y: None
        height: dp(48)
        text: 'display on/off'
        on_state: 
            db.display = self.state == 'normal'
"""


class DisplayButton(ScreenManager):
    display = BooleanProperty(True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.transition = NoTransition()

    def on_display(self, obj, value):
        self.current = 'on_screen' if value else 'off_screen'


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


AddMethodToWidgetApp().run()

I suppose the screens could be sized rather than the ScreenManager...

@FilipeMarch
Copy link
Contributor

@FilipeMarch Yes, I see your point, if you use a ScreenManager, the same problem exists. The space is still there. Here is an example using the ScreenManager to hide the widget.

Thanks for the example, I understand it now.
But as you can see this is too much complexity for a simple behaviour that is present in the web in general.

<DisplayButton>:
    Screen:
        name: 'on_screen'
        Button:
            text: 'Make me disappear'
            on_release: print('Hello') 
    Screen:
        name: 'off_screen'

class DisplayButton(ScreenManager):
    display = BooleanProperty(True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.transition = NoTransition()

    def on_display(self, obj, value):
        self.current = "on_screen" if value else "off_screen"

How many lines does this has? It could be even bigger depending what the user actually wants.
Ideally, what I understand from OP request, it is a feature request like this:

Button:
    render: False

This would actually remove the widget from the widget tree.

@ElliotGarbus
Copy link
Contributor

How about as a mixin...

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.properties import BooleanProperty

kv = """
BoxLayout:
    orientation: 'vertical'
    AnchorLayout:
        DisplayButton:
            id: button
            text: 'Make me disappear'
            size_hint: None, None
            size: dp(150), dp(48)
            on_release: print('Hello')
    ToggleButton:
        size_hint_y: None
        height: dp(48)
        text: 'display on/off'
        on_state: 
            button.display = self.state == 'normal'
"""


class DisplayBehavior:
    display = BooleanProperty(True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._parent = None
        self._widget = None

    def on_display(self, obj, value):
        if not value:
            self._parent = self.parent
            self._widget = self
            self._parent.remove_widget(self)
        else:
            self._parent.add_widget(self._widget)


class DisplayButton(DisplayBehavior, Button):
    pass


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


AddMethodToWidgetApp().run()

@FilipeMarch
Copy link
Contributor

FilipeMarch commented Mar 15, 2024

How about as a mixin...

Very good idea. What about saving the initial position? Because the tree order can change.

from kivy.app import App
from kivy.factory import Factory as F
from kivy.lang import Builder
from kivy.properties import BooleanProperty
from kivy.uix.button import Button


class DisplayBehavior:
    display = BooleanProperty(True)
    initial_position = F.NumericProperty()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._parent = None
        self._widget = None

    def on_display(self, obj, value):
        if not value:
            self._parent = self.parent
            self._widget = self
            self.initial_position = self._parent.children.index(self)
            self._parent.remove_widget(self)
        else:
            self._parent.add_widget(self._widget, index=self.initial_position)


class DisplayAnchorLayout(DisplayBehavior, F.AnchorLayout):
    pass


kv = """
BoxLayout:
    orientation: 'vertical'
    on_children: print('children changed')
    DisplayAnchorLayout:
        id: button
        DisplayButton:
            text: 'Make me disappear'
            size_hint: None, None
            size: dp(150), dp(48)
            on_release: print('Hello')
    ToggleButton:
        size_hint_y: None
        height: dp(48)
        text: 'display on/off'
        on_state: 
            button.display = self.state == 'normal'
"""


class DisplayButton(DisplayBehavior, Button):
    pass


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


AddMethodToWidgetApp().run()

@ikus060
Copy link
Contributor Author

ikus060 commented Mar 16, 2024

@FilipeMarch Yes, we need to keep the initial position. That the most callengin part. If you look at my implementation that more or less the general idea. It to keep reference to previous widget in the tree to know where to insert it.

If you have multiple children getting hidden, you cannot use index to restore the widget at the right location.

@FilipeMarch
Copy link
Contributor

@FilipeMarch Yes, we need to keep the initial position. That the most callengin part. If you look at my implementation that more or less the general idea. It to keep reference to previous widget in the tree to know where to insert it.

If you have multiple children getting hidden, you cannot use index to restore the widget at the right location.

Well I think one solution would be to save the index of each widget in the tree, then before adding a widget back to its parent you check all the indexes and make sure you insert in such a way all indexes are still in increasing order

Here I asked ChatGPT for such an half baked implementation idea: https://chat.openai.com/share/b97d8776-4620-485d-8442-c7d02a82a365

For example

initial indexes: [1,2,3,4,5]
suppose you remove 2 and 4
current_indexes: [1,3,5]

Now suppose you set widget_4.display = True

Now you just need a function that will receives [1,3,5] as arg and outputs 2, which is the index the widget_4 must be added

I probably won't enter on computer today so I may implement it until Monday. You can wait or follow my reasoning, I hope it helps

@ElliotGarbus
Copy link
Contributor

Preserving the order adds a number of complications. Assume we have a layout that includes some Widgets with DisplayBeahvior and some without. The widget lacks a mechanism to track additions/removals from the enclosing layout.

The add_widget, remove_widget methods of the enclosing layout would need to be overloaded to capture the changes. A shadow children list would need to track the order of the widgets. The change in display state could remove and re-add all of the child widgets that have the display attribute equal True as a way to preserve order.

This suggests a more robust solution would need to be implemented at the layout level. DisplayParentBehavior or DisplayLayoutBehavior for an enclosing Layout that includes widgets with the display attribute. This behavior could add the display attribute to all the child widgets.

Things get more complicated with nesting, and the addition of Layouts under Layouts... Restricting the requirements can simplify the implementation.

@ElliotGarbus
Copy link
Contributor

Here is a prototype that adds a behavior to the layout and the widgets. I added a name to the widgets as a way to access the widget to show/hide.

from kivy.app import App
from kivy.lang import Builder
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.properties import BooleanProperty, StringProperty


"""
Prototype behaviors to remove/restore widgets in a layout
TODO: raise exception if a Layout or a widget without DisplayWidgetBehavior is added
"""


class DisplayWidgetBehavior:
    display = BooleanProperty(True)
    name = StringProperty()  # use name to access widget

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._parent = None

    def on_parent(self, obj, value):
        if value:
            self._parent = value

    def on_display(self, obj, value):
        if value:
            self._parent.restore_widget()
        else:
            self._parent.remove_widget(self)


class DisplayLayoutBehavior:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._shadow = [] # used to hold the list of all the widgets in the layout hidden and visible

    def add_widget(self, widget, index=0):
        super().add_widget(widget, index)
        if widget not in self._shadow:
            self._shadow = self.children.copy()

    def display_toggle(self, name):
        for w in self._shadow:
            if w.name == name:
                w.display = not w.display
                break

    def restore_widget(self):
        self.clear_widgets()
        for w in self._shadow[::-1]:
            if w.display:
                super().add_widget(w)


class DisplayBoxLayout(DisplayLayoutBehavior, BoxLayout):
    pass


class DisplayButton(DisplayWidgetBehavior, Button):
    pass


kv = """
BoxLayout:
    orientation: 'vertical'
    Label: 
        size_hint_y: None
        height: dp(30)
        text: 'Test Display Layout'
    AnchorLayout:        
        DisplayBoxLayout:
            id: display_layout
            size_hint: None, None
            size: dp(500), dp(48)
            DisplayButton:
                text: 'one'
                name: self.text
            DisplayButton:
                text: 'two'
                name: self.text
            DisplayButton:
                text: 'three'
                name: self.text
    BoxLayout:
        size_hint_y: None
        height: dp(48)
        Button:
            text: 'one'
            on_release: display_layout.display_toggle('one')
        Button:
            text: 'two'
            on_release: display_layout.display_toggle('two')
        Button:
            text: 'three'
            on_release: display_layout.display_toggle('three')
"""


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


DisplayTestApp().run()

@FilipeMarch
Copy link
Contributor

@ElliotGarbus That's awesome, bro. Your solution is very neat, I haven't found any problems until now. I will keep using it for some time. I'll let you know if I find any issues.

@ikus060
Copy link
Contributor Author

ikus060 commented Mar 19, 2024

@ElliotGarbus Thanks for your POC. Your recommendation provide a way to solve part of the issue.

If this implementation is adopted directly in Kivy, it would be nice that a widget with display = False keep it's reference to parent. Same for children. Modification of display property should only have an impact in the rendering process and not on the widget tree.

@FilipeMarch
Copy link
Contributor

@ElliotGarbus Thanks for your POC. Your recommendation provide a way to solve part of the issue.

If this implementation is adopted directly in Kivy, it would be nice that a widget with display = False keep it's reference to parent. Same for children. Modification of display property should only have an impact in the rendering process and not on the widget tree.

There's a problem with not changing the widget tree that I pointed at #8645 (comment)

@ikus060
Copy link
Contributor Author

ikus060 commented Mar 19, 2024

@FilipeMarch I'm not a Kivy expert, but I would think it might be possible to change the way Layout are working to make sure of an Alias Property(e.g.: _display_children) that would be a reflection of children property that only include widget with display. Then GridLayout and other widget could make use of only that property to compute the posotion and spacing, etc.

@ElliotGarbus
Copy link
Contributor

ElliotGarbus commented Mar 19, 2024

Here is an updated prototype. This makes a few improvements and segregates the test code such that the file can be imported directly:

from kivy.uix.layout import Layout
from kivy.properties import BooleanProperty


"""
Prototype behaviors to remove/restore widgets in a layout.

A property "display" is added to a widget.  If display is True the widget is visible.  If display is False
the widget is removed from the widget tree, and is not visible.  The goal is the be able to remove widgets
from the layout and later restore them while maintaining the relative position of the widgets.

The code defines 2 behaviors:
DisplayWidgetBehavior: adds a property, display, to the widget.  display is a BooleanProperty. 
When display is True the widget is visible, with display is False, the widget is removed
from the widget tree.

DisplayLayoutBehavior: must be added to the layout that will enclose the display widgets.  It preserves the order
of the widgets.

For simplicity, the DisplayLayoutBehavior does not support Nested Layouts
"""


class DisplayWidgetBehavior:
    display = BooleanProperty(True)

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._parent = None

    def on_parent(self, obj, value):
        if value:
            self._parent = value

    def on_display(self, obj, value):
        if value:
            self._parent.restore_widget()
        else:
            self._parent.hide_widget(self)


class DisplayLayoutBehavior:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._shadow = []  # holds a copy of the widgets
        self.clearing = False

    def add_widget(self, widget, index=0):
        if not isinstance(widget, DisplayWidgetBehavior):
            raise TypeError('Child Widgets must have DisplayWidgetBehavior')
        if isinstance(widget, Layout):
            raise TypeError('Nested Layouts not supported')
        super().add_widget(widget, index)
        if widget not in self._shadow:
            self._shadow.insert(index, widget)

    def remove_widget(self, widget):
        super().remove_widget(widget)
        if not self.clearing:
            self._shadow.remove(widget)

    def hide_widget(self, widget):
        super().remove_widget(widget)

    def restore_widget(self):
        self._remove_all_widgets()
        for w in self._shadow[::-1]:
            if w.display:
                super().add_widget(w)

    def _remove_all_widgets(self):
        # this clears the widget list, but preserves the shadow list
        self.clearing = True
        super().clear_widgets()
        self.clearing = False


if __name__ == '__main__':
    from kivy.app import App
    from kivy.lang import Builder
    from kivy.uix.boxlayout import BoxLayout
    from kivy.uix.button import Button
    from textwrap import dedent


    class DisplayBoxLayout(DisplayLayoutBehavior, BoxLayout):
        pass


    class DisplayButton(DisplayWidgetBehavior, Button):
        pass


    kv = dedent("""
    BoxLayout:
        orientation: 'vertical'
        Label: 
            size_hint_y: None
            height: dp(30)
            text: 'Test Display Layout'
        AnchorLayout:        
            DisplayBoxLayout:
                id: display_layout
                size_hint: None, None
                size: dp(500), dp(48)
                DisplayButton:
                    id: btn_1
                    text: 'one'
                    name: self.text
                DisplayButton:
                    id: btn_2
                    text: 'two'
                    name: self.text
                DisplayButton:
                    id: btn_3
                    text: 'three'
                    name: self.text
        BoxLayout:
            size_hint_y: None
            height: dp(48)
            Button:
                text: 'one'
                on_release: btn_1.display = not btn_1.display
            Button:
                text: 'two'
                on_release: btn_2.display = not btn_2.display
            Button:
                text: 'three'
                on_release: btn_3.display = not btn_3.display
            ToggleButton: 
                text: 'Add/remove button 4'
                on_state: app.add_remove(self.state)
            ToggleButton: 
                id: show_hide
                disabled: True
                text: 'hide/show button 4'
                on_state: app.show_hide(self.state)
    """)


    class DisplayTestApp(App):
        def __init__(self, **kwargs):
            super().__init__(**kwargs)
            self.db_4 = DisplayButton(text='four')

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

        def add_remove(self, state):
            if state == 'down':
                self.root.ids.display_layout.add_widget(self.db_4)
                self.root.ids.show_hide.disabled = False
            else:
                self.root.ids.display_layout.remove_widget(self.db_4)
                self.db_4.display = True
                self.root.ids.show_hide.disabled = True
                self.root.ids.show_hide.state = 'normal'

        def show_hide(self, state):
            self.db_4.display = {'normal': True, 'down': False }[state]


    DisplayTestApp().run()

Edit: added some minimal documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants