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
24.11k stars 744 forks source link

Bugs related to multi-screen approach #1846

Closed mzebrak closed 1 year ago

mzebrak commented 1 year ago

Version: 0.11.0

Consider a following code:

from textual.app import App
from textual.binding import Binding
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Footer, Header, Placeholder

class SomeWidget(Widget):
    def compose(self):
        yield Placeholder('SomeWidget')

class BaseScreen(Screen):

    def compose(self):
        # looks like heres also a problem - adding this line breaks the app `NoMatches: No nodes match <DOMQuery Header() filter='HeaderTitle'>`
        # yield Header()

        yield SomeWidget()
        yield Footer()

class FirstScreen(BaseScreen):
    BINDINGS = [
        Binding('n', 'switch_screen("second")', 'Second screen - via name'),
        Binding('m', 'switch', 'Second screen - via object'),
    ]

    def action_switch(self):
        self.app.switch_screen(SecondScreen())

class SecondScreen(BaseScreen):
    BINDINGS = [
        Binding('n', 'switch_screen("first")', 'First screen - via name'),
        Binding('m', 'switch', 'First screen - via objecct'),

    ]

    def action_switch(self):
        self.app.switch_screen(FirstScreen())

class MyApp(App):
    SCREENS = {
        'first': FirstScreen,
        'second': SecondScreen,
    }

    BINDINGS = [
        Binding('q', 'query', 'Query for SomeWidget'),
    ]

    def on_mount(self):
        # with 'first', we got 2 of SomeWidget's after first keypress (m). When there is `First()`, we got a 1 SomeWidget
        self.push_screen(FirstScreen())

    def action_query(self):
        self.log(f"Current stack: {self.screen_stack}")

        # Should be always equal to 1, but sometimes it's 2 or even 3 (while switching via both 'n' and 'm' keys)
        self.log(f"Number of SomeWidget's: {len(self.query(SomeWidget))}")

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

The example provided above shows some problems. Try launching it and switching views via "n" and "m" keys. I think it's a descriptive example so there's not much to add, but it seems that there are 2 bugs in this piece of code - one is a problem with displaying the header, because after uncommenting it throws an error, and second problem occurs when switching screens.

github-actions[bot] commented 1 year ago

Thank you for your issue. Give us a little time to review it.

PS. You might want to check the FAQ if you haven't done so already.

This is an automated reply, generated by FAQtory

willmcgugan commented 1 year ago

I can't reproduce any issue with that code. The error you mention in a comment suggests you are running an older version of Textual. Can you please double check you are running 0.11.0. Running textual diagnose will help with that.

mzebrak commented 1 year ago

I can't reproduce any issue with that code. The error you mention in a comment suggests you are running an older version of Textual. Can you please double check you are running 0.11.0. Running textual diagnose will help with that.

That's interesting because:

(screens-py3.10) PS D:\PycharmProjects\screens> textual diagnose
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.11.0 |
| Rich    | 13.3.1 |

## Python

| Name           | Value                                                                                             |
|----------------|---------------------------------------------------------------------------------------------------|
| Version        | 3.10.7                                                                                            |
| Implementation | CPython                                                                                           |
| Compiler       | MSC v.1933 64 bit (AMD64)                                                                         |
| Executable     | C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\Scripts\python.exe |

## Operating System

| Name    | Value      |
|---------|------------|
| System  | Windows    |
| Release | 10         |
| Version | 10.0.19044 |

## Terminal

| Name                 | Value            |
|----------------------|------------------|
| Terminal Application | Windows Terminal |
| TERM                 | *Not set*        |
| COLORTERM            | *Not set*        |
| FORCE_COLOR          | *Not set*        |
| NO_COLOR             | *Not set*        |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=120, height=26 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 120                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 26                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |

Problem with switching screens:

  1. When I run textual console and then textual run --dev <script_path>:

    • press 'N', press 'Q' -> "Number of SomeWidget's: 1", "Current stack: [Screen(id='_default'), SecondScreen()]"
    • press 'N', press 'Q' -> "Number of SomeWidget's: 2", "Current stack: [Screen(id='_default'), FirstScreen()]"
    • press 'N', press 'Q' -> "Number of SomeWidget's: 2", "Current stack: [Screen(id='_default'), SecondScreen()]"
    • press 'M', press 'Q' -> "Number of SomeWidget's: 3", "Current stack: [Screen(id='_default'), FirstScreen()]"
    • press 'M', press 'Q' -> "Number of SomeWidget's: 3", "Current stack: [Screen(id='_default'), SecondScreen()]"
    • press 'N', press 'Q' -> "Number of SomeWidget's: 2", "Current stack: [Screen(id='_default'), FirstScreen()]"

    And of course amount of SomeWidget should be always equal to 1. So there is a problem clearly visible when switching screens via switch_screen("first") and switch_screen("second")

  2. When I run textual console and then textual run --dev <script_path>:

    • press 'M', press 'Q' -> "Number of SomeWidget's: 1", "Current stack: [Screen(id='_default'), SecondScreen()]"
    • press 'M', press 'Q' -> "Number of SomeWidget's: 1", "Current stack: [Screen(id='_default'), FirstScreen()]"
    • press 'M', press 'Q' -> "Number of SomeWidget's: 1", "Current stack: [Screen(id='_default'), SecondScreen()]"

    No problem when switching screens via self.app.switch_screen(FirstScreen()) and self.app.switch_screen(SecondScreen())

