diff --git a/sandbox/basic.py b/sandbox/basic.py index 0778a9eed..267653581 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -89,7 +89,7 @@ class BasicApp(App): def on_load(self): """Bind keys here.""" - self.bind("tab", "toggle_class('#sidebar', '-active')") + self.bind("s", "toggle_class('#sidebar', '-active')") def on_mount(self): """Build layout here.""" diff --git a/src/textual/app.py b/src/textual/app.py index 0dffb9524..a2bfca31e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -25,6 +25,7 @@ import rich.repr from rich.console import Console, RenderableType from rich.control import Control from rich.measure import Measurement +from rich.protocol import is_renderable from rich.screen import Screen as ScreenRenderable from rich.segment import Segments from rich.traceback import Traceback @@ -96,7 +97,9 @@ ReturnType = TypeVar("ReturnType") class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" - css = "" + css = """ + + """ def __init__( self, @@ -205,7 +208,7 @@ class App(Generic[ReturnType], DOMNode): self.close_messages_no_wait() @property - def _focusable_widgets(self) -> list[Widget]: + def focus_chain(self) -> list[Widget]: """Get widgets that may receive focus""" widgets: list[Widget] = [] add_widget = widgets.append @@ -219,7 +222,7 @@ class App(Generic[ReturnType], DOMNode): if node is None: pop() else: - if node.is_container: + if node.is_container and node.can_focus: push(iter(node.children)) else: if node.can_focus: @@ -227,25 +230,25 @@ class App(Generic[ReturnType], DOMNode): return widgets - def show_focus(self) -> None: - """Highlight the currently focused widget.""" - self.move_focus(0) + def _set_active(self) -> None: + active_app.set(self) - def move_focus(self, direction: int = 0) -> None: + def _move_focus(self, direction: int = 0) -> Widget | None: """Move the focus in the given direction. Args: direction (int, optional): 1 to move forward, -1 to move backward, or 0 to highlight the current focus. """ - if self._focus_timer: + # Cancel the timer that clears the show focus class + # We will be creating a new timer to extend the time until the focus is hidden self._focus_timer.stop_no_wait() - focusable_widgets = self._focusable_widgets + focusable_widgets = self.focus_chain if not focusable_widgets: # Nothing focusable, so nothing to do - return + return self.focused if self.focused is None: # Nothing currently focused, so focus the first one self.set_focus(focusable_widgets[0]) @@ -262,17 +265,21 @@ class App(Generic[ReturnType], DOMNode): current_index = (current_index + direction) % len(focusable_widgets) self.set_focus(focusable_widgets[current_index]) - self._focus_timer = self.set_timer(3, self.hide_focus) + self._focus_timer = self.set_timer(2, self.hide_focus) self.add_class("-show-focus") - self.screen.refresh_layout() + return self.focused - def focus_next(self) -> None: + def show_focus(self) -> Widget | None: + """Highlight the currently focused widget.""" + return self._move_focus(0) + + def focus_next(self) -> Widget | None: """Focus the next widget.""" - self.move_focus(1) + return self._move_focus(1) - def focus_previous(self) -> None: + def focus_previous(self) -> Widget | None: """Focus the previous widget.""" - self.move_focus(-1) + return self._move_focus(-1) def hide_focus(self) -> None: """Hide the focus.""" @@ -483,9 +490,10 @@ class App(Generic[ReturnType], DOMNode): self.register(self.screen, *anon_widgets, **widgets) self.screen.refresh() - async def push_screen(self, screen: Screen) -> Screen: - self._screen_stack.append(screen) - return screen + def push_screen(self, screen: Screen | None = None) -> Screen: + new_screen = Screen() if screen is None else screen + self._screen_stack.append(new_screen) + return new_screen def set_focus(self, widget: Widget | None) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. @@ -493,25 +501,32 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): [description] """ - self.log("set_focus", widget) + self.log("set_focus", widget=widget) if widget == self.focused: - self.log("already focused") # Widget is already focused return if widget is None: - if self.focused is not None: - focused = self.focused - self.focused = None - focused.post_message_no_wait(events.Blur(self)) - elif widget.can_focus: + # No focus, so blur currently focused widget if it exists if self.focused is not None: self.focused.post_message_no_wait(events.Blur(self)) - if widget is not None and self.focused != widget: + self.focused = None + elif widget.can_focus: + if self.focused != widget: + if self.focused is not None: + # Blur currently focused widget + self.focused.post_message_no_wait(events.Blur(self)) + # Change focus self.focused = widget + # Send focus event widget.post_message_no_wait(events.Focus(self)) - async def set_mouse_over(self, widget: Widget | None) -> None: + async def _set_mouse_over(self, widget: Widget | None) -> None: + """Called when the mouse is over a nother widget. + + Args: + widget (Widget | None): Widget under mouse, or None for no widgets. + """ if widget is None: if self.mouse_over is not None: try: @@ -551,6 +566,10 @@ class App(Generic[ReturnType], DOMNode): *renderables (RenderableType, optional): Rich renderables to display on exit. """ + assert all( + is_renderable(renderable) for renderable in renderables + ), "Can only call panic with strings or Rich renderables" + prerendered = [ Segments(self.console.render(renderable, self.console.options)) for renderable in renderables @@ -589,7 +608,7 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.clear() async def process_messages(self) -> None: - active_app.set(self) + self._set_active() log("---") log(f"driver={self.driver_class}") @@ -835,7 +854,7 @@ class App(Generic[ReturnType], DOMNode): if isinstance(event, events.Mount): screen = Screen() self.register(self, screen) - await self.push_screen(screen) + self.push_screen(screen) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: @@ -876,12 +895,18 @@ class App(Generic[ReturnType], DOMNode): action_target = default_namespace or self action_name = target - log("ACTION", action_target, action_name) + log("action", action) await self.dispatch_action(action_target, action_name, params) async def dispatch_action( self, namespace: object, action_name: str, params: Any ) -> None: + log( + "dispatch_action", + namespace=namespace, + action_name=action_name, + params=params, + ) _rich_traceback_guard = True method_name = f"action_{action_name}" method = getattr(namespace, method_name, None) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 15459657e..37367fd7e 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -83,7 +83,7 @@ class Selector: return self._checks[self.type](node) def _check_universal(self, node: DOMNode) -> bool: - return True + return node.has_pseudo_class(*self.pseudo_classes) def _check_type(self, node: DOMNode) -> bool: if node.css_type != self._name_lower: diff --git a/src/textual/dom.py b/src/textual/dom.py index c906ea78b..874ef2a6e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -439,8 +439,11 @@ class DOMNode(MessagePump): """ self._classes.update(class_names) - self.app.stylesheet.update(self.app, animate=True) - self.refresh() + try: + self.app.stylesheet.update(self.app, animate=True) + self.refresh() + except LookupError: + pass def remove_class(self, *class_names: str) -> None: """Remove class names from this Node. @@ -450,8 +453,11 @@ class DOMNode(MessagePump): """ self._classes.difference_update(class_names) - self.app.stylesheet.update(self.app, animate=True) - self.refresh() + try: + self.app.stylesheet.update(self.app, animate=True) + self.refresh() + except LookupError: + pass def toggle_class(self, *class_names: str) -> None: """Toggle class names on this Node. @@ -461,8 +467,11 @@ class DOMNode(MessagePump): """ self._classes.symmetric_difference_update(class_names) - self.app.stylesheet.update(self.app, animate=True) - self.refresh() + try: + self.app.stylesheet.update(self.app, animate=True) + self.refresh() + except LookupError: + pass def has_pseudo_class(self, *class_names: str) -> bool: """Check for pseudo class (such as hover, focus etc)""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index ac217bb85..4da07f2c8 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -240,7 +240,7 @@ class MessagePump: except CancelledError: raise except Exception as error: - self.app.panic(error) + self.app.on_exception(error) break finally: if self._message_queue.empty(): diff --git a/src/textual/screen.py b/src/textual/screen.py index 1074f87f0..75607ab44 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -160,9 +160,9 @@ class Screen(Widget): else: widget, region = self.get_widget_at(event.x, event.y) except errors.NoWidget: - await self.app.set_mouse_over(None) + await self.app._set_mouse_over(None) else: - await self.app.set_mouse_over(widget) + await self.app._set_mouse_over(widget) mouse_event = events.MouseMove( self, event.x - region.x, diff --git a/src/textual/widget.py b/src/textual/widget.py index 28ab5ad67..1630537ac 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -529,7 +529,7 @@ class Widget(DOMNode): Returns: bool: True if this widget is a container. """ - return self.styles.layout is not None + return self.styles.layout is not None or bool(self.children) def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 71e69c76c..46e8039dd 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -36,8 +36,7 @@ class Button(Widget, can_focus=True): } App.-show-focus Button:focus { - tint: $accent 20%; - + tint: $accent 20%; } """ @@ -80,7 +79,5 @@ class Button(Widget, can_focus=True): await self.emit(Button.Pressed(self)) async def on_key(self, event: events.Key) -> None: - self.log("BUTTON KEY", event) if event.key == "enter" and not self.disabled: - self.log("PRESSEd") await self.emit(Button.Pressed(self)) diff --git a/tests/test_focus.py b/tests/test_focus.py new file mode 100644 index 000000000..167738494 --- /dev/null +++ b/tests/test_focus.py @@ -0,0 +1,71 @@ +from textual.app import App +from textual.screen import Screen +from textual.widget import Widget + + +class Focusable(Widget, can_focus=True): + pass + + +class NonFocusable(Widget, can_focus=False): + pass + + +def test_focus_chain(): + + app = App() + app.push_screen(Screen()) + + # Check empty focus chain + assert not app.focus_chain + + app.screen.add_children( + Focusable(id="foo"), + NonFocusable(id="bar"), + Focusable(Focusable(id="Paul"), id="container1"), + NonFocusable(Focusable(id="Jessica"), id="container2"), + Focusable(id="baz"), + ) + + focused = [widget.id for widget in app.focus_chain] + assert focused == ["foo", "Paul", "baz"] + + +def test_show_focus(): + app = App() + app.push_screen(Screen()) + app.screen.add_children( + Focusable(id="foo"), + NonFocusable(id="bar"), + Focusable(Focusable(id="Paul"), id="container1"), + NonFocusable(Focusable(id="Jessica"), id="container2"), + Focusable(id="baz"), + ) + + focused = [widget.id for widget in app.focus_chain] + assert focused == ["foo", "Paul", "baz"] + + assert app.focused is None + assert not app.has_class("-show-focus") + app.show_focus() + assert app.has_class("-show-focus") + + +def test_focus_next_and_previous(): + + app = App() + app.push_screen(Screen()) + app.screen.add_children( + Focusable(id="foo"), + NonFocusable(id="bar"), + Focusable(Focusable(id="Paul"), id="container1"), + NonFocusable(Focusable(id="Jessica"), id="container2"), + Focusable(id="baz"), + ) + + assert app.focus_next().id == "foo" + assert app.focus_next().id == "Paul" + assert app.focus_next().id == "baz" + + assert app.focus_previous().id == "Paul" + assert app.focus_previous().id == "foo"