Textualize / textual

The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.
https://textual.textualize.io/
MIT License
25.64k stars 787 forks source link

New widgets being ignored #5024

Closed xavierog closed 1 month ago

xavierog commented 1 month ago

As of Textual 0.80.0, RichLog delays actual rendering until it gets a Resize event, thus making the lack of such events visible.

This MRE showcases a seemingly nonsensical behaviour where a RichLog widget does not systematically receive Show and Resize events depending on the presence and visibility of a sibling ProgressBar widget:

from textual.app import App
from textual.containers import VerticalScroll
from textual.widgets import Footer, ProgressBar, RichLog

class MRE(App):
    BINDINGS = [("z", "toggle_console", "Console")]
    CSS = """
    RichLog { border-top: dashed blue; height: 6; }
    .hidden { display: none; }
    """
    def compose(self):
        yield VerticalScroll()
        yield ProgressBar(classes='hidden') # removing or displaying this widget prevents the bug
        yield Footer() # clicking "Console" in the footer prevents the bug
        yield RichLog(classes='hidden')

    def on_ready(self) -> None:
        self.query_one('RichLog').write('\n'.join(f'line #{i}' for i in range(5)))

    def action_toggle_console(self) -> None:
        self.query_one('RichLog').toggle_class('hidden')

if __name__ == '__main__':
    app = MRE()
    app.run()

Expectations

After hitting z, this MRE should display these lines at the bottom of the screen:

line #0
line #1
line #2
line #3
line #4

mre-ok

Encountered behaviour

In practice, these lines:

mre-fail

Early investigations noticed that, afer hitting z, Compositor.reflow() returned that no widget was hidden, no widget was shown and no widget was resized.

github-actions[bot] commented 1 month ago

We found the following entry in the FAQ which you may find helpful:

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by FAQtory

xavierog commented 1 month ago

Events reflected by the Textual dev console after pressing z:

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
# Pressing 'z' to make the console appear:
Key(key='z', character='z', name='z', is_printable=True) >>> VerticalScroll() method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> Screen(id='_default') method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=<App.on_key>
<action> namespace=MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) action_name='toggle_console' params=()
Mount() >>> ScrollBar(name='vertical', window_virtual_size=100, window_size=0, position=0, thickness=2) method=<Widget.on_mount>
# No Show/Resize events, the core of this bugreport.
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
# Pressing 'z' to make the console disappear:
Key(key='z', character='z', name='z', is_printable=True) >>> VerticalScroll() method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> Screen(id='_default') method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=<App.on_key>
<action> namespace=MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) action_name='toggle_console' params=()
Hide() >>> RichLog() method=<Widget.on_hide>
Resize(size=Size(width=255, height=30), virtual_size=Size(width=255, height=30)) >>> VerticalScroll() method=None
Hide() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<ScrollBar.on_hide>
Hide() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<Widget.on_hide>
# Hide and Resize events ok
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
# Pressing 'z' to make the console reappear:
Key(key='z', character='z', name='z', is_printable=True) >>> VerticalScroll() method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> Screen(id='_default') method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=<App.on_key>
<action> namespace=MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) action_name='toggle_console' params=()
# No Show/Resize events
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
# Pressing 'z' to make the console disappear again:
Key(key='z', character='z', name='z', is_printable=True) >>> VerticalScroll() method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> Screen(id='_default') method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=<App.on_key>
<action> namespace=MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) action_name='toggle_console' params=()
Hide() >>> RichLog() method=<Widget.on_hide>
Hide() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<ScrollBar.on_hide>
Hide() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<Widget.on_hide>
# Hide events ok; missing Resize event to the VerticalScroll?
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
# Pressing 'z' to make the console and its contents appear:
Key(key='z', character='z', name='z', is_printable=True) >>> VerticalScroll() method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> Screen(id='_default') method=<Widget.on_key>
Key(key='z', character='z', name='z', is_printable=True) >>> MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=<App.on_key>
<action> namespace=MRE(title='MRE', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) action_name='toggle_console' params=()
Resize(size=Size(width=2, height=5), virtual_size=Size(width=255, height=5), container_size=Size(width=255, height=5)) >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=None
Show() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<Widget.on_show>
Resize(size=Size(width=255, height=6), virtual_size=Size(width=253, height=5), container_size=Size(width=255, height=5)) >>> RichLog() method=<RichLog.on_resize>
Show() >>> RichLog() method=<Widget.on_show>
Resize(size=Size(width=255, height=24), virtual_size=Size(width=255, height=24)) >>> VerticalScroll() method=None
Show() >>> ScrollBar(name='vertical', window_virtual_size=0, window_size=5, position=0, thickness=2) method=<Widget.on_show>
# Finally: Show and Resize events
xavierog commented 1 month ago

