textual icon indicating copy to clipboard operation
textual copied to clipboard

Footer binding disappear

Open mzebrak opened this issue 1 year ago • 4 comments

I encountered a weird situation when bindings were missing in the footer. Also, ctrl+c doesn't work until they appear again, however, the UI keeps refreshing. Pressing a button / resizing the terminal causes everything to work properly again.

Version: 0.83.0.

Reproduction steps:

  • press A
  • press S
  • <bindings visible>
  • press A
  • press S
  • <missing bindings>
  • resize / click outside the terminal / press button
  • <bindings visible>

MRE:

from __future__ import annotations

import uuid
from typing import cast

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal
from textual.reactive import var
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Button, Footer, Label


class AddNewElementScreen(Screen):
    BINDINGS = [
        Binding("escape", "dismiss()", "Back"),
        Binding("s", "save", "Save a new element"),
    ]

    def compose(self) -> ComposeResult:
        yield Label("Press 's' to save a new element")
        yield Footer()

    def action_save(self) -> None:
        app = cast(MyApp, self.app)
        app.elements.append(uuid.uuid4().hex)
        app.mutate_reactive(app.__class__.elements)
        self.dismiss()


class Elements(Widget):
    DEFAULT_CSS = """
    Elements {
        height: auto;

        Horizontal {
            height: auto;
            background: $primary-background;

            Label {
                padding: 1;
            }
        }
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("Press 'a' to add a new element")

    def on_mount(self) -> None:
        self.watch(self.app, "elements", self.remount_elements, init=False)

    async def remount_elements(self) -> None:
        with self.app.batch_update():
            await self.remove_children()  # this is crucial for the bug, bug does not occur when no children are removed
            await self.mount_all(self.create_elements())

    def create_elements(self) -> list[Widget]:
        app = cast(MyApp, self.app)
        return [  # has to include a Button, when no Button is included, the bug does not occur
            Horizontal(Label(element), Button("1")) for element in app.elements
        ]


class SomeScreen(Screen):
    BINDINGS = [
        Binding("q", "quit", "Quit"),
        Binding("a", "add_new_element", "Add new element"),
    ]

    def compose(self) -> ComposeResult:
        yield Elements()
        yield Footer()

    def action_add_new_element(self) -> None:
        self.app.push_screen(AddNewElementScreen())


class MyApp(App):
    BINDINGS = [
        Binding("q", "quit", "Quit"),
    ]

    elements: var[list[str]] = var([], init=False)

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

    def action_quit(self) -> None:
        self.exit()


MyApp().run()

Screencast from 10-22-2024 12:42:55 PM.webm

What is really weird is that when no Button is mounted - everything works perfectly fine:

Screencast from 10-22-2024 12:52:39 PM.webm

mzebrak avatar Oct 22 '24 10:10 mzebrak

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

github-actions[bot] avatar Oct 22 '24 10:10 github-actions[bot]

Would you mind attempting to make that MRE more minimal? i.e. the simplest possible code that causes the issue. Remove all code that isn't essential in reproducing the issue.

willmcgugan avatar Oct 22 '24 10:10 willmcgugan

From 94 to 58 - I guess this is all I can get keeping the readability. The bug does not trigger when the remove and mount_all happens on a single screen without a reactive update. I also think the dismiss is crucial - otherwise, there is no need to update the bindings?

from __future__ import annotations

import uuid
from typing import cast

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal
from textual.reactive import var
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Button, Footer, Label


class AddNewElementScreen(Screen):
    BINDINGS = [Binding("s", "save", "Save a new element")]

    def compose(self) -> ComposeResult:
        yield Label("Press 's' to save a new element")
        yield Footer()

    def action_save(self) -> None:
        app = cast(MyApp, self.app)
        app.elements.append(uuid.uuid4().hex)
        app.mutate_reactive(app.__class__.elements)
        self.dismiss()


class Elements(Widget):
    def on_mount(self) -> None:
        self.watch(self.app, "elements", self.remount_elements, init=False)

    async def remount_elements(self) -> None:
        with self.app.batch_update():
            await self.remove_children()  # this is crucial for the bug, bug does not occur when no children are removed
            await self.mount_all(self.create_elements())

    def create_elements(self) -> list[Widget]:
        app = cast(MyApp, self.app)
        return [  # has to include a Button, when no Button is included, the bug does not occur
            Horizontal(Label(element), Button("1")) for element in app.elements
        ]


class MyApp(App):
    BINDINGS = [Binding("a", "add_new_element", "Add new element")]

    elements: var[list[str]] = var([], init=False)

    def compose(self) -> ComposeResult:
        yield Elements()
        yield Footer()

    def action_add_new_element(self) -> None:
        self.app.push_screen(AddNewElementScreen())


MyApp().run()

--- EDIT Here it also occurs:

from __future__ import annotations

from typing import cast

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import var
from textual.screen import Screen
from textual.widgets import Button, Footer, Label


class TriggerUpdateScreen(Screen):
    BINDINGS = [Binding("t", "trigger_update", "Trigger the reactive update")]

    def compose(self) -> ComposeResult:
        yield Label("Press 't' to trigger the reactive update")
        yield Footer()

    def action_trigger_update(self) -> None:
        app = cast(MyApp, self.app)
        app.trigger_update = not app.trigger_update
        self.dismiss()


class MyApp(App):
    BINDINGS = [Binding("t", "trigger_update_via_new_screen", "Trigger update via new screen")]

    trigger_update = var(False, init=False)

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

    def on_mount(self) -> None:
        self.watch(self, "trigger_update", self.remount_button, init=False)

    async def remount_button(self) -> None:
        with self.batch_update():
            # has to be a Button, bug does not occur with Label. Also removing the button before mounting again is crucial
            await self.query(Button).remove()
            await self.mount(Button("anything"))

    def action_trigger_update_via_new_screen(self) -> None:
        self.app.push_screen(TriggerUpdateScreen())


MyApp().run()

mzebrak avatar Oct 22 '24 11:10 mzebrak

This is a focus issue. Explaination below.

Looks like maybe related to: https://github.com/Textualize/textual/issues/5630

Because to reproduce it with the previously mentioned:

MRE
from __future__ import annotations

from typing import cast

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import var
from textual.screen import Screen
from textual.widgets import Button, Footer, Label


class TriggerUpdateScreen(Screen):
    BINDINGS = [Binding("t", "trigger_update", "Trigger the reactive update")]

    def compose(self) -> ComposeResult:
        yield Label("Press 't' to trigger the reactive update")
        yield Footer()

    def action_trigger_update(self) -> None:
        app = cast(MyApp, self.app)
        app.trigger_update = not app.trigger_update
        self.dismiss()


class MyApp(App):
    BINDINGS = [Binding("t", "trigger_update_via_new_screen", "Trigger update via new screen")]

    trigger_update = var(False, init=False)

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

    def on_mount(self) -> None:
        self.watch(self, "trigger_update", self.remount_button, init=False)

    async def remount_button(self) -> None:
        with self.batch_update():
            # has to be a Button, bug does not occur with Label. Also removing the button before mounting again is crucial
            await self.query(Button).remove()
            await self.mount(Button("anything"))

    def action_trigger_update_via_new_screen(self) -> None:
        self.app.push_screen(TriggerUpdateScreen())


MyApp().run()

We need to remove any focusable widget - like a Button or Checkbox, that explains why Label doesn't reproduce - because is not focusable. And when bindings disappear, it looks like there is no focus. Pressing and holding the LMB over the widget causes it to be focused again, and bindings appear again.

Also calling action_focus_next() after await self.mount(Button("anything")) seems to be a workaround for this MRE.

mzebrak avatar May 12 '25 07:05 mzebrak