Coordinating state between widgets #1099
-
|
Hi! Fair warning, I'm trying to cram some existing mental models into Textual and I've never done any serious GUI/TUI programming, so feel free to "that doesn't make any sense, do this instead" me :) I'm playing with an application that has some complex state that will be consumed by many different widgets across many different screens. The state can be updated by actions that might happen on different widgets across different screens. My initial thought was "store the state in one place, on the from dataclasses import dataclass
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import reactive
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Static, Footer
@dataclass
class State:
msg: str = "Hello!"
style: Style = Style(color = "red")
def __rich__(self):
return self.msg
class DisplayWidget(Widget):
def render(self) -> RenderableType:
return Text(self.app.state.msg, style=self.app.state.style)
class ScreenA(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("A"))
yield DisplayWidget()
yield Footer()
class ScreenB(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("B"))
yield DisplayWidget()
yield Footer()
class StateApp(App):
state = reactive(State)
SCREENS = {
"a": ScreenA(),
"b": ScreenB()
}
BINDINGS = [
Binding("a", "switch_screen('a')", "Screen A",),
Binding("b", "switch_screen('b')", "Screen B",),
Binding("s", "change_state", "Change State"),
]
def action_change_state(self) -> None:
self.state = State(msg = self.state.msg.swapcase(), style=Style(bold=not self.state.style.bold))
self.log(f"{self.state}")
def on_mount(self) -> None:
self.switch_screen("a")
if __name__ == "__main__":
StateApp().run()Unsurprisingly, this does not work: state changes are not reflected in the screens/widgets because there's nothing happening that would trigger the update (no Suppose I make class DisplayWidget(Widget):
state = reactive(State())
def render(self) -> RenderableType:
return Text(self.state.msg, style=self.state.style)But now I've got a copy of the state that's not stored in the source of truth! So I need to make sure that all widgets have the same class StateApp(App):
...
def watch_state(self, new_state: State) -> None:
for widget in self.query(None):
if hasattr(widget, "state"):
widget.state = new_state(I want to pass the whole That works for the active For example, if you run this app: from dataclasses import dataclass
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import reactive
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Static, Footer
@dataclass
class State:
msg: str = "Hello!"
style: Style = Style(color = "red")
def __rich__(self):
return self.msg
class DisplayWidget(Widget):
state = reactive(State())
def render(self) -> RenderableType:
return Text(self.state.msg, style=self.state.style)
class ScreenA(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("A"))
yield DisplayWidget()
yield Footer()
class ScreenB(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("B"))
yield DisplayWidget()
yield Footer()
class StateApp(App):
state = reactive(State)
SCREENS = {
"a": ScreenA(),
"b": ScreenB()
}
BINDINGS = [
Binding("a", "switch_screen('a')", "Screen A",),
Binding("b", "switch_screen('b')", "Screen B",),
Binding("s", "change_state", "Change State"),
]
def action_change_state(self) -> None:
self.state = State(msg = self.state.msg.swapcase(), style=Style(underline=not self.state.style.underline))
self.log(f"{self.state}")
def on_mount(self) -> None:
self.switch_screen("a")
def watch_state(self, new_state: State) -> None:
for widget in self.query(None):
if hasattr(widget, "state"):
self.log(f"Updating state on {widget}")
widget.state = new_state
if __name__ == "__main__":
StateApp().run()and press I tried overriding various methods like So I think my question is: with this kind of multi-screen app where it seems like I need to coordinate changes in "global", or at least cross-screen/widget, state, what patterns should be I using to make sure that:
I suspect this question is arising due to a clash of my instinct to do something more like immediate-mode and the retained-mode model that Textual is encouraging, which makes me think that I'm just missing something in the way I'm thinking about this problem. Sorry for the brain dump, hope it makes some sense. Any advice/examples appreciated! |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 4 replies
-
|
Hey @JoshKarpel, thanks for the comprehensive question. I think I see a couple of main things you're highlighting here; one sort of small but which will inform the approach you take, the other quite big and important. First off:
This is something I've been seeing myself -- that red underline in my Emacs buffer -- and it's a conversation we've been having and will be giving some thought to. I imagine seeing type warnings is something that has been informing your decision as to if a particular approach is a good one or not? For the moment I'm afraid the only suggestion I have for how to approach this will involve a couple of type warnings around Now to the issue of handling and reacting to global state: there is no "the Textual way" solution to this at the moment; "Reactive state abstraction" is a thing that is on the roadmap and it's something we very much want to address. The sort of feedback you're giving here is really helpful to deciding how we go about that. Meanwhile, here's my quick little attempt at making your final example code work. WARNING: It does mean there will be a couple of type warnings from your type checker, presumably depending on how strict it is being (I like to run as strict as possible), and it does use an undocumented function from inside from dataclasses import dataclass
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.reactive import reactive, watch
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Static, Footer
@dataclass
class State:
msg: str = "Hello!"
style: Style = Style(color = "red")
def __rich__(self):
return self.msg
class DisplayWidget(Widget):
def state_changed(self, _):
self.refresh()
def on_mount(self):
watch(self.app, "state", self.state_changed)
def render(self) -> RenderableType:
return Text(self.app.state.msg, style=self.app.state.style)
class ScreenA(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("A"))
yield DisplayWidget()
yield Footer()
class ScreenB(Screen):
def compose(self) -> ComposeResult:
yield Static(Text("B"))
yield DisplayWidget()
yield Footer()
class StateApp(App[None]):
state = reactive(State)
SCREENS = {
"a": ScreenA(),
"b": ScreenB()
}
BINDINGS = [
Binding("a", "switch_screen('a')", "Screen A",),
Binding("b", "switch_screen('b')", "Screen B",),
Binding("s", "change_state", "Change State"),
]
def action_change_state(self) -> None:
self.state = State(msg = self.state.msg.swapcase(), style=Style(underline=not self.state.style.underline))
self.log(f"{self.state}")
def on_mount(self) -> None:
self.switch_screen("a")
if __name__ == "__main__":
StateApp().run()Long story short: I've used the While not the most ideal approach, and likely not the final recommended approach for Textual apps, I think this might be a clean way to do things for now: wire a widget classes up to the parts of the app's global state that they care about, and have them react when things change. Also, speaking to @willmcgugan about Does that help? |
Beta Was this translation helpful? Give feedback.
-
|
That is very helpful! re: type-checking, yep, I'm also a "as strict as possible" kind of person, but I'm fine with throwing some ignores on things if need be. I do try to let it guide my approach to things so that I can avoid ignores as much as possible. Given that users will definitely be subclassing |
Beta Was this translation helpful? Give feedback.
Hey @JoshKarpel, thanks for the comprehensive question. I think I see a couple of main things you're highlighting here; one sort of small but which will inform the approach you take, the other quite big and important.
First off:
This is something I've been seeing myself -- that red underline in my Emacs buffer -- and it's a conversation we've been having and will be giving some thought to. I imagine seeing type warnings is something that has been informing your decision as to if a parti…