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

As of 0.6.0, bindings on (at least) left, right, up and down no longer appear to work #1343

Closed
davep opened this issue Dec 12, 2022 · 21 comments · Fixed by #1346
Closed

As of 0.6.0, bindings on (at least) left, right, up and down no longer appear to work #1343

davep opened this issue Dec 12, 2022 · 21 comments · Fixed by #1346
Assignees
Labels
bug Something isn't working

Comments

@davep
Copy link
Contributor

davep commented Dec 12, 2022

With Textual 0.5.0 I had bindings on the arrow keys that worked fine. Having upgraded to 0.6.0 those bindings no longer seem to work. Isolating the issue I can recreate with this code (some other keys thrown in as control tests)

from textual.app import App, ComposeResult
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class Binder( App[ None ] ):

    CSS = """
    TextLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    BINDINGS = [
        Binding( "up",    "log( 'up' )", "Up" ),
        Binding( "down",  "log( 'down' )", "Down" ),
        Binding( "left",  "log( 'left' )", "Left" ),
        Binding( "right", "log( 'right' )", "Right" ),
        Binding( "a", "log( 'a' )", "a" ),
        Binding( "s", "log( 's' )", "s" ),
        Binding( "d", "log( 'd' )", "d" ),
        Binding( "f", "log( 'f' )", "f" ),
        Binding( "left_square_bracket", "log( '[' )", "[" ),
        Binding( "right_square_bracket", "log( ']' )", "]" ),
    ]

    def compose( self ) -> ComposeResult:
        yield Header()
        yield TextLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( TextLog ).write( "Ready..." )
        self.query_one( TextLog ).write( "Key bindings being looked for:" )
        self.query_one( TextLog ).write( ", ".join(
            [ binding.key for binding in self.BINDINGS ]
        ) )

    def action_log( self, name: str ) -> None:
        self.query_one( TextLog ).write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.query_one( TextLog ).write( f"Via event: {event!r}" )

if __name__ == "__main__":
    Binder().run()

Running the above, pressing the arrow keys vs some of the other keys...

Screenshot 2022-12-12 at 11 25 12

@davep davep added the bug Something isn't working label Dec 12, 2022
@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

This seems to be related to the universal keyword parameter on Binding. Again, the above code as shown above:

Screenshot 2022-12-12 at 12 32 46

That is, I get no BINDINGS response for the arrow keys, but I do get on_key events fired.

Now, if I mark the arrow keys as universal=True...

Screenshot 2022-12-12 at 12 34 23

That is: they do fire via BINDINGS but at the same time no on_key event happens.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

In fact, if I make all of the keys in the example code universal=True I get no on_key event but do get a BINDINGS-related action run.

I'm unsure at the moment if this is expected behaviour, and I'm unsure what would have happened here with 0.5.0 (I'll check), but there's a difference in how the arrow keys (at least, I've not found any other affected keys) work between 0.5.0 and 0.6.0.

The only (documented) change to gets between 0.5.0 and 0.6.0 is how BINDINGS is inherited.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Looks like I've found the culprit. It does seem to be an unintended consequence of the change to how bindings are inherited. Consider this version of the test code:

from textual.app import App, ComposeResult
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class Binder( App[ None ] ):

    CSS = """
    TextLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    BINDINGS = [
        Binding( "up",       "log( 'up' )",        "Up" ),
        Binding( "down",     "log( 'down' )",      "Down" ),
        Binding( "left",     "log( 'left' )",      "Left" ),
        Binding( "right",    "log( 'right' )",     "Right" ),
        Binding( "home",     "log( 'home' )",      "Home"),
        Binding( "end",      "log( 'end' )",       "End"),
        Binding( "pageup",   "log( 'page_up' )",   "Page Up"),
        Binding( "pagedown", "log( 'page_down' )", "Page Down"),
        Binding( "a",        "log( 'a' )",         "a" ),
        Binding( "s",        "log( 's' )",         "s" ),
        Binding( "d",        "log( 'd' )",         "d" ),
        Binding( "f",        "log( 'f' )",         "f" ),
        Binding( "comma",    "log( 'comma' )",     "," ),
        Binding( "f1",       "log( 'f1' )",        "F1" ),
   ]

    def compose( self ) -> ComposeResult:
        yield Header()
        yield TextLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( TextLog ).write( "Ready..." )
        self.query_one( TextLog ).write( "Key bindings being looked for:" )
        self.query_one( TextLog ).write( ", ".join(
            [ binding.key for binding in self.BINDINGS ]
        ) )

    def action_log( self, name: str ) -> None:
        self.query_one( TextLog ).write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.query_one( TextLog ).write( f"Via event: {event!r}" )