Notes:

This suggests Bar's indeterminate animation somehow triggers this issue. ... but adjusting the MRE so it uses Bar instead of ProgressBar actually removes the bug:

from textual.app import App
from textual.containers import VerticalScroll
from textual.widgets import Footer, Label, RichLog
from textual.widgets._progress_bar import Bar

class MRE(App):
    BINDINGS = [("z", "toggle_console", "Console")]
    CSS = """
    RichLog { border-top: dashed blue; height: 6; }
    .hidden { display: none; }
    """
    def compose(self):
        yield VerticalScroll()
        yield Bar(classes='hidden')
        yield Footer()
        yield RichLog(classes='hidden')

    def on_ready(self) -> None:
        self.query_one('RichLog').write('\n'.join(f'line #{i}' for i in range(5)))

    def action_toggle_console(self) -> None:
        self.query_one('RichLog').toggle_class('hidden')

if __name__ == '__main__':
    app = MRE()
    app.run()

Otherly put, it has to be a ProgressBar, and it has to be in indeterminate mode.

xavierog commented 1 month ago

FWIW, it looks like the result of a race condition related to the value of self.auto_refresh. I played with that value but still have a hard time getting relevant conclusions out of it. Apparently, auto_refresh is used only in Bar and LoadingIndicator.

Could this issue be a consequence of https://github.com/Textualize/textual/issues/4835? Edit: probably not: removing the condition in DOMNode.automatic_refresh() does not help.

willmcgugan commented 1 month ago

Thanks for doing the legwork. Confirmed it is something to do with auto refresh.

willmcgugan commented 1 month ago

Well that was fun. If a refresh (such as via auto_refresh) occurred at the point a widgets became visible, they could be missed when the layout is reflowed. Which resulted in no Resize message.

xavierog commented 1 month ago

Very interesting. Could the same thing happen when a widget becomes invisible?

xavierog commented 1 month ago

Also: how come auto_refresh induces a refresh for a hidden widget despite #4847?

github-actions[bot] commented 1 month ago

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

willmcgugan commented 1 month ago

Potentially. I may have to do something similar with hidden widgets.

xavierog commented 1 month ago

Potentially. I may have to do something similar with hidden widgets.

Would it translate into a rendering issue by chance?

from textual.app import App
from textual.containers import VerticalScroll
from textual.widgets import Footer, ProgressBar, RichLog, Placeholder

class MRE(App):
    BINDINGS = [("z", "toggle('RichLog')", "Console"), ("x", "toggle('ProgressBar')", "Progress bar")]
    CSS = """
    Placeholder { height: 15; }
    RichLog { border-top: dashed blue; height: 6; }
    .hidden { display: none; }
    """
    def compose(self):
        with VerticalScroll():
            for i in range(10):
                yield Placeholder()
        yield ProgressBar(classes='hidden')
        yield RichLog(classes='hidden')
        yield Footer()

    def on_ready(self) -> None:
        self.query_one('RichLog').write('\n'.join(f'line #{i}' for i in range(5)))

    def action_toggle(self, widget_type) -> None:
        self.query_one(widget_type).toggle_class('hidden')

if __name__ == '__main__':
    app = MRE()
    app.run()

issue-5024-is-back