Files
textual/tests/test_focus.py
Rodrigo Girão Serrão 41006caffc Consider visible children inside invisible containers when computing focus chain (#3070)
* Add regression tests for #3053

* Traverse invisible containers when computing focus chain.

At the moment, we were completely bypassing invisible containers which meant that their visible children wouldn't be included in the focus chain.

* Make note of removed property.

* Add regression test for #3071.

* Fix #3071.

* Fix regression test for #3053.

* Optimize computation of focus chain.

Computing the focus chain was relying on the property 'visible' of nodes which may traverse the DOM up to find the visibility of a given node. Instead, we cache the visibility of the nodes we traverse and keep them in a stack, saving some of that computation.
Related issues: #3071
Related comments: https://github.com/Textualize/textual/pull/3070#issuecomment-1669683285

* Make test more robust.

* Make test more robust.

* Short-circuit disabled portions of DOM.

If a node is disabled, we will not be focusable, nor will its children, so we can skip it altogether.
Related review comment: https://github.com/Textualize/textual/pull/3070/files#r1300292492

* Simplify traversal.

The traversal code could be simplified after reordering some lines of code.
We also get rid of the visibility stack and instead keep everything in the same stack.
Related comments: https://github.com/Textualize/textual/pull/3070#pullrequestreview-1587295458
2023-08-28 11:06:39 +01:00

312 lines
9.4 KiB
Python

import pytest
from textual.app import App
from textual.containers import Container
from textual.screen import Screen
from textual.widget import Widget
from textual.widgets import Button
class Focusable(Widget, can_focus=True):
pass
class NonFocusable(Widget, can_focus=False, can_focus_children=False):
pass
class ChildrenFocusableOnly(Widget, can_focus=False, can_focus_children=True):
pass
@pytest.fixture
def screen() -> Screen:
app = App()
app._set_active()
app.push_screen(Screen())
screen = app.screen
# The classes even/odd alternate along the focus chain.
# The classes in/out identify nested widgets.
screen._add_children(
Focusable(id="foo", classes="a"),
NonFocusable(id="bar"),
Focusable(Focusable(id="Paul", classes="c"), id="container1", classes="b"),
NonFocusable(Focusable(id="Jessica", classes="a"), id="container2"),
Focusable(id="baz", classes="b"),
ChildrenFocusableOnly(Focusable(id="child", classes="c")),
)
return screen
def test_focus_chain():
app = App()
app._set_active()
app.push_screen(Screen())
screen = app.screen
# Check empty focus chain
assert not screen.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"),
ChildrenFocusableOnly(Focusable(id="child")),
)
focus_chain = [widget.id for widget in screen.focus_chain]
assert focus_chain == ["foo", "container1", "Paul", "baz", "child"]
def test_focus_next_and_previous(screen: Screen):
assert screen.focus_next().id == "foo"
assert screen.focus_next().id == "container1"
assert screen.focus_next().id == "Paul"
assert screen.focus_next().id == "baz"
assert screen.focus_next().id == "child"
assert screen.focus_previous().id == "baz"
assert screen.focus_previous().id == "Paul"
assert screen.focus_previous().id == "container1"
assert screen.focus_previous().id == "foo"
def test_focus_next_wrap_around(screen: Screen):
"""Ensure focusing the next widget wraps around the focus chain."""
screen.set_focus(screen.query_one("#child"))
assert screen.focused.id == "child"
assert screen.focus_next().id == "foo"
def test_focus_previous_wrap_around(screen: Screen):
"""Ensure focusing the previous widget wraps around the focus chain."""
screen.set_focus(screen.query_one("#foo"))
assert screen.focused.id == "foo"
assert screen.focus_previous().id == "child"
def test_wrap_around_selector(screen: Screen):
"""Ensure moving focus in both directions wraps around the focus chain."""
screen.set_focus(screen.query_one("#foo"))
assert screen.focused.id == "foo"
assert screen.focus_previous("#Paul").id == "Paul"
assert screen.focus_next("#foo").id == "foo"
def test_no_focus_empty_selector(screen: Screen):
"""Ensure focus is cleared when selector matches nothing."""
assert screen.focus_next("#bananas") is None
assert screen.focus_previous("#bananas") is None
screen.set_focus(screen.query_one("#foo"))
assert screen.focused is not None
assert screen.focus_next("bananas") is None
assert screen.focused is None
screen.set_focus(screen.query_one("#foo"))
assert screen.focused is not None
assert screen.focus_previous("bananas") is None
assert screen.focused is None
def test_focus_next_and_previous_with_type_selector(screen: Screen):
"""Move focus with a selector that matches the currently focused node."""
screen.set_focus(screen.query_one("#Paul"))
assert screen.focused.id == "Paul"
assert screen.focus_next(Focusable).id == "baz"
assert screen.focus_next(Focusable).id == "child"
assert screen.focus_previous(Focusable).id == "baz"
assert screen.focus_previous(Focusable).id == "Paul"
assert screen.focus_previous(Focusable).id == "container1"
assert screen.focus_previous(Focusable).id == "foo"
def test_focus_next_and_previous_with_str_selector(screen: Screen):
"""Move focus with a selector that matches the currently focused node."""
screen.set_focus(screen.query_one("#foo"))
assert screen.focused.id == "foo"
assert screen.focus_next(".a").id == "foo"
assert screen.focus_next(".c").id == "Paul"
assert screen.focus_next(".c").id == "child"
assert screen.focus_previous(".c").id == "Paul"
assert screen.focus_previous(".a").id == "foo"
def test_focus_next_and_previous_with_type_selector_without_self():
"""Test moving the focus with a selector that does not match the currently focused node."""
app = App()
app._set_active()
app.push_screen(Screen())
screen = app.screen
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Button, Input, Switch
screen._add_children(
VerticalScroll(
Horizontal(
Input(id="w3"),
Switch(id="w4"),
Input(id="w5"),
Button(id="w6"),
Switch(id="w7"),
id="w2",
),
Horizontal(
Button(id="w9"),
Switch(id="w10"),
Button(id="w11"),
Input(id="w12"),
Input(id="w13"),
id="w8",
),
id="w1",
)
)
screen.set_focus(screen.query_one("#w3"))
assert screen.focused.id == "w3"
assert screen.focus_next(Button).id == "w6"
assert screen.focus_next(Switch).id == "w7"
assert screen.focus_next(Input).id == "w12"
assert screen.focus_previous(Button).id == "w11"
assert screen.focus_previous(Switch).id == "w10"
assert screen.focus_previous(Button).id == "w9"
assert screen.focus_previous(Input).id == "w5"
def test_focus_next_and_previous_with_str_selector_without_self(screen: Screen):
"""Test moving the focus with a selector that does not match the currently focused node."""
screen.set_focus(screen.query_one("#foo"))
assert screen.focused.id == "foo"
assert screen.focus_next(".c").id == "Paul"
assert screen.focus_next(".b").id == "baz"
assert screen.focus_next(".c").id == "child"
assert screen.focus_previous(".a").id == "foo"
assert screen.focus_previous(".a").id == "foo"
assert screen.focus_previous(".b").id == "baz"
async def test_focus_does_not_move_to_invisible_widgets():
"""Make sure invisible widgets don't get focused by accident.
This is kind of a regression test for https://github.com/Textualize/textual/issues/3053,
but not really.
"""
class MyApp(App):
CSS = "#inv { visibility: hidden; }"
def compose(self):
yield Button("one", id="one")
yield Button("two", id="inv")
yield Button("three", id="three")
app = MyApp()
async with app.run_test():
assert app.focused.id == "one"
assert app.screen.focus_next().id == "three"
async def test_focus_moves_to_visible_widgets_inside_invisible_containers():
"""Regression test for https://github.com/Textualize/textual/issues/3053."""
class MyApp(App):
CSS = """
#inv { visibility: hidden; }
#three { visibility: visible; }
"""
def compose(self):
yield Button(id="one")
with Container(id="inv"):
yield Button(id="three")
app = MyApp()
async with app.run_test():
assert app.focused.id == "one"
assert app.screen.focus_next().id == "three"
async def test_focus_chain_handles_inherited_visibility():
"""Regression test for https://github.com/Textualize/textual/issues/3053
This is more or less a test for the interactions between #3053 and #3071.
We want to make sure that the focus chain is computed correctly when going through
a DOM with containers with all sorts of visibilities set.
"""
class W(Widget):
can_focus = True
w1 = W(id="one")
c2 = Container(id="two")
w3 = W(id="three")
c4 = Container(id="four")
w5 = W(id="five")
c6 = Container(id="six")
w7 = W(id="seven")
c8 = Container(id="eight")
w9 = W(id="nine")
w10 = W(id="ten")
w11 = W(id="eleven")
w12 = W(id="twelve")
w13 = W(id="thirteen")
class InheritedVisibilityApp(App[None]):
CSS = """
#four, #eight, #ten {
visibility: visible;
}
#six, #thirteen {
visibility: hidden;
}
"""
def compose(self):
yield w1 # visible, inherited
with c2: # visible, inherited
yield w3 # visible, inherited
with c4: # visible, set
yield w5 # visible, inherited
with c6: # hidden, set
yield w7 # hidden, inherited
with c8: # visible, set
yield w9 # visible, inherited
yield w10 # visible, set
yield w11 # visible, inherited
yield w12 # visible, inherited
yield w13 # invisible, set
app = InheritedVisibilityApp()
async with app.run_test():
focus_chain = app.screen.focus_chain
assert focus_chain == [
w1,
w3,
w5,
w9,
w10,
w11,
w12,
]