if __name__ == "__main__":
    Binder().run()

Now consider this code in widget.py. This would also explain why the bindings for the arrow keys weren't showing up in the footer either. Looking at the CHANGELOG there is mention of inherit_bindings, but it's not super-obvious to me what that actually is or how it should be used, and there's currently no mention of it in the documentation.

Moreover, I would have expected a child class (in this case my own App-derived class) to override any bindings inherited from the parent; but at first glance it seems that it's happening the other way around: the parent is overriding the bindings in the child.

More digging needed.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Just to confirm that this is a difference from 0.5.0, here's the output of the above code, pressing the arrow keys (note the keys being looked for aren't printed here as I commented that out for a moment):

Screenshot 2022-12-12 at 14 43 12

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

So it looks like it's one or more changes in #1170 (the code that adds the new Tree control) that has changed this behaviour.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Had a wee chat with @willmcgugan to confirm the intentions of the change in #1170 and was happy that the intended working made sense. So to dive a bit deeper I made the following change to Textual itself:

diff --git a/src/textual/app.py b/src/textual/app.py
index 19e7b4c1..efa5fd70 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1741,8 +1741,10 @@ class App(Generic[ReturnType], DOMNode):
         """

         for namespace, bindings in self._binding_chain:
+            self.log.debug( f"{'' if universal else 'NON-'}UNIVERSAL BINDING CHECK - {key} - {namespace} - {bindings}" )
             binding = bindings.keys.get(key)
             if binding is not None and binding.universal == universal:
+                self.log.debug( f"HIT {binding}" )
                 await self.action(binding.action, default_namespace=namespace)
                 return True
         return False

Running with textual console -x EVENT (to declutter the output), I see this (with a version of the test code above), when pressing up:

[15:38:36] DEBUG                                                                                                                                    app.py:1744
UNIVERSAL BINDING CHECK - up - Screen(id='_default') - Bindings({'up': Binding(key='up', action='scroll_up', description='Scroll Up', show=False,
key_display=None, universal=False), 'down': Binding(key='down', action='scroll_down', description='Scroll Down', show=False, key_display=None,
universal=False), 'left': Binding(key='left', action='scroll_left', description='Scroll Up', show=False, key_display=None, universal=False), 'right':
Binding(key='right', action='scroll_right', description='Scroll Right', show=False, key_display=None, universal=False), 'home': Binding(key='home',
action='scroll_home', description='Scroll Home', show=False, key_display=None, universal=False), 'end': Binding(key='end', action='scroll_end',
description='Scroll End', show=False, key_display=None, universal=False), 'pageup': Binding(key='pageup', action='page_up', description='Page Up', show=False,
key_display=None, universal=False), 'pagedown': Binding(key='pagedown', action='page_down', description='Page Down', show=False, key_display=None,
universal=False)})
[15:38:36] DEBUG                                                                                                                                    app.py:1744
UNIVERSAL BINDING CHECK - up - Binder(title='Binder') - Bindings({'up': Binding(key='up', action="log( 'up' )", description='Up', show=True, key_display=None,
universal=False), 'down': Binding(key='down', action="log( 'down' )", description='Down', show=True, key_display=None, universal=False), 'left':
Binding(key='left', action="log( 'left' )", description='Left', show=True, key_display=None, universal=False), 'right': Binding(key='right', action="log(
'right' )", description='Right', show=True, key_display=None, universal=False), 'home': Binding(key='home', action="log( 'home' )", description='Home',
show=True, key_display=None, universal=False), 'end': Binding(key='end', action="log( 'end' )", description='End', show=True, key_display=None,
universal=False), 'pageup': Binding(key='pageup', action="log( 'page_up' )", description='Page Up', show=True, key_display=None, universal=False), 'pagedown':
Binding(key='pagedown', action="log( 'page_down' )", description='Page Down', show=True, key_display=None, universal=False), 'a': Binding(key='a', action="log(
'a' )", description='a', show=True, key_display=None, universal=False), 's': Binding(key='s', action="log( 's' )", description='s', show=True,
key_display=None, universal=False), 'd': Binding(key='d', action="log( 'd' )", description='d', show=True, key_display=None, universal=False), 'f':
Binding(key='f', action="log( 'f' )", description='f', show=True, key_display=None, universal=False), 'comma': Binding(key='comma', action="log( 'comma' )",
description=',', show=True, key_display=None, universal=False), 'f1': Binding(key='f1', action="log( 'f1' )", description='F1', show=True, key_display=None,
universal=False), 'ctrl+c': Binding(key='ctrl+c', action='quit', description='', show=False, key_display=None, universal=True)})
[15:38:36] DEBUG                                                                                                                                    app.py:1744
NON-UNIVERSAL BINDING CHECK - up - Screen(id='_default') - Bindings({'up': Binding(key='up', action='scroll_up', description='Scroll Up', show=False,
key_display=None, universal=False), 'down': Binding(key='down', action='scroll_down', description='Scroll Down', show=False, key_display=None,
universal=False), 'left': Binding(key='left', action='scroll_left', description='Scroll Up', show=False, key_display=None, universal=False), 'right':
Binding(key='right', action='scroll_right', description='Scroll Right', show=False, key_display=None, universal=False), 'home': Binding(key='home',
action='scroll_home', description='Scroll Home', show=False, key_display=None, universal=False), 'end': Binding(key='end', action='scroll_end',
description='Scroll End', show=False, key_display=None, universal=False), 'pageup': Binding(key='pageup', action='page_up', description='Page Up', show=False,
key_display=None, universal=False), 'pagedown': Binding(key='pagedown', action='page_down', description='Page Down', show=False, key_display=None,
universal=False)})
[15:38:36] DEBUG                                                                                                                                    app.py:1747
HIT Binding(key='up', action='scroll_up', description='Scroll Up', show=False, key_display=None, universal=False)

In this instance at least, it would appear that the default Screen is robbing the App (well, my inherited App) of the bindings because the Screen's bindings, which presumably are also inherited from Widget win.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Actually, that may not be it and there may be something else afoot. Having created what looked like a failure mode above, I then moved to attempting to have the problem happen when it's all happening on a widget (as that's the case in my own app that first showed this problem on upgrade to 0.6.0):

from textual.app import App, ComposeResult
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class MyLog( TextLog ):

    BINDINGS = [
        Binding( "up",       "log( 'up' )",        "Up" ),
        Binding( "down",     "log( 'down' )",      "Down" ),
        Binding( "left",     "log( 'left' )",      "Left" ),
        Binding( "right",    "log( 'right' )",     "Right" ),
        Binding( "home",     "log( 'home' )",      "Home"),
        Binding( "end",      "log( 'end' )",       "End"),
        Binding( "pageup",   "log( 'page_up' )",   "Page Up"),
        Binding( "pagedown", "log( 'page_down' )", "Page Down")
    ]

    def on_mount( self ) -> None:
        self.write( "Ready..." )

    def action_log( self, name: str ) -> None:
        self.write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.write( f"Via event: {event!r}" )

class Binder( App[ None ] ):

    CSS = """
    MyLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield MyLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( MyLog ).focus()