I don't think it is a windows platform-specific bug since as I correctly remember - saw it for the first time under the Ubuntu:22.04 environment.

Problem with Header throwing an exception:

  1. Uncomment the yield Header() in BaseScreen's compose method.
  2. textual run --dev
  3. Press "N" or "M", it doesn't matter, exception is raised in both ways.

Traceback:

╭───────────────────────────────────────── Traceback (most recent call last) ──────────────────────────────────────────╮
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\widgets\_he │
│ ader.py:136 in on_mount                                                                                              │
│                                                                                                                      │
│   133 │   │   def set_sub_title(sub_title: str) -> None:                                                             │
│   134 │   │   │   self.query_one(HeaderTitle).sub_text = sub_title                                                   │
│   135 │   │                                                                                                          │
│ ❱ 136 │   │   self.watch(self.app, "title", set_title)                                                               │
│   137 │   │   self.watch(self.app, "sub_title", set_sub_title)                                                       │
│   138                                                                                                                │
│                                                                                                                      │
│ ╭──────────────────────────────────────── locals ─────────────────────────────────────────╮                          │
│ │          self = Header()                                                                │                          │
│ │ set_sub_title = <function Header.on_mount.<locals>.set_sub_title at 0x0000023B81A181F0> │                          │
│ │     set_title = <function Header.on_mount.<locals>.set_title at 0x0000023B818FFC70>     │                          │
│ ╰─────────────────────────────────────────────────────────────────────────────────────────╯                          │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\dom.py:669  │
│ in watch                                                                                                             │
│                                                                                                                      │
│   666 │   │   │   callback: A callback to run when attribute changes.                                                │
│   667 │   │   │   init: Check watchers on first call.                                                                │
│   668 │   │   """                                                                                                    │
│ ❱ 669 │   │   _watch(self, obj, attribute_name, callback, init=init)                                                 │
│   670 │                                                                                                              │
│   671 │   def get_pseudo_classes(self) -> Iterable[str]:                                                             │
│   672 │   │   """Get any pseudo classes applicable to this Node, e.g. hover, focus.                                  │
│                                                                                                                      │
│ ╭─────────────────────────────────────── locals ───────────────────────────────────────╮                             │
│ │ attribute_name = 'title'                                                             │                             │
│ │       callback = <function Header.on_mount.<locals>.set_title at 0x0000023B818FFC70> │                             │
│ │           init = True                                                                │                             │
│ │            obj = MyApp(title='MyApp', classes={'-dark-mode'})                        │                             │
│ │           self = Header()                                                            │                             │
│ ╰──────────────────────────────────────────────────────────────────────────────────────╯                             │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\reactive.py │
│ :354 in _watch                                                                                                       │
│                                                                                                                      │
│   351 │   watcher_list.append((node, callback))                                                                      │
│   352 │   if init:                                                                                                   │
│   353 │   │   current_value = getattr(obj, attribute_name, None)                                                     │
│ ❱ 354 │   │   Reactive._check_watchers(obj, attribute_name, current_value)                                           │
│   355                                                                                                                │
│                                                                                                                      │
│ ╭───────────────────────────────────────────────────── locals ─────────────────────────────────────────────────────╮ │
│ │ attribute_name = 'title'                                                                                         │ │
│ │       callback = <function Header.on_mount.<locals>.set_title at 0x0000023B818FFC70>                             │ │
│ │  current_value = 'MyApp'                                                                                         │ │
│ │           init = True                                                                                            │ │
│ │           node = Header()                                                                                        │ │
│ │            obj = MyApp(title='MyApp', classes={'-dark-mode'})                                                    │ │
│ │   watcher_list = [                                                                                               │ │
│ │                  │   (Header(), <function Header.on_mount.<locals>.set_title at 0x0000023B818FEC20>),            │ │
│ │                  │   (Header(), <function Header.on_mount.<locals>.set_title at 0x0000023B818FFC70>)             │ │
│ │                  ]                                                                                               │ │
│ │       watchers = {                                                                                               │ │
│ │                  │   'title': [                                                                                  │ │
│ │                  │   │   (Header(), <function Header.on_mount.<locals>.set_title at 0x0000023B818FEC20>),        │ │
│ │                  │   │   (Header(), <function Header.on_mount.<locals>.set_title at 0x0000023B818FFC70>)         │ │
│ │                  │   ],                                                                                          │ │
│ │                  │   'sub_title': [                                                                              │ │
│ │                  │   │   (                                                                                       │ │
│ │                  │   │   │   Header(),                                                                           │ │
│ │                  │   │   │   <function Header.on_mount.<locals>.set_sub_title at 0x0000023B818FE4D0>             │ │
│ │                  │   │   )                                                                                       │ │
│ │                  │   ]                                                                                           │ │
│ │                  }                                                                                               │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\widgets\_he │
│ ader.py:131 in set_title                                                                                             │
│                                                                                                                      │
│   128 │                                                                                        ╭───── locals ─────╮  │
│   129 │   def on_mount(self) -> None:                                                          │  self = Header() │  │
│   130 │   │   def set_title(title: str) -> None:                                               │ title = 'MyApp'  │  │
│ ❱ 131 │   │   │   self.query_one(HeaderTitle).text = title                                     ╰──────────────────╯  │
│   132 │   │                                                                                                          │
│   133 │   │   def set_sub_title(sub_title: str) -> None:                                                             │
│   134 │   │   │   self.query_one(HeaderTitle).sub_text = sub_title                                                   │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\dom.py:839  │
│ in query_one                                                                                                         │
│                                                                                                                      │
│   836 │   │   │   query_selector = selector.__name__                                                                 │
│   837 │   │   query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector)                                        │
│   838 │   │                                                                                                          │
│ ❱ 839 │   │   return query.only_one() if expect_type is None else query.only_one(expect_type)                        │
│   840 │                                                                                                              │
│   841 │   def set_styles(self, css: str | None = None, **update_styles) -> None:                                     │
│   842 │   │   """Set custom styles on this object."""                                                                │
│                                                                                                                      │
│ ╭──────────────────────────── locals ────────────────────────────╮                                                   │
│ │       DOMQuery = <class 'textual.css.query.DOMQuery'>          │                                                   │
│ │    expect_type = None                                          │                                                   │
│ │          query = <DOMQuery Header() filter='HeaderTitle'>      │                                                   │
│ │ query_selector = 'HeaderTitle'                                 │                                                   │
│ │       selector = <class 'textual.widgets._header.HeaderTitle'> │                                                   │
│ │           self = Header()                                      │                                                   │
│ ╰────────────────────────────────────────────────────────────────╯                                                   │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\css\query.p │
│ y:243 in only_one                                                                                                    │
│                                                                                                                      │
│   240 │   │   """                                                                                                    │
│   241 │   │   # Call on first to get the first item. Here we'll use all of the                                       │
│   242 │   │   # testing and checking it provides.                                                                    │
│ ❱ 243 │   │   the_one = self.first(expect_type) if expect_type is not None else self.first()                         │
│   244 │   │   try:                                                                                                   │
│   245 │   │   │   # Now see if we can access a subsequent item in the nodes. There                                   │
│   246 │   │   │   # should *not* be anything there, so we *should* get an                                            │
│                                                                                                                      │
│ ╭──────────────────────── locals ────────────────────────╮                                                           │
│ │ expect_type = None                                     │                                                           │
│ │        self = <DOMQuery Header() filter='HeaderTitle'> │                                                           │
│ ╰────────────────────────────────────────────────────────╯                                                           │
│                                                                                                                      │
│ C:\Users\...\AppData\Local\pypoetry\Cache\virtualenvs\screens-XJncIhWR-py3.10\lib\site-packages\textual\css\query.p │
│ y:214 in first                                                                                                       │
│                                                                                                                      │
│   211 │   │   │   │   │   )                                                                                          │
│   212 │   │   │   return first                                                                                       │
│   213 │   │   else:                                                                                                  │
│ ❱ 214 │   │   │   raise NoMatches(f"No nodes match {self!r}")                                                        │
│   215 │                                                                                                              │
│   216 │   @overload                                                                                                  │
│   217 │   def only_one(self) -> Widget:                                                                              │
│                                                                                                                      │
│ ╭──────────────────────── locals ────────────────────────╮                                                           │
│ │ expect_type = None                                     │                                                           │
│ │        self = <DOMQuery Header() filter='HeaderTitle'> │                                                           │
│ ╰────────────────────────────────────────────────────────╯                                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
NoMatches: No nodes match <DOMQuery Header() filter='HeaderTitle'>
github-actions[bot] commented 1 year ago

Don't forget to star the repository!

Follow @textualizeio for Textual updates.