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

[Improvement] Add/expose API to update Footer's Bindings #1057

Closed
epi052 opened this issue Oct 30, 2022 · 15 comments
Closed

[Improvement] Add/expose API to update Footer's Bindings #1057

epi052 opened this issue Oct 30, 2022 · 15 comments
Labels
enhancement New feature or request

Comments

@epi052
Copy link

epi052 commented Oct 30, 2022

First off, thank you for textual, it's pretty darn great!

Second, this isn't a bug, nor is it a full feature.

It would be awesome if there were a way to easily update the footer's bindings, specifically their attributes like description/visibility. I was definitely surprised at the lack of control over this particular piece of textual.

I have some hacky workarounds that kind of work and kind of don't. I think asyncio/non-determinism is a part of it, but not all of my issue.

use cases:

  • for the 'toggle dark mode', we could show Dark mode when in light mode, and vice versa, tying the modificiation to the normal action/watch
  • consider when there is a child Input component to a Screen. The Screen has a set of bindings that make sense when the Input isn't focused (? for a help menu, for instance), and the Input has bindings that don't make sense when it's not focused (ESC to drop focus). In this instance, the trick of splitting Bindings across Screens doesn't help, as the Input still inherits those that are in the parent Screen.

Ideally, we could easily update the footer's visuals to have finer-grained control over the footer's visuals.

@willmcgugan
Copy link
Collaborator

I wouldn't want to update they keys. Bindings should be relatively static, i.e. not change dynamically. But I agree about the meta-data. For instance, a toggle key that changes its description based on current state.

Bindings do update according to the focused widgets. Textual will merge the bindings for the focus widget up through the DOM, to Screen and then App. The footer should update when focus changes.

If something isn't working, could you prepare a short demo app for sake of discussion?

@epi052
Copy link
Author

epi052 commented Oct 30, 2022

Agreed on the keys/actions/etc being static, and yes, I'm specifically interested in updating the metadata.

Things are working as intended from the docs and your description, it's more a matter of a little more flexibility.

The code below shows that once you tab into the Input box, the QUESTION_MARK footer entry doesn't make sense in the context of Input, since typing a ? will enter the character into the Input. In this case, QUESTION_MARK should have its show attribute set to false. The same applies to the m for dark/light mode.

Additionally, it shows how we could use existing things to dynamically update a binding's name. Based on dark/light mode, we could dynamically update the description to reflect the one we'd swap to if pressed.

side note: self.app.set_focus(None) works in my real app, but not in this toy one, not sure why.

from textual.widgets import Header, Footer, Static, Input
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical


class DemoInput(Input):
    BINDINGS = [Binding("escape", "lose_focus", "Unfocus input")]

    async def lose_focus(self) -> None:
        self.app.set_focus(None)


class HelpMenu(Screen):
    BINDINGS = [("escape", "app.pop_screen", "Exit help")]

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Help!")
        yield Footer()


class MainMenu(Screen):
    BINDINGS = [("question_mark", "app.push_screen('help')", "Help")]

    def compose(self) -> ComposeResult:
        yield Header()
        yield Vertical(Static("Howdy!"), DemoInput(placeholder="Type here"))
        yield Footer()


class Demo(App):
    SCREENS = {"help": HelpMenu()}

    def on_mount(self) -> None:
        self.push_screen(MainMenu())

    def action_toggle_dark(self) -> None:
        self.dark = not self.dark

    def watch_dark(self, dark: bool) -> None:
        """Watches the dark bool."""
        self.set_class(dark, "-dark-mode")
        self.set_class(not dark, "-light-mode")
        self.refresh_css()

        # Update text to 'Dark mode' when we're in light
        # mode and vice versa
        choice: str = "Light" if self.dark else "Dark"
        self.update_binding("m", description=f"{choice} mode")

    def update_binding(
        self, key: str, description: str, visibility: bool = True
    ) -> None:
        """Updates binding metadata."""
        # do stuff ...


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

@epi052
Copy link
Author

epi052 commented Oct 30, 2022

unfocused:
image

input focused:
image

@davep davep added the enhancement New feature or request label Oct 31, 2022
@geekscrapy
Copy link

geekscrapy commented Feb 24, 2023

I would love to be able to ESC when on any Widget. In addition, being able to take away focus by clicking elsewhere on the Screen is the normal behaviour of other apps.

I have tried the likes of:

    def compose(self) -> ComposeResult:
        yield Container(
            Input(),
        )

On a Widget, with a combination with:

class TestApp(App):

    BINDINGS = [
        Binding('q', 'quit', 'Quit'),
        Binding('escape', 'esc_focus()', 'Unfocus input'),
    ]

    def action_esc_focus(self):
        self.app.set_focus(self.app.focused.parent)

But I think due to the Container pseudo_classes attribute, it drops focus back to the Input each time we set the focus to the parent?

Container(pseudo_classes={'focus-within'})

@davep
Copy link
Contributor

davep commented Feb 24, 2023

@geekscrapy You can set_focus( None ). For example:

from textual.app     import App, ComposeResult
from textual.widgets import Header, Footer, Input

class RemoveFocusApp( App[ None ] ):

    BINDINGS = [
        ( "escape", "unfocus", "Unfocus" ),
    ]

    CSS = """
    Screen {
        align: center middle;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Input( placeholder="Here is the input" )
        yield Footer()

    def action_unfocus( self ) -> None:
        self.set_focus( None )

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

@Emasoft
Copy link

Emasoft commented Sep 12, 2023

I wouldn't want to update they keys. Bindings should be relatively static, i.e. not change dynamically. But I agree about the meta-data. For instance, a toggle key that changes its description based on current state.

I think that bindings should be dynamic. Even if it is true that bindings can change with the focus, it is not enough.
Think about different states of operations in the same widget: like the "ribbon" concept used by Microsoft in Word for example. The options and buttons changed according to what was currently selected. If I select a file, I should have a very different toolbar on the footer compared to selecting a folder. Or even the file type: if I highlight an image file in Kupo, the toolbar on the footer should display options like "Convert", "Resize", "Crop", "Stitch", "Edit Exif", "Filter", "Make Thumbnail", etc. If I move the cursor on an audio file, I should see options like "Listen", "Convert", "Join", "Split", "Filter", "Edit ID3 Tags", etc. On a PDF or an ePub I should see "Read", "Edit Metadata", "Split", "Merge", "Add pages", "Reorder", "Print",etc. And just source code files could have tons of different options. But this is an universal concept: the toolbar do not change only when you change widget, but also when the selected content changes, or even when different things are displayed in the same widget. Also different options can become available given certain conditions: for example if you are online or offline, if you are in a secure connection or not, if a certain cloud service is available and logged in, if files are being downloaded in background, if you are doing a backup or chatting on telegram, etc.etc.

But not only Bindings should be dynamic, the descriptions should not be only strings, but renderables customizable and with a dynamic style linked to certain states of the binding label. A binding could be a switch, a toggle, a menu, a color palette, a draggable handle, a news ticker with scrolling text, a progress bar, a tab, breadcrumbs, filenames mask, a git branch, a search and replace input field, an undo/redo button, a toogle for Bold or Italic, etc. If you are editing a markdown file and your cursor moves inside a table, an option to add or remove a column (or a row) should appear.
From a design point of view, no choice could be more limiting than forcing the bindings to be fixed for each widget. If the library will not be designed from the ground up to be powerful enough to do those kind of things, in the future this will be a bottleneck impeding its development.

All those things should be possible with bindings and made easy to write thanks to the bindings quick syntax used by Textual, one of the best things I ever seen for rapid development.

Please give more consideration to this proposal! 🙏

@mzebrak
Copy link

mzebrak commented Sep 29, 2023

I would also appreciate dynamic bindings: #3041 (comment) which comes from #1792

@jakubziebin
Copy link

Dynamic bindings would be so helpful, i also had a problem with this: #3419

@mzebrak
Copy link

mzebrak commented Sep 29, 2023

Also related: #2006

@Klavionik
Copy link

I would also very much appreciate having dynamic bindings.

I have a simple screen that can have multiple states and a different set of controls in each state. It makes sense to me to just rewire bindings and not to have multiple screens with identical UI.

Right now I'm doing this, but I do realize that I live in a state of sin and deserve whatever happens to me. :)

def update_bindings(self, new_bindings: list[tuple[str, str, str]]):
    default_bindings = {b.key for b in App.BINDINGS}  # type: ignore
    
    # Clear current bindings, preserving the default ones.
    for key in list(self._bindings.keys):
        if key not in default_bindings:
            self._bindings.keys.pop(key)
    
    # Assign new bindings.
    for key, action, description in new_bindings:
        self.bind(key, action, description=description)

    # Notify the footer about binding changes.
    self._footer._bindings_changed(None)

@mon
Copy link
Contributor

mon commented Jan 19, 2024

A "blessed" way to do this would be great. A similar workaround that I've discovered for my use-case is to call self.screen.post_message(events.ScreenResume()) inside my App instance, which redraws the footer.

Full micro-example, hit s to toggle the footer's desc:

from textual import events
from textual.app import App
from textual.widgets import Footer, Placeholder


class TestApp(App):
    updating = False

    def compose(self):
        yield Placeholder()
        yield Footer()

    def action_toggle_updating(self):
        self.updating = not self.updating
        desc = "Stop updating" if self.updating else "Start updating"
        self.bind("s", "app.toggle_updating", description=desc)
        self.screen.post_message(events.ScreenResume())

    def on_mount(self) -> None:
        self.action_toggle_updating()


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

@willmcgugan
Copy link
Collaborator

This is planned. Update coming soon.

Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

@mon
Copy link
Contributor

mon commented Mar 20, 2024

Excellent news! How was it fixed? Does calling bind() now automatically update the Footer automatically?

@arjunj132
Copy link

This is planned. Update coming soon.

Lets goooo, I have been waiting for this feature for such a long time!

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

No branches or pull requests

10 participants