if __name__ == "__main__":
    Binder().run()

but... that works fine!

Screenshot 2022-12-12 at 16 05 11

My suspicion now is that widgets might be affected if there's a non-default screen at play (which is the case in my app). So that's the next test.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Nope. That works fine too:

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class MyLog( TextLog ):

    BINDINGS = [
        Binding( "up",       "log( 'up' )",        "Up" ),
        Binding( "down",     "log( 'down' )",      "Down" ),
        Binding( "left",     "log( 'left' )",      "Left" ),
        Binding( "right",    "log( 'right' )",     "Right" ),
        Binding( "home",     "log( 'home' )",      "Home"),
        Binding( "end",      "log( 'end' )",       "End"),
        Binding( "pageup",   "log( 'page_up' )",   "Page Up"),
        Binding( "pagedown", "log( 'page_down' )", "Page Down")
    ]

    def on_mount( self ) -> None:
        self.write( "Ready..." )

    def action_log( self, name: str ) -> None:
        self.write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.write( f"Via event: {event!r}" )

class BinderScreen( Screen ):

    def compose( self ) -> ComposeResult:
        yield Header()
        yield MyLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( MyLog ).focus()

class Binder( App[ None ] ):

    CSS = """
    MyLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    SCREENS = {
        "main": BinderScreen
    }

    def on_mount( self ) -> None:
        self.push_screen( "main" )

if __name__ == "__main__":
    Binder().run()

Time to go back to my app and see what I'm doing that's different, that worked fine with 0.5.0 but stopped working with 0.6.0.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

What I'm doing different.... the bindings and actions are on the screen.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Bingo!

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class MyLog( TextLog ):
    pass

class BinderScreen( Screen ):

    BINDINGS = [
        Binding( "up",       "log( 'up' )",        "Up" ),
        Binding( "down",     "log( 'down' )",      "Down" ),
        Binding( "left",     "log( 'left' )",      "Left" ),
        Binding( "right",    "log( 'right' )",     "Right" ),
        Binding( "home",     "log( 'home' )",      "Home"),
        Binding( "end",      "log( 'end' )",       "End"),
        Binding( "pageup",   "log( 'page_up' )",   "Page Up"),
        Binding( "pagedown", "log( 'page_down' )", "Page Down")
    ]

    def action_log( self, name: str ) -> None:
        self.query_one( MyLog ).write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.query_one( MyLog ).write( f"Via event: {event!r}" )

    def compose( self ) -> ComposeResult:
        yield Header()
        yield MyLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( MyLog ).write( "Ready..." )
        self.query_one( MyLog ).focus()

class Binder( App[ None ] ):

    CSS = """
    MyLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    SCREENS = {
        "main": BinderScreen
    }

    def on_mount( self ) -> None:
        self.push_screen( "main" )

