diff --git a/docs/examples/guide/screens/screen01.py b/docs/examples/guide/screens/screen01.py index 63a24fd4e..7b83cedee 100644 --- a/docs/examples/guide/screens/screen01.py +++ b/docs/examples/guide/screens/screen01.py @@ -1,4 +1,5 @@ -from textual.app import App, Screen, ComposeResult +from textual.app import App, ComposeResult +from textual.screen import Screen from textual.widgets import Static diff --git a/docs/examples/guide/screens/screen02.py b/docs/examples/guide/screens/screen02.py index 0d5d01e46..f422a410e 100644 --- a/docs/examples/guide/screens/screen02.py +++ b/docs/examples/guide/screens/screen02.py @@ -1,4 +1,5 @@ -from textual.app import App, Screen, ComposeResult +from textual.app import App, ComposeResult +from textual.screen import Screen from textual.widgets import Static diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 7bca457f8..bfac0d098 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -298,7 +298,7 @@ For example, the following will draw a red outline around all widgets: ### Pseudo classes -Pseudo classes can be used to match widgets in a particular state. Psuedo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector. +Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector. ```sass Button:hover { diff --git a/docs/guide/screens.md b/docs/guide/screens.md index 94c61794e..aeb0381ad 100644 --- a/docs/guide/screens.md +++ b/docs/guide/screens.md @@ -10,7 +10,7 @@ Textual requires that there be at least one screen object and will create one im !!! tip - Try printing `widget.parent` to see what object your widget is connected to. + Try printing `widget.parent` to see what object your widget is connected to.
--8<-- "docs/images/dom1.excalidraw.svg" @@ -24,13 +24,13 @@ Let's look at a simple example of writing a screen class to simulate Window's [b === "screen01.py" - ```python title="screen01.py" hl_lines="17-23 28" + ```python title="screen01.py" hl_lines="18-24 29" --8<-- "docs/examples/guide/screens/screen01.py" ``` === "screen01.css" - ```sass title="screen01.css" + ```sass title="screen01.css" --8<-- "docs/examples/guide/screens/screen01.css" ``` @@ -53,13 +53,13 @@ You can also _install_ new named screens dynamically with the [install_screen][t === "screen02.py" - ```python title="screen02.py" hl_lines="30-31" + ```python title="screen02.py" hl_lines="31-32" --8<-- "docs/examples/guide/screens/screen02.py" ``` === "screen02.css" - ```sass title="screen02.css" + ```sass title="screen02.css" --8<-- "docs/examples/guide/screens/screen02.css" ``` @@ -96,7 +96,7 @@ You can also push screens with the `"app.push_screen"` action, which requires th ### Pop screen -The [pop_screen][textual.app.App.pop_screen] method removes the top-most screen from the stack, and makes the new top screen active. +The [pop_screen][textual.app.App.pop_screen] method removes the top-most screen from the stack, and makes the new top screen active. !!! note @@ -115,7 +115,7 @@ You can also pop screens with the `"app.pop_screen"` action. ### Switch screen -The [switch_screen][textual.app.App.switch_screen] method replaces the top of the stack with a new screen. +The [switch_screen][textual.app.App.switch_screen] method replaces the top of the stack with a new screen.
--8<-- "docs/images/screens/switch_screen.excalidraw.svg" @@ -139,7 +139,7 @@ Screens can be used to implement modal dialogs. The following example pushes a s === "modal01.css" - ```sass title="modal01.css" + ```sass title="modal01.css" --8<-- "docs/examples/guide/screens/modal01.css" ``` @@ -147,5 +147,5 @@ Screens can be used to implement modal dialogs. The following example pushes a s ```{.textual path="docs/examples/guide/screens/modal01.py" press="q,_"} ``` - + Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`. This makes the quit screen active. if you click cancel, the quit screen calls `pop_screen` to return the default screen. This also removes and deletes the `QuitScreen` object. diff --git a/docs/guide/styles.md b/docs/guide/styles.md index 99457a078..ac7a32e79 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -237,7 +237,7 @@ You can also set padding to a tuple of *four* values which applies padding to ea ### Border -The [border](../styles/border.md) style draws a border around a widget. To add a border set `syles.border` to a tuple of two values. The first value is the border type, which should be a string. The second value is the border color which will accept any value that works with [color](../styles/color.md) and [background](../styles/background.md). +The [border](../styles/border.md) style draws a border around a widget. To add a border set `styles.border` to a tuple of two values. The first value is the border type, which should be a string. The second value is the border color which will accept any value that works with [color](../styles/color.md) and [background](../styles/background.md). The following example adds a border around a widget: diff --git a/docs/styles/background.md b/docs/styles/background.md index 1cf5283c7..6c41885c7 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -35,7 +35,7 @@ This example creates three widgets and applies a different background to each. /* Blue background */ background: blue; -/* 20% red backround */ +/* 20% red background */ background: red 20%; /* RGB color */ diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md index f850281a1..7b49ec4f1 100644 --- a/docs/styles/overflow.md +++ b/docs/styles/overflow.md @@ -50,7 +50,7 @@ The right side has `overflow-y: hidden` which will prevent a scrollbar from bein ## CSS ```sass -/* Automatic scrollbars on both axies (the default) */ +/* Automatic scrollbars on both axes (the default) */ overflow: auto auto; /* Hide the vertical scrollbar */ diff --git a/docs/widgets/footer.md b/docs/widgets/footer.md index 01717b225..ee1d4eeb7 100644 --- a/docs/widgets/footer.md +++ b/docs/widgets/footer.md @@ -9,7 +9,7 @@ available keybindings for the currently focused widget. ## Example The example below shows an app with a single keybinding that contains only a `Footer` -widget. Notice how the `Footer` automatically displays the keybind. +widget. Notice how the `Footer` automatically displays the keybinding. === "Output" diff --git a/examples/dictionary.py b/examples/dictionary.py index 80936ac01..56c986371 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from typing import Any try: import httpx diff --git a/sandbox/darren/focus_keybinds.css b/sandbox/darren/focus_keybinds.css new file mode 100644 index 000000000..dea2ba7de --- /dev/null +++ b/sandbox/darren/focus_keybinds.css @@ -0,0 +1,52 @@ +*:focus { + tint: red 20%; +} + +#info { + background: $primary; + dock: top; + height: 3; + padding: 1; +} + +#body { + dock: top; +} + +#left_list { + width: 50%; +} + +#right_list { + width: 50%; +} + +#footer { + height: 1; + background: $secondary; + padding: 0 1; + dock: bottom; +} + +.list:focus-within { + background: $panel-lighten-1; + outline-top: $accent-lighten-1; + outline-bottom: $accent-lighten-1; +} + +.list { + background: $surface; + border-top: hkey $surface-darken-1; +} + +.list-item { + background: $surface; + height: auto; + border: $surface-darken-1 tall; + padding: 0 1; +} + +.list-item:focus { + background: $surface-darken-1; + outline: $accent tall; +} diff --git a/sandbox/darren/focus_keybinds.py b/sandbox/darren/focus_keybinds.py new file mode 100644 index 000000000..041401a26 --- /dev/null +++ b/sandbox/darren/focus_keybinds.py @@ -0,0 +1,60 @@ +from textual import containers as layout +from textual.app import App, ComposeResult +from textual.widgets import Static, Input + + +class Label(Static, can_focus=True): + pass + + +class FocusKeybindsApp(App): + dark = True + + def on_load(self) -> None: + self.bind("1", "focus('widget1')") + self.bind("2", "focus('widget2')") + self.bind("3", "focus('widget3')") + self.bind("4", "focus('widget4')") + self.bind("q", "focus('widgetq')") + self.bind("w", "focus('widgetw')") + self.bind("e", "focus('widgete')") + self.bind("r", "focus('widgetr')") + + def compose(self) -> ComposeResult: + yield Static( + "Use keybinds to shift focus between the widgets in the lists below", + id="info", + ) + yield layout.Horizontal( + layout.Vertical( + Label("Press 1 to focus", id="widget1", classes="list-item"), + Label("Press 2 to focus", id="widget2", classes="list-item"), + Input(placeholder="Enter some text..."), + Label("Press 3 to focus", id="widget3", classes="list-item"), + Label("Press 4 to focus", id="widget4", classes="list-item"), + classes="list", + id="left_list", + ), + layout.Vertical( + Label("Press Q to focus", id="widgetq", classes="list-item"), + Label("Press W to focus", id="widgetw", classes="list-item"), + Label("Press E to focus", id="widgete", classes="list-item"), + Label("Press R to focus", id="widgetr", classes="list-item"), + classes="list", + id="right_list", + ), + ) + yield Static("No widget focused", id="footer") + + def on_descendant_focus(self): + self.get_child("footer").update( + f"Focused: {self.focused.id}" or "No widget focused" + ) + + def key_p(self): + print(self.app.focused.parent) + print(self.app.focused) + + +app = FocusKeybindsApp(css_path="focus_keybinds.css", watch_css=True) +app.run() diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 51157227c..d4e53d178 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -7,8 +7,14 @@ from textual.widgets import Static, Footer, Header class JustABox(App): BINDINGS = [ - Binding(key="t", action="text_fade_out", description="text-opacity fade out"), - Binding(key="o,f,w", action="widget_fade_out", description="opacity fade out"), + Binding( + key="ctrl+t", action="text_fade_out", description="text-opacity fade out" + ), + Binding( + key="o,f,w", + action="widget_fade_out", + description="opacity fade out", + ), ] def compose(self) -> ComposeResult: @@ -28,6 +34,9 @@ class JustABox(App): print(self.screen.styles.get_rules()) print(self.screen.styles.css) + def key_plus(self): + print("plus!") + app = JustABox(watch_css=True, css_path="../darren/just_a_box.css") diff --git a/sandbox/darren/screens_focus.css b/sandbox/darren/screens_focus.css new file mode 100644 index 000000000..8affd01fa --- /dev/null +++ b/sandbox/darren/screens_focus.css @@ -0,0 +1,9 @@ +Focusable { + padding: 1 2; + background: $panel; + margin-bottom: 1; +} + +Focusable:focus { + outline: solid dodgerblue; +} diff --git a/sandbox/darren/screens_focus.py b/sandbox/darren/screens_focus.py new file mode 100644 index 000000000..ba67bcef8 --- /dev/null +++ b/sandbox/darren/screens_focus.py @@ -0,0 +1,47 @@ +from textual.app import App, ComposeResult, ScreenStackError +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import Static, Footer, Input + + +class Focusable(Static, can_focus=True): + pass + + +class CustomScreen(Screen): + def compose(self) -> ComposeResult: + yield Focusable(f"Screen {id(self)} - two") + yield Focusable(f"Screen {id(self)} - three") + yield Focusable(f"Screen {id(self)} - four") + yield Input(placeholder="Text input") + yield Footer() + + +class ScreensFocusApp(App): + BINDINGS = [ + Binding("plus", "push_new_screen", "Push"), + Binding("minus", "pop_top_screen", "Pop"), + ] + + def compose(self) -> ComposeResult: + yield Focusable("App - one") + yield Input(placeholder="Text input") + yield Input(placeholder="Text input") + yield Focusable("App - two") + yield Focusable("App - three") + yield Focusable("App - four") + yield Footer() + + def action_push_new_screen(self): + self.push_screen(CustomScreen()) + + def action_pop_top_screen(self): + try: + self.pop_screen() + except ScreenStackError: + pass + + +app = ScreensFocusApp(css_path="screens_focus.css") +if __name__ == "__main__": + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index c4a7f058e..643ffe2d3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -150,8 +150,6 @@ class App(Generic[ReturnType], DOMNode): _BASE_PATH: str | None = None CSS_PATH: CSSPathType = None - focused: Reactive[Widget | None] = Reactive(None) - def __init__( self, driver_class: Type[Driver] | None = None, @@ -326,36 +324,15 @@ class App(Generic[ReturnType], DOMNode): self._close_messages_no_wait() @property - def focus_chain(self) -> list[Widget]: - """Get widgets that may receive focus, in focus order. - - Returns: - list[Widget]: List of Widgets in focus order. - - """ - widgets: list[Widget] = [] - add_widget = widgets.append - root = self.screen - stack: list[Iterator[Widget]] = [iter(root.focusable_children)] - pop = stack.pop - push = stack.append - - while stack: - node = next(stack[-1], None) - if node is None: - pop() - else: - if node.is_container and node.can_focus_children: - push(iter(node.focusable_children)) - else: - if node.can_focus: - add_widget(node) - - return widgets + def focused(self) -> Widget | None: + """Get the widget that is focused on the currently active screen.""" + return self.screen.focused @property def bindings(self) -> Bindings: - """Get current bindings.""" + """Get current bindings. If no widget is focused, then the app-level bindings + are returned. If a widget is focused, then any bindings present between that widget + and the App in the DOM are merged and returned.""" if self.focused is None: return self._bindings else: @@ -367,63 +344,6 @@ class App(Generic[ReturnType], DOMNode): """Set this app to be the currently active app.""" active_app.set(self) - 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. - - Returns: - Widget | None: Newly focused widget, or None for no focus. - """ - focusable_widgets = self.focus_chain - - if not focusable_widgets: - # Nothing focusable, so nothing to do - return self.focused - if self.focused is None: - # Nothing currently focused, so focus the first one - self.set_focus(focusable_widgets[0]) - else: - try: - # Find the index of the currently focused widget - current_index = focusable_widgets.index(self.focused) - except ValueError: - # Focused widget was removed in the interim, start again - self.set_focus(focusable_widgets[0]) - else: - # Only move the focus if we are currently showing the focus - if direction: - current_index = (current_index + direction) % len(focusable_widgets) - self.set_focus(focusable_widgets[current_index]) - - return self.focused - - def show_focus(self) -> Widget | None: - """Highlight the currently focused widget. - - Returns: - Widget | None: Focused widget, or None for no focus. - """ - return self._move_focus(0) - - def focus_next(self) -> Widget | None: - """Focus the next widget. - - Returns: - Widget | None: Newly focused widget, or None for no focus. - """ - return self._move_focus(1) - - def focus_previous(self) -> Widget | None: - """Focus the previous widget. - - Returns: - Widget | None: Newly focused widget, or None for no focus. - """ - return self._move_focus(-1) - def compose(self) -> ComposeResult: """Yield child widgets for a container.""" return @@ -872,7 +792,7 @@ class App(Generic[ReturnType], DOMNode): self.log.system(f"{self.screen} is current (PUSHED)") def switch_screen(self, screen: Screen | str) -> None: - """Switch to a another screen by replacing the top of the screen stack with a new screen. + """Switch to another screen by replacing the top of the screen stack with a new screen. Args: screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). @@ -965,43 +885,7 @@ class App(Generic[ReturnType], DOMNode): widget (Widget): Widget to focus. scroll_visible (bool, optional): Scroll widget in to view. """ - if widget is self.focused: - # Widget is already focused - return - - if widget is None: - # 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)) - self.focused.emit_no_wait(events.DescendantBlur(self)) - 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)) - self.focused.emit_no_wait(events.DescendantBlur(self)) - # Change focus - self.focused = widget - # Send focus event - if scroll_visible: - self.screen.scroll_to_widget(widget) - widget.post_message_no_wait(events.Focus(self)) - widget.emit_no_wait(events.DescendantFocus(self)) - - def _reset_focus(self, widget: Widget) -> None: - """Reset the focus when a widget is removed - - Args: - widget (Widget): A widget that is removed. - """ - if self.focused is widget: - for sibling in widget.siblings: - if sibling.can_focus: - sibling.focus() - break - else: - self.focused = None + self.screen.set_focus(widget, scroll_visible) async def _set_mouse_over(self, widget: Widget | None) -> None: """Called when the mouse is over another widget. @@ -1269,7 +1153,7 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): A Widget to unregister """ - self._reset_focus(widget) + widget.screen._reset_focus(widget) if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) @@ -1391,14 +1275,14 @@ class App(Generic[ReturnType], DOMNode): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) if isinstance(event, events.Key) and self.focused is not None: - # Key events are sent direct to focused widget + # Key events are sent direct to focused widget of the currently active screen if self.bindings.allow_forward(event.key): await self.focused._forward_event(event) else: # Key has allow_forward=False which disallows forward to focused widget await super().on_event(event) else: - # Forward the event to the view + # Forward the event to the currently active Screen await self.screen._forward_event(event) elif isinstance(event, events.Paste): if self.focused is not None: @@ -1499,9 +1383,9 @@ class App(Generic[ReturnType], DOMNode): async def _on_key(self, event: events.Key) -> None: if event.key == "tab": - self.focus_next() + self.screen.focus_next() elif event.key == "shift+tab": - self.focus_previous() + self.screen.focus_previous() else: if not (await self.press(event.key)): await self.dispatch_key(event) diff --git a/src/textual/binding.py b/src/textual/binding.py index 498201c76..1f52a5a10 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -47,14 +47,26 @@ class Bindings: for binding in bindings: if isinstance(binding, Binding): binding_keys = binding.key.split(",") + + # If there's a key display, split it and associate it with the keys + key_displays = ( + binding.key_display.split(",") if binding.key_display else [] + ) + if len(binding_keys) == len(key_displays): + keys_and_displays = zip(binding_keys, key_displays) + else: + keys_and_displays = [ + (key, binding.key_display) for key in binding_keys + ] + if len(binding_keys) > 1: - for key in binding_keys: + for key, display in keys_and_displays: new_binding = Binding( key=key, action=binding.action, description=binding.description, show=binding.show, - key_display=binding.key_display, + key_display=display, allow_forward=binding.allow_forward, ) yield new_binding diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index f7372d65b..6a2bffe52 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -283,9 +283,10 @@ class BoxProperty: StyleSyntaxError: If the string supplied for the color has invalid syntax. """ _rich_traceback_omit = True + if border is None: if obj.clear_rule(self.name): - obj.refresh() + obj.refresh(layout=True) else: _type, color = border new_value = border @@ -301,8 +302,13 @@ class BoxProperty: ) elif isinstance(color, Color): new_value = (_type, color) + current_value: tuple[str, Color] = cast( + "tuple[str, Color]", obj.get_rule(self.name) + ) + has_edge = current_value and current_value[0] + new_edge = bool(_type) if obj.set_rule(self.name, new_value): - obj.refresh() + obj.refresh(layout=has_edge != new_edge) @rich.repr.auto diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index afdc2b054..0045f8a67 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -424,7 +424,6 @@ class Stylesheet: # Calculate replacement rules (defaults + new rules) new_styles = Styles(node, rules) - if new_styles == base_styles: # Nothing to change, return early return diff --git a/src/textual/devtools/redirect_output.py b/src/textual/devtools/redirect_output.py index 46a830ab0..bed976274 100644 --- a/src/textual/devtools/redirect_output.py +++ b/src/textual/devtools/redirect_output.py @@ -7,7 +7,7 @@ from .client import DevtoolsLog from .._log import LogGroup, LogVerbosity if TYPE_CHECKING: - from .devtools.client import DevtoolsClient + from .client import DevtoolsClient class StdoutRedirector: diff --git a/src/textual/events.py b/src/textual/events.py index 298694cf6..b8c56a24c 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -373,7 +373,7 @@ class MouseEvent(InputEvent, bubble=True): Returns: Offset | None: An offset where the origin is at the top left of the content area. """ - if self.offset not in widget.content_region: + if self.screen_offset not in widget.content_region: return None return self.offset - widget.gutter.top_left diff --git a/src/textual/keys.py b/src/textual/keys.py index fbc4ca292..7f9fed165 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -202,6 +202,9 @@ KEY_NAME_REPLACEMENTS = { "solidus": "slash", "reverse_solidus": "backslash", "commercial_at": "at", + "hyphen_minus": "minus", + "plus_sign": "plus", + "low_line": "underscore", } # Some keys have aliases. For example, if you press `ctrl+m` on your keyboard, diff --git a/src/textual/screen.py b/src/textual/screen.py index dd8c78cfd..c3a06d6c0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import Iterable +from typing import Iterable, Iterator import rich.repr from rich.console import RenderableType @@ -39,6 +39,7 @@ class Screen(Widget): """ dark: Reactive[bool] = Reactive(False) + focused: Reactive[Widget | None] = Reactive(None) def __init__( self, @@ -142,9 +143,132 @@ class Screen(Widget): Returns: Region: Region relative to screen. + + Raises: + NoWidget: If the widget could not be found in this screen. """ return self._compositor.find_widget(widget) + @property + def focus_chain(self) -> list[Widget]: + """Get widgets that may receive focus, in focus order. + + Returns: + list[Widget]: List of Widgets in focus order. + """ + widgets: list[Widget] = [] + add_widget = widgets.append + stack: list[Iterator[Widget]] = [iter(self.focusable_children)] + pop = stack.pop + push = stack.append + + while stack: + node = next(stack[-1], None) + if node is None: + pop() + else: + if node.is_container and node.can_focus_children: + push(iter(node.focusable_children)) + else: + if node.can_focus: + add_widget(node) + + return widgets + + 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 keep the current focus. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + focusable_widgets = self.focus_chain + + if not focusable_widgets: + # Nothing focusable, so nothing to do + return self.focused + if self.focused is None: + # Nothing currently focused, so focus the first one + self.set_focus(focusable_widgets[0]) + else: + try: + # Find the index of the currently focused widget + current_index = focusable_widgets.index(self.focused) + except ValueError: + # Focused widget was removed in the interim, start again + self.set_focus(focusable_widgets[0]) + else: + # Only move the focus if we are currently showing the focus + if direction: + current_index = (current_index + direction) % len(focusable_widgets) + self.set_focus(focusable_widgets[current_index]) + + return self.focused + + def focus_next(self) -> Widget | None: + """Focus the next widget. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + return self._move_focus(1) + + def focus_previous(self) -> Widget | None: + """Focus the previous widget. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + return self._move_focus(-1) + + def _reset_focus(self, widget: Widget) -> None: + """Reset the focus when a widget is removed + + Args: + widget (Widget): A widget that is removed. + """ + if self.focused is widget: + for sibling in widget.siblings: + if sibling.can_focus: + sibling.focus() + break + else: + self.focused = None + + def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: + """Focus (or un-focus) a widget. A focused widget will receive key events first. + + Args: + widget (Widget | None): Widget to focus, or None to un-focus. + scroll_visible (bool, optional): Scroll widget in to view. + """ + if widget is self.focused: + # Widget is already focused + return + + if widget is None: + # 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)) + self.focused.emit_no_wait(events.DescendantBlur(self)) + 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)) + self.focused.emit_no_wait(events.DescendantBlur(self)) + # Change focus + self.focused = widget + # Send focus event + if scroll_visible: + self.screen.scroll_to_widget(widget) + widget.post_message_no_wait(events.Focus(self)) + widget.emit_no_wait(events.DescendantFocus(self)) + async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() @@ -318,11 +442,11 @@ class Screen(Widget): else: widget, region = self.get_widget_at(event.x, event.y) except errors.NoWidget: - self.app.set_focus(None) + self.set_focus(None) else: if isinstance(event, events.MouseUp) and widget.can_focus: - if self.app.focused is not widget: - self.app.set_focus(widget) + if self.focused is not widget: + self.set_focus(widget) event.stop() return event.style = self.get_style_at(event.screen_x, event.screen_y) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0847f35cb..2052f9f6e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -52,7 +52,6 @@ if TYPE_CHECKING: ScrollUp, ) - _JUSTIFY_MAP: dict[str, JustifyMethod] = { "start": "left", "end": "right", @@ -1813,7 +1812,7 @@ class Widget(DOMNode): scroll_visible (bool, optional): Scroll parent to make this widget visible. Defaults to True. """ - self.app.set_focus(self, scroll_visible=scroll_visible) + self.screen.set_focus(self, scroll_visible=scroll_visible) def capture_mouse(self, capture: bool = True) -> None: """Capture (or release) the mouse. @@ -1889,22 +1888,16 @@ class Widget(DOMNode): self.refresh() def _on_descendant_focus(self, event: events.DescendantFocus) -> None: - self.descendant_has_focus = True - if "focus-within" in self.pseudo_classes: - sender = event.sender - for child in self.walk_children(False): - child.refresh() - if child is sender: - break + if not self.descendant_has_focus: + self.descendant_has_focus = True def _on_descendant_blur(self, event: events.DescendantBlur) -> None: - self.descendant_has_focus = False + if self.descendant_has_focus: + self.descendant_has_focus = False + + def watch_descendant_has_focus(self, value: bool) -> None: if "focus-within" in self.pseudo_classes: - sender = event.sender - for child in self.walk_children(False): - child.refresh() - if child is sender: - break + self.app._require_stylesheet_update.add(self) def _on_mouse_scroll_down(self, event) -> None: if self.allow_vertical_scroll: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index e012b96fa..cd9808713 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,5 +1,7 @@ from __future__ import annotations +from collections import defaultdict + from rich.console import RenderableType from rich.text import Text @@ -55,7 +57,7 @@ class Footer(Widget): self._key_text = None def on_mount(self) -> None: - watch(self.app, "focused", self._focus_changed) + watch(self.screen, "focused", self._focus_changed) def _focus_changed(self, focused: Widget | None) -> None: self._key_text = None @@ -85,12 +87,22 @@ class Footer(Widget): highlight_style = self.get_component_rich_style("footer--highlight") highlight_key_style = self.get_component_rich_style("footer--highlight-key") key_style = self.get_component_rich_style("footer--key") - for binding in self.app.bindings.shown_keys: - key_display = ( + + bindings = self.app.bindings.shown_keys + + action_to_bindings = defaultdict(list) + for binding in bindings: + action_to_bindings[binding.action].append(binding) + + for action, bindings in action_to_bindings.items(): + key_displays = [ binding.key.upper() if binding.key_display is None else binding.key_display - ) + for binding in bindings + ] + key_display = "ยท".join(key_displays) + binding = bindings[0] hovered = self.highlight_key == binding.key key_text = Text.assemble( (f" {key_display} ", highlight_key_style if hovered else key_style), diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 48381586a..2b814d6a8 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -243,7 +243,7 @@ class Tabs(Widget): return if event.key == Keys.Escape: - self.app.set_focus(None) + self.screen.set_focus(None) elif event.key == Keys.Right: self.activate_next_tab() elif event.key == Keys.Left: diff --git a/tests/test_focus.py b/tests/test_focus.py index 78542aed9..811817b92 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -12,13 +12,14 @@ class NonFocusable(Widget, can_focus=False, can_focus_children=False): async def test_focus_chain(): - app = App() app._set_active() app.push_screen(Screen()) + screen = app.screen + # Check empty focus chain - assert not app.focus_chain + assert not screen.focus_chain app.screen._add_children( Focusable(id="foo"), @@ -28,16 +29,18 @@ async def test_focus_chain(): Focusable(id="baz"), ) - focused = [widget.id for widget in app.focus_chain] + focused = [widget.id for widget in screen.focus_chain] assert focused == ["foo", "Paul", "baz"] async def test_focus_next_and_previous(): - app = App() app._set_active() app.push_screen(Screen()) - app.screen._add_children( + + screen = app.screen + + screen._add_children( Focusable(id="foo"), NonFocusable(id="bar"), Focusable(Focusable(id="Paul"), id="container1"), @@ -45,9 +48,9 @@ async def test_focus_next_and_previous(): Focusable(id="baz"), ) - assert app.focus_next().id == "foo" - assert app.focus_next().id == "Paul" - assert app.focus_next().id == "baz" + assert screen.focus_next().id == "foo" + assert screen.focus_next().id == "Paul" + assert screen.focus_next().id == "baz" - assert app.focus_previous().id == "Paul" - assert app.focus_previous().id == "foo" + assert screen.focus_previous().id == "Paul" + assert screen.focus_previous().id == "foo"