mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
focus traversal
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -439,8 +439,11 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
self._classes.update(class_names)
|
self._classes.update(class_names)
|
||||||
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
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)
|
||||||
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
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)
|
||||||
|
try:
|
||||||
self.app.stylesheet.update(self.app, animate=True)
|
self.app.stylesheet.update(self.app, animate=True)
|
||||||
self.refresh()
|
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)"""
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ 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
71
tests/test_focus.py
Normal 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"
|
||||||
Reference in New Issue
Block a user