if __name__ == "__main__":
    Binder().run()

Screenshot 2022-12-12 at 16 15 57

So... it looks like the new key binding inheritance approach has an unintended consequence when it comes to movement key bindings on classes that inherit from App and Screen.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Okay, narrowing in more, take this code for example (similar to the above, just sans the inherited TextLog):

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import TextLog, Header, Footer
from textual.binding import Binding
from textual.events import Key

class BinderScreen( Screen ):

    BINDINGS = [
        Binding( "up",       "log( 'up' )",        "Up" ),
        Binding( "down",     "log( 'down' )",      "Down" ),
        Binding( "left",     "log( 'left' )",      "Left" ),
        Binding( "right",    "log( 'right' )",     "Right" ),
        Binding( "home",     "log( 'home' )",      "Home"),
        Binding( "end",      "log( 'end' )",       "End"),
        Binding( "pageup",   "log( 'page_up' )",   "Page Up"),
        Binding( "pagedown", "log( 'page_down' )", "Page Down")
    ]

    def action_log( self, name: str ) -> None:
        self.query_one( TextLog ).write( f"Via binding: {name}" )

    def on_key( self, event: Key ) -> None:
        self.query_one( TextLog ).write( f"Via event: {event!r}" )

    def compose( self ) -> ComposeResult:
        yield Header()
        yield TextLog()
        yield Footer()

    def on_mount( self ) -> None:
        self.query_one( TextLog ).write( "Ready..." )
        self.query_one( TextLog ).focus()

