focus traversal

This commit is contained in:
Will McGugan
2022-05-04 10:54:20 +01:00
parent 375a18c0b1
commit a2da5546bd
9 changed files with 149 additions and 47 deletions

View File

@@ -89,7 +89,7 @@ class BasicApp(App):
def on_load(self): def on_load(self):
"""Bind keys here.""" """Bind keys here."""
self.bind("tab", "toggle_class('#sidebar', '-active')") self.bind("s", "toggle_class('#sidebar', '-active')")
def on_mount(self): def on_mount(self):
"""Build layout here.""" """Build layout here."""

View File

@@ -25,6 +25,7 @@ import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.control import Control from rich.control import Control
from rich.measure import Measurement from rich.measure import Measurement
from rich.protocol import is_renderable
from rich.screen import Screen as ScreenRenderable from rich.screen import Screen as ScreenRenderable
from rich.segment import Segments from rich.segment import Segments
from rich.traceback import Traceback from rich.traceback import Traceback
@@ -96,7 +97,9 @@ ReturnType = TypeVar("ReturnType")
class App(Generic[ReturnType], DOMNode): class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications""" """The base class for Textual Applications"""
css = "" css = """
"""
def __init__( def __init__(
self, self,
@@ -205,7 +208,7 @@ class App(Generic[ReturnType], DOMNode):
self.close_messages_no_wait() self.close_messages_no_wait()
@property @property
def _focusable_widgets(self) -> list[Widget]: def focus_chain(self) -> list[Widget]:
"""Get widgets that may receive focus""" """Get widgets that may receive focus"""
widgets: list[Widget] = [] widgets: list[Widget] = []
add_widget = widgets.append add_widget = widgets.append
@@ -219,7 +222,7 @@ class App(Generic[ReturnType], DOMNode):
if node is None: if node is None:
pop() pop()
else: else:
if node.is_container: if node.is_container and node.can_focus:
push(iter(node.children)) push(iter(node.children))
else: else:
if node.can_focus: if node.can_focus:
@@ -227,25 +230,25 @@ class App(Generic[ReturnType], DOMNode):
return widgets return widgets
def show_focus(self) -> None: def _set_active(self) -> None:
"""Highlight the currently focused widget.""" active_app.set(self)
self.move_focus(0)
def move_focus(self, direction: int = 0) -> None: def _move_focus(self, direction: int = 0) -> Widget | None:
"""Move the focus in the given direction. """Move the focus in the given direction.
Args: Args:
direction (int, optional): 1 to move forward, -1 to move backward, or direction (int, optional): 1 to move forward, -1 to move backward, or
0 to highlight the current focus. 0 to highlight the current focus.
""" """
if self._focus_timer: 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() self._focus_timer.stop_no_wait()
focusable_widgets = self._focusable_widgets focusable_widgets = self.focus_chain
if not focusable_widgets: if not focusable_widgets:
# Nothing focusable, so nothing to do # Nothing focusable, so nothing to do
return return self.focused
if self.focused is None: if self.focused is None:
# Nothing currently focused, so focus the first one # Nothing currently focused, so focus the first one
self.set_focus(focusable_widgets[0]) self.set_focus(focusable_widgets[0])
@@ -262,17 +265,21 @@ class App(Generic[ReturnType], DOMNode):
current_index = (current_index + direction) % len(focusable_widgets) current_index = (current_index + direction) % len(focusable_widgets)
self.set_focus(focusable_widgets[current_index]) 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.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.""" """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.""" """Focus the previous widget."""
self.move_focus(-1) return self._move_focus(-1)
def hide_focus(self) -> None: def hide_focus(self) -> None:
"""Hide the focus.""" """Hide the focus."""
@@ -483,9 +490,10 @@ class App(Generic[ReturnType], DOMNode):
self.register(self.screen, *anon_widgets, **widgets) self.register(self.screen, *anon_widgets, **widgets)
self.screen.refresh() self.screen.refresh()
async def push_screen(self, screen: Screen) -> Screen: def push_screen(self, screen: Screen | None = None) -> Screen:
self._screen_stack.append(screen) new_screen = Screen() if screen is None else screen
return screen self._screen_stack.append(new_screen)
return new_screen
def set_focus(self, widget: Widget | None) -> None: def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first. """Focus (or unfocus) a widget. A focused widget will receive key events first.
@@ -493,25 +501,32 @@ class App(Generic[ReturnType], DOMNode):
Args: Args:
widget (Widget): [description] widget (Widget): [description]
""" """
self.log("set_focus", widget) self.log("set_focus", widget=widget)
if widget == self.focused: if widget == self.focused:
self.log("already focused")
# Widget is already focused # Widget is already focused
return return
if widget is None: if widget is None:
if self.focused is not None: # No focus, so blur currently focused widget if it exists
focused = self.focused
self.focused = None
focused.post_message_no_wait(events.Blur(self))
elif widget.can_focus:
if self.focused is not None: if self.focused is not None:
self.focused.post_message_no_wait(events.Blur(self)) 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 self.focused = widget
# Send focus event
widget.post_message_no_wait(events.Focus(self)) 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 widget is None:
if self.mouse_over is not None: if self.mouse_over is not None:
try: try:
@@ -551,6 +566,10 @@ class App(Generic[ReturnType], DOMNode):
*renderables (RenderableType, optional): Rich renderables to display on exit. *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 = [ prerendered = [
Segments(self.console.render(renderable, self.console.options)) Segments(self.console.render(renderable, self.console.options))
for renderable in renderables for renderable in renderables
@@ -589,7 +608,7 @@ class App(Generic[ReturnType], DOMNode):
self._exit_renderables.clear() self._exit_renderables.clear()
async def process_messages(self) -> None: async def process_messages(self) -> None:
active_app.set(self) self._set_active()
log("---") log("---")
log(f"driver={self.driver_class}") log(f"driver={self.driver_class}")
@@ -835,7 +854,7 @@ class App(Generic[ReturnType], DOMNode):
if isinstance(event, events.Mount): if isinstance(event, events.Mount):
screen = Screen() screen = Screen()
self.register(self, screen) self.register(self, screen)
await self.push_screen(screen) self.push_screen(screen)
await super().on_event(event) await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded: 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_target = default_namespace or self
action_name = target action_name = target
log("ACTION", action_target, action_name) log("action", action)
await self.dispatch_action(action_target, action_name, params) await self.dispatch_action(action_target, action_name, params)
async def dispatch_action( async def dispatch_action(
self, namespace: object, action_name: str, params: Any self, namespace: object, action_name: str, params: Any
) -> None: ) -> None:
log(
"dispatch_action",
namespace=namespace,
action_name=action_name,
params=params,
)
_rich_traceback_guard = True _rich_traceback_guard = True
method_name = f"action_{action_name}" method_name = f"action_{action_name}"
method = getattr(namespace, method_name, None) method = getattr(namespace, method_name, None)

View File

@@ -83,7 +83,7 @@ class Selector:
return self._checks[self.type](node) return self._checks[self.type](node)
def _check_universal(self, node: DOMNode) -> bool: def _check_universal(self, node: DOMNode) -> bool:
return True return node.has_pseudo_class(*self.pseudo_classes)
def _check_type(self, node: DOMNode) -> bool: def _check_type(self, node: DOMNode) -> bool:
if node.css_type != self._name_lower: if node.css_type != self._name_lower:

View File

@@ -439,8 +439,11 @@ class DOMNode(MessagePump):
""" """
self._classes.update(class_names) self._classes.update(class_names)
self.app.stylesheet.update(self.app, animate=True) try:
self.refresh() self.app.stylesheet.update(self.app, animate=True)
self.refresh()
except LookupError:
pass
def remove_class(self, *class_names: str) -> None: def remove_class(self, *class_names: str) -> None:
"""Remove class names from this Node. """Remove class names from this Node.
@@ -450,8 +453,11 @@ class DOMNode(MessagePump):
""" """
self._classes.difference_update(class_names) self._classes.difference_update(class_names)
self.app.stylesheet.update(self.app, animate=True) try:
self.refresh() self.app.stylesheet.update(self.app, animate=True)
self.refresh()
except LookupError:
pass
def toggle_class(self, *class_names: str) -> None: def toggle_class(self, *class_names: str) -> None:
"""Toggle class names on this Node. """Toggle class names on this Node.
@@ -461,8 +467,11 @@ class DOMNode(MessagePump):
""" """
self._classes.symmetric_difference_update(class_names) self._classes.symmetric_difference_update(class_names)
self.app.stylesheet.update(self.app, animate=True) try:
self.refresh() self.app.stylesheet.update(self.app, animate=True)
self.refresh()
except LookupError:
pass
def has_pseudo_class(self, *class_names: str) -> bool: def has_pseudo_class(self, *class_names: str) -> bool:
"""Check for pseudo class (such as hover, focus etc)""" """Check for pseudo class (such as hover, focus etc)"""

View File

@@ -240,7 +240,7 @@ class MessagePump:
except CancelledError: except CancelledError:
raise raise
except Exception as error: except Exception as error:
self.app.panic(error) self.app.on_exception(error)
break break
finally: finally:
if self._message_queue.empty(): if self._message_queue.empty():

View File

@@ -160,9 +160,9 @@ class Screen(Widget):
else: else:
widget, region = self.get_widget_at(event.x, event.y) widget, region = self.get_widget_at(event.x, event.y)
except errors.NoWidget: except errors.NoWidget:
await self.app.set_mouse_over(None) await self.app._set_mouse_over(None)
else: else:
await self.app.set_mouse_over(widget) await self.app._set_mouse_over(widget)
mouse_event = events.MouseMove( mouse_event = events.MouseMove(
self, self,
event.x - region.x, event.x - region.x,

View File

@@ -529,7 +529,7 @@ class Widget(DOMNode):
Returns: Returns:
bool: True if this widget is a container. 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: def watch_mouse_over(self, value: bool) -> None:
"""Update from CSS if mouse over state changes.""" """Update from CSS if mouse over state changes."""

View File

@@ -36,8 +36,7 @@ class Button(Widget, can_focus=True):
} }
App.-show-focus Button:focus { 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)) await self.emit(Button.Pressed(self))
async def on_key(self, event: events.Key) -> None: async def on_key(self, event: events.Key) -> None:
self.log("BUTTON KEY", event)
if event.key == "enter" and not self.disabled: if event.key == "enter" and not self.disabled:
self.log("PRESSEd")
await self.emit(Button.Pressed(self)) await self.emit(Button.Pressed(self))

71
tests/test_focus.py Normal file
View File

@@ -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"