diff --git a/examples/code_browser.py b/examples/code_browser.py index 86b54665a..90303a5d4 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -30,7 +30,7 @@ class CodeBrowser(App): def watch_show_tree(self, show_tree: bool) -> None: """Called when show_tree is modified.""" - self.set_class(show_tree, "-show-tree") + self.set_class(show_tree, "-show-tree") def compose(self) -> ComposeResult: """Compose our UI.""" diff --git a/examples/dictionary.css b/examples/dictionary.css index 8850249c4..6bca8b9f5 100644 --- a/examples/dictionary.css +++ b/examples/dictionary.css @@ -15,7 +15,7 @@ Input { #results-container { background: $background 50%; - margin: 0; + margin: 0 0 1 0; height: 100%; overflow: hidden auto; border: tall $background; diff --git a/src/textual/app.py b/src/textual/app.py index 890957c9f..95c5ffae5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -37,7 +37,7 @@ from .css.stylesheet import Stylesheet from .design import ColorSystem from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog from .devtools.redirect_output import StdoutRedirector -from .dom import DOMNode, NoScreen +from .dom import DOMNode from .driver import Driver from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features @@ -1014,15 +1014,18 @@ class App(Generic[ReturnType], DOMNode): process_messages = super()._process_messages async def run_process_messages(): - Reactive.initialize_object(self) + compose_event = events.Compose(sender=self) await self._dispatch_message(compose_event) mount_event = events.Mount(sender=self) await self._dispatch_message(mount_event) + Reactive._initialize_object(self) + self.title = self._title self.stylesheet.update(self) self.refresh() + await self.animator.start() await self._ready() if ready_callback is not None: @@ -1166,6 +1169,14 @@ class App(Generic[ReturnType], DOMNode): widget._start_messages() def is_mounted(self, widget: Widget) -> bool: + """Check if a widget is mounted. + + Args: + widget (Widget): A widget. + + Returns: + bool: True of the widget is mounted. + """ return widget in self._registry async def _close_all(self) -> None: @@ -1195,7 +1206,7 @@ class App(Generic[ReturnType], DOMNode): stylesheet.set_variables(self.get_css_variables()) stylesheet.reparse() stylesheet.update(self.app, animate=animate) - self.screen._refresh_layout(self.size, full=True) + # self.screen._refresh_layout(self.size, full=True) def _display(self, screen: Screen, renderable: RenderableType | None) -> None: """Display a renderable within a sync. @@ -1387,7 +1398,6 @@ class App(Generic[ReturnType], DOMNode): async def _on_resize(self, event: events.Resize) -> None: event.stop() - self.screen._screen_resized(event.size) await self.screen.post_message(event) async def _on_remove(self, event: events.Remove) -> None: diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index cb1d19619..20e2fa250 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -72,6 +72,9 @@ class ColorsApp(App): yield Footer() def on_mount(self) -> None: + self.call_later(self.update_view) + + def update_view(self) -> None: content = self.query_one("Content", Content) content.mount(ColorsView()) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 0045f8a67..611539358 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -249,7 +249,7 @@ class Stylesheet: css = css_file.read() path = os.path.abspath(filename) except Exception as error: - raise StylesheetError(f"unable to read {filename!r}; {error}") + raise StylesheetError(f"unable to read CSS file {filename!r}") from None self.source[str(path)] = CssSource(css, False, 0) self._require_parse = True diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d9eb8abc4..8d2f4c421 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -326,6 +326,9 @@ class MessagePump(metaclass=MessagePumpMeta): except MessagePumpClosed: break + is_mount = isinstance(message, events.Mount) + if is_mount: + Reactive._initialize_object(self) try: await self._dispatch_message(message) except CancelledError: @@ -335,10 +338,13 @@ class MessagePump(metaclass=MessagePumpMeta): self.app._handle_exception(error) break finally: - if isinstance(message, events.Mount): + if is_mount: self._mounted_event.set() + self._message_queue.task_done() current_time = time() + + # Insert idle events if self._message_queue.empty() or ( self._max_idle is not None and current_time - self._last_idle > self._max_idle diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 8aa7a06f1..c237955b4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -20,6 +20,12 @@ if TYPE_CHECKING: ReactiveType = TypeVar("ReactiveType") +class _NotSet: + pass + + +_NOT_SET = _NotSet() + T = TypeVar("T") @@ -83,26 +89,31 @@ class Reactive(Generic[ReactiveType]): return cls(default, layout=False, repaint=False, init=True) @classmethod - def initialize_object(cls, obj: object) -> None: - """Call any watchers / computes for the first time. + def _initialize_object(cls, obj: object) -> None: + """Set defaults and call any watchers / computes for the first time. Args: obj (Reactable): An object with Reactive descriptors """ if not hasattr(obj, "__reactive_initialized"): startswith = str.startswith - for key in obj.__class__.__dict__.keys(): - if startswith(key, "_init_"): - name = key[6:] + for key in obj.__class__.__dict__: + if startswith(key, "_default_"): + name = key[9:] + # Check defaults if not hasattr(obj, name): + # Attribute has no value yet default = getattr(obj, key) default_value = default() if callable(default) else default + # Set the default vale (calls `__set__`) setattr(obj, name, default_value) setattr(obj, "__reactive_initialized", True) def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: + # Check for compute method if hasattr(owner, f"compute_{name}"): + # Compute methods are stored in a list called `__computes` try: computes = getattr(owner, "__computes") except AttributeError: @@ -110,31 +121,46 @@ class Reactive(Generic[ReactiveType]): setattr(owner, "__computes", computes) computes.append(name) + # The name of the attribute self.name = name + # The internal name where the attribute's value is stored self.internal_name = f"_reactive_{name}" default = self._default - setattr(owner, f"_init_{name}", default) + setattr(owner, f"_default_{name}", default) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: - if not hasattr(obj, self.internal_name): - init_name = f"_init_{self.name}" + value: _NotSet | ReactiveType = getattr(obj, self.internal_name, _NOT_SET) + if isinstance(value, _NotSet): + # No value present, we need to set the default + init_name = f"_default_{self.name}" default = getattr(obj, init_name) default_value = default() if callable(default) else default + # Set and return the value setattr(obj, self.internal_name, default_value) + if self._init: + self._check_watchers(obj, self.name, default_value, first_set=True) return default_value - return getattr(obj, self.internal_name) + return value def __set__(self, obj: Reactable, value: ReactiveType) -> None: name = self.name - current_value = getattr(obj, self.name) + current_value = getattr(obj, name) + # Check for validate function validate_function = getattr(obj, f"validate_{name}", None) + # Check if this is the first time setting the value first_set = getattr(obj, f"__first_set_{self.internal_name}", True) + # Call validate, but not on first set. if callable(validate_function) and not first_set: value = validate_function(value) + # If the value has changed, or this is the first time setting the value if current_value != value or first_set: + # Set the first set flag to False setattr(obj, f"__first_set_{self.internal_name}", False) + # Store the internal value setattr(obj, self.internal_name, value) + # Check all watchers self._check_watchers(obj, name, current_value, first_set=first_set) + # Refresh according to descriptor flags if self._layout or self._repaint: obj.refresh(repaint=self._repaint, layout=self._layout) @@ -142,50 +168,77 @@ class Reactive(Generic[ReactiveType]): def _check_watchers( cls, obj: Reactable, name: str, old_value: Any, first_set: bool = False ) -> None: + """Check watchers, and call watch methods / computes + Args: + obj (Reactable): The reactable object. + name (str): Attribute name. + old_value (Any): The old (previous) value of the attribute. + first_set (bool, optional): True if this is the first time setting the value. Defaults to False. + """ + # Get the current value. internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) async def update_watcher( obj: Reactable, watch_function: Callable, old_value: Any, value: Any ) -> None: + """Call watch function, and run compute. + + Args: + obj (Reactable): Reactable object. + watch_function (Callable): Watch method. + old_value (Any): Old value. + value (Any): new value. + """ _rich_traceback_guard = True + # Call watch with one or two parameters if count_parameters(watch_function) == 2: watch_result = watch_function(old_value, value) else: watch_result = watch_function(value) + # Optionally await result if isawaitable(watch_result): await watch_result + # Run computes await Reactive._compute(obj) + # Check for watch method watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): + # Post a callback message, so we can call the watch method in an orderly async manner obj.post_message_no_wait( events.Callback( - obj, + sender=obj, callback=partial( update_watcher, obj, watch_function, old_value, value ), ) ) + # Check for watchers set via `watch` watcher_name = f"__{name}_watchers" watchers = getattr(obj, watcher_name, ()) for watcher in watchers: obj.post_message_no_wait( events.Callback( - obj, + sender=obj, callback=partial(update_watcher, obj, watcher, old_value, value), ) ) - if not first_set: - obj.post_message_no_wait( - events.Callback(obj, callback=partial(Reactive._compute, obj)) - ) + # Run computes + obj.post_message_no_wait( + events.Callback(sender=obj, callback=partial(Reactive._compute, obj)) + ) @classmethod async def _compute(cls, obj: Reactable) -> None: + """Invoke all computes. + + Args: + obj (Reactable): Reactable object. + """ _rich_traceback_guard = True computes = getattr(obj, "__computes", []) for compute in computes: diff --git a/src/textual/screen.py b/src/textual/screen.py index c3a06d6c0..d32b2dda5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -387,12 +387,12 @@ class Screen(Widget): def _on_screen_resume(self) -> None: """Called by the App""" - size = self.app.size self._refresh_layout(size, full=True) async def _on_resize(self, event: events.Resize) -> None: event.stop() + self._screen_resized(event.size) async def _handle_mouse_move(self, event: events.MouseMove) -> None: try: diff --git a/src/textual/widget.py b/src/textual/widget.py index ba314ec2b..e65cb0b4f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -336,7 +336,8 @@ class Widget(DOMNode): """ self.app._register(self, *anon_widgets, **widgets) - self.app.screen.refresh(layout=True) + # self.app.screen.refresh(layout=True) + # self.refresh(layout=True) def compose(self) -> ComposeResult: """Called by Textual to create child widgets. @@ -1820,7 +1821,10 @@ class Widget(DOMNode): visible. Defaults to True. """ - self.screen.set_focus(self, scroll_visible=scroll_visible) + def set_focus(widget: Widget): + widget.screen.set_focus(self, scroll_visible=scroll_visible) + + self.app.call_later(set_focus, self) def reset_focus(self) -> None: """Reset the focus (move it to the next available widget)."""