class Binder( App[ None ] ):

    CSS = """
    TextLog {
        background: #222200;
        color: #BBBB00;
        text-style: bold;
    }
    """

    SCREENS = {
        "main": BinderScreen
    }

    def on_mount( self ) -> None:
        self.push_screen( "main" )

if __name__ == "__main__":
    Binder().run()

This still has the same issue. I think it boils down to this: there is a TextLog at play. It inherits (eventually) from Widget. Widget has a set of BINDINGS that binds up, down, left, right, etc... Looking at the extra logging I've added to Textual for the moment:

[16:35:36] DEBUG                                                                                                                                    app.py:1744
NON-UNIVERSAL BINDING CHECK - down - TextLog(pseudo_classes={'focus-within', 'focus'}) - Bindings({'up': Binding(key='up', action='scroll_up',
description='Scroll Up', show=False, key_display=None, universal=False), 'down': Binding(key='down', action='scroll_down', description='Scroll Down',
show=False, key_display=None, universal=False), 'left': Binding(key='left', action='scroll_left', description='Scroll Up', show=False, key_display=None,
universal=False), 'right': Binding(key='right', action='scroll_right', description='Scroll Right', show=False, key_display=None, universal=False), 'home':
Binding(key='home', action='scroll_home', description='Scroll Home', show=False, key_display=None, universal=False), 'end': Binding(key='end',
action='scroll_end', description='Scroll End', show=False, key_display=None, universal=False), 'pageup': Binding(key='pageup', action='page_up',
description='Page Up', show=False, key_display=None, universal=False), 'pagedown': Binding(key='pagedown', action='page_down', description='Page Down',
show=False, key_display=None, universal=False)})
[16:35:36] DEBUG                                                                                                                                    app.py:1747
HIT Binding(key='down', action='scroll_down', description='Scroll Down', show=False, key_display=None, universal=False)

the binding handling code is finding a hit for that key in TextLog, despite the fact that I haven't told TextLog to do anything with those keys, which means it correctly (internally) but surprisingly runs off with the binding and my Screen never gets it.

@davep
Copy link
Contributor Author

davep commented Dec 12, 2022

Taking it back to my own app. There's a widget called StreakDay. It can receive focus. It inherits from TimelineDay (which can't receive focus), which in turn inherits from Static (which in turn inherits from Widget). Logging what its internal list of bindings look like, with 0.6.0, we see this:

Bindings(
    {
        'up': Binding(key='up', action='scroll_up', description='Scroll Up', show=False, key_display=None, universal=False),
        'down': Binding(key='down', action='scroll_down', description='Scroll Down', show=False, key_display=None, universal=False),
        'left': Binding(key='left', action='scroll_left', description='Scroll Up', show=False, key_display=None, universal=False),
        'right': Binding(key='right', action='scroll_right', description='Scroll Right', show=False, key_display=None, universal=False),
        'home': Binding(key='home', action='scroll_home', description='Scroll Home', show=False, key_display=None, universal=False),
        'end': Binding(key='end', action='scroll_end', description='Scroll End', show=False, key_display=None, universal=False),
        'pageup': Binding(key='pageup', action='page_up', description='Page Up', show=False, key_display=None, universal=False),
        'pagedown': Binding(key='pagedown', action='page_down', description='Page Down', show=False, key_display=None, universal=False),
        'minus': Binding(key='minus', action='done( -1 )', description='Less Done', show=True, key_display='-', universal=False),
        'backspace': Binding(key='backspace', action='done( -1 )', description='Less Done', show=True, key_display='-', universal=False),
        'equals_sign': Binding(key='equals_sign', action='done(  1 )', description='More Done', show=True, key_display='=', universal=False),
        'space': Binding(key='space', action='done(  1 )', description='More Done', show=True, key_display='=', universal=False)
    }
)

In other words we can see that it has inherited a whole bunch of bindings that we didn't intend for it to inherit, which would potentially rob our main screen of hits on the related actions.

So... using the inherit_bindings setting mentioned in the CHANGELOG for 0.6.0:

diff --git a/oidia/widgets/streakline.py b/oidia/widgets/streakline.py
index 3611ca6..bcd1bcb 100644
--- a/oidia/widgets/streakline.py
+++ b/oidia/widgets/streakline.py
@@ -23,7 +23,7 @@ from .timeline    import TimelineTitle, TimelineDay, Timeline
 from .title_input import TitleInput

 ##############################################################################
-class StreakDay( TimelineDay, can_focus=True ):
+class StreakDay( TimelineDay, can_focus=True, inherit_bindings=False ):
     """Widget for tracking if a day is done or not."""

     DEFAULT_CSS = """
@@ -77,6 +77,7 @@ class StreakDay( TimelineDay, can_focus=True ):
     def on_mount( self ) -> None:
         """Force an initial refresh on mount."""
         self.action_done( 0 )
+        self.log.debug( self._bindings )

     def watch_done( self, new_done: int ) -> None:
         """React to changes in the done count.

we then see this:

Bindings(
    {
        'minus': Binding(key='minus', action='done( -1 )', description='Less Done', show=True, key_display='-', universal=False),
        'backspace': Binding(key='backspace', action='done( -1 )', description='Less Done', show=True, key_display='-', universal=False),
        'equals_sign': Binding(key='equals_sign', action='done(  1 )', description='More Done', show=True, key_display='=', universal=False),
        'space': Binding(key='space', action='done(  1 )', description='More Done', show=True, key_display='=', universal=False)
    }
)

this shows that setting inherit_bindings to False does the trick. But... it doesn't make things work. My screen still never gets sight of the up/down/left/right/etc keys. The most likely cause of this is that between that focused widget, and my screen, are a layer or two of other container widgets that, while they don't have (and can't have) focus, they contain focus, and they will have inherited the bindings on Widget and so will handle or dispose of the keys before the screen sees them.

To make this work I'd have to set inherit_bindings=False on all the widgets between the focused widget and the screen, and most if not all of those are potentially going to be widgets used directly from Textual itself (actually not the case in my app, I think, but leaning into that would just be a workaround).

As such, and as agreed in conversation with @willmcgugan, I think a bit of a rethink of how the movement bindings are done is in order. Having them at play from Widget on down is always going to cause this problem, isn't easy to discover, isn't easy to work around and will likely lead to confusing outcomes more often than intended outcomes.

davep added a commit to davep/textual that referenced this issue Dec 12, 2022
Up until now there doesn't seem to have been any unit tests aimed squarely
at setting up bindings, as part of a running application, which are only
about testing the bindings. As such there was no way of slotting in tests
for how inheritance of bindings works.

This starts that process with a view to testing how inheriting
likely *should* work.

See Textualize#1343 for some background to this.
@davep
Copy link
Contributor Author

davep commented Dec 13, 2022

Started adding some unit tests to reproduce the issue in a controlled environment, along with some control tests that are fine at the moment and should remain fine throughout any changes I make (more tests to come). Having done so, having a quick look at the code, especially Widget, and trying to figure out the decisions made regarding the changes on b48a140, I'm curious about this:

    @property
    def is_container(self) -> bool:
        """Check if this widget is a container (contains other widgets).

        Returns:
            bool: True if this widget is a container.
        """
        return self.styles.layout is not None or bool(self.children)

    @property
    def is_scrollable(self) -> bool:
        """Check if this Widget may be scrolled.

        Returns:
            bool: True if this widget may be scrolled.
        """
        return self.styles.layout is not None or bool(self.children)

The latter looks to be somewhat important to decisions made elsewhere in the code, and could possibly be something that can deliver a more sensitive approach to providing these bindings, but unless I'm missing something obvious it seems that is_container and is_scrollable are the same thing. Which raises the question (that can perhaps be found in child classes?): why isn't is_scrollable simply return self.is_container?

@willmcgugan
Copy link
Collaborator

They are the same thing for the base class, and most widgets. But it is possible for something to be scrollable that is not a container. I think the only one we have is ScrollView.

davep added a commit to davep/textual that referenced this issue Dec 13, 2022
…able

The code is exactly the same between is_container and is_scrollable, and the
intent (confirmed in
Textualize#1343 (comment))
is that the latter is intended to be overridden in some circumstances (so
far only in `ScrollView`). As such I feel it better conveys intent and
reduces the changes of mismatched future changes if is_scrollable is defined
in respect to is_container.

The only possible reason I can think of is if there's a measurable
performance win here. Applying Knuth for the moment, at least for the scope
of this PR. I strongly suspect this is one of the 97% rather than one of the
3% and for the purposes of moving stuff around (which I may be doing as I
explore this) I believe this makes it easier to follow and to think about.
davep added a commit to davep/textual that referenced this issue Dec 13, 2022
This is the heart of the issue introduced by
Textualize@b48a140
and which is being investigated in
Textualize#1343 -- the child widget can be
focused, but (as far as the author of the code is concerned) it has no
bindings. Bindings for movement-oriented keys exist on the screen which
composes up the widget into it. Up until 0.5.0 this worked just fine. As of
0.6.0, because binding inheritance was introduced, the bindings for movement
that live at the `Widget` level cause the widget that has no bindings to
appear to have bindings.

While this can potentially be worked around with the use of
inherit_bindings, this isn't a very satisfying solution and also breaks the
rule of least astonishment.

This test is going to be key to all of this. This is the test that should be
made to work without breaking any of the other currently-passing tests.
@davep
Copy link
Contributor Author

davep commented Dec 13, 2022

While building up a collection of unit tests to explore all the issues caused by this change, I've found another that, while heavily overlapping with this one, I feel deserves an issue of its own: #1351

davep added a commit to davep/textual that referenced this issue Dec 13, 2022
@davep
Copy link
Contributor Author

davep commented Dec 13, 2022

I think I've got a pretty comprehensive collection of tests in #1346 now -- I suspect there's one more to add, which will be the situation where a screen wants to capture movement bindings, contains a container1 that won't ever scroll, which in turn contains widgets that won't ever scroll.

Footnotes

  1. This could be a problem for one possible solution. On a call we've all just had, there was discussion about how the best solution to all of this may be to break up the widget hierarchy a little so, at a high level, there's a "scrollable" and a "non-scrollable" branch, and then the scroll-oriented widgets derive from a common ancestor that introduces the relevant bindings, etc. But... right now I think all containers are considered to be scrollable (see above) and I can see that this may not be desirable. A container of non-container widgets is absolutely a situation where you don't want it to ever be seen as scrollable in any way -- consider the 5x5 GameGrid filled with GameCells; or even a Screen of Buttons?

@davep
Copy link
Contributor Author

davep commented Dec 13, 2022

Tidying up the current set of tests, with a view to making them a PR in their own right (0.6.x needs these standalone of any changes anyway, either to describe the current problem, or to test changes made to fix it), and suddenly this:

Screenshot 2022-12-13 at 17 12 25

11 instances of these tests passed before this, and looking at the issue I suspect the problem may be simply a timing issue with faking key presses and subsequently detecting them. Perhaps all of the fake presses are going to require a pause along the way?

@davep
Copy link
Contributor Author

davep commented Dec 14, 2022

Had a good long chat with @willmcgugan about this this morning, and the options we have available to resolving this. After going over the causes and symptoms we've arrived at this:

  • Binding has a universal argument, which is intended to give priority to a binding. The primary purpose of this is to allow bindings at the app level to optionally win out (see the setting up of Ctrl+c for example).
  • This actually should, right now, likely carry right through a hierarchy anyway. That would explain this near-parenthetical observation from a couple of days back.
  • Due to binding inheritance, apps such as 5x5 and OIDIA would need universal=True for movement-related key bindings now (which are on the screen, not the focused widget) so they win.

In other words, mostly, this is working as advertised and intended, it's just that binding inheritance has had the unintended consequence of elevating the movement keys to a sort of special "system" status. Apps that make use of them in the way I have been will need to bind them as universal.

The next step is to modify OIDIA and 5x5 in this way and be confidant that it fixes the problem. If so...

The naming of universal isn't ideal and doesn't really properly convey the intent. So we now have this plan:

  • Rename universal to priority. This will be an optional bool value.
  • Introduce a new class variable, likely called PRIORITY_BINDINGS. This will be a bool. When a Binding is created, if priority is not set, it will default to this value.
  • By default have App (I think?) and Screen set PRIORITY_BINDINGS to True as it's highly likely that anyone setting bindings at those levels will expect them to win over anything lower down.

davep added a commit to davep/oidia that referenced this issue Dec 14, 2022
Due to binding inheritance being introduced in 0.6.0, the cursor keys
stopped working in the app. The issue is that they're getting consumed by
widgets *below* the screen now. This makes the screen as having priority for
those keys.

See Textualize/textual#1343 for a bunch of
background on this, and note that this will change some more in the future
once the fix we're aiming for goes into place.
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
The cursor keys stopped working in 5x5 once binding inheritance was
introduced in 0.6.0 (see Textualize#1343). Making them `universal` keys here fixes the
issue. This won't be the final form of this change, but this fixes this
example so that it works with 0.6.0 (so anyone cloning down the code and
running with an installed 0.6.0 will get the full effect).

Once the final work resulting from Textualize#1343 takes place this will need a final
update (and should be a good test example for the changes).
@davep
Copy link
Contributor Author

davep commented Dec 14, 2022

Both #1360 and davep/oidia@a3dbb19 nicely demonstrate that this ultimately does come down to the use of universal to override the Textual-internal movement-oriented keys. Looks like this is the approach to take.

davep added a commit to davep/textual that referenced this issue Dec 14, 2022
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
This commit changes things slightly so that the priority of a binding is an
three-state: True, False or None. True and False are firm choices that
nothing else should override. None says "fall back to whatever default is up
for grabs".

The commit also then adds support for a default priority and, when building
a binding, it uses that if the binding has a priority of None.

See Textualize#1343.
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
This works in conjunction with BINDINGS. If a widget has BINDINGS, and if
any of those bindings have a priority that isn't True or False, the value of
PRIORITY_BINDINGS will be used (or the value from one of the parent classes,
if there are any, will be used if it isn't set on the current class).

See Textualize#1343.
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
davep added a commit to davep/textual that referenced this issue Dec 14, 2022
I feel some more will be needed, but this is all of the basics, hitting all
of the important points that relate to Textualize#1343. More importantly all of the
xfails are now removed.
@davep
Copy link
Contributor Author

davep commented Dec 15, 2022

Something I should add to #1346

  • A test of a stack of bindings for the same key, none priority, to check which wins.
  • A test of a stack of bindings for the same key, all priority, to check which wins.
  • A test of a stack of bindings for the same key, mixed priorities, to check which wins.

No matter what the final decision is on how these situations work out, the tests will be there to ensure the code works as desired and that any future changes take this into account.

@github-actions
Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants