Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider visible children inside invisible containers when computing focus chain #3070

Merged
merged 13 commits into from
Aug 28, 2023
Merged
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Fixed auto height container with default grid-rows https://github.com/Textualize/textual/issues/1597
- Fixed `page_up` and `page_down` bug in `DataTable` when `show_header = False` https://github.com/Textualize/textual/pull/3093
- Fixed issue with visible children inside invisible container when moving focus https://github.com/Textualize/textual/issues/3053

## [0.33.0] - 2023-08-15

Expand All @@ -36,6 +37,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed `SelectionList.clear_options` https://github.com/Textualize/textual/pull/3075
- `MouseMove` events bubble up from widgets. `App` and `Screen` receive `MouseMove` events even if there's no Widget under the cursor. https://github.com/Textualize/textual/issues/2905

### Changed

- Breaking change: `DOMNode.visible` now takes into account full DOM to report whether a node is visible or not.

### Removed

- Property `Widget.focusable_children` https://github.com/Textualize/textual/pull/3070

### Added

- Added an interface for replacing prompt of an individual option in an `OptionList` https://github.com/Textualize/textual/issues/2603
Expand Down
16 changes: 12 additions & 4 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,13 +612,21 @@ def display(self, new_val: bool | str) -> None:

@property
def visible(self) -> bool:
"""Is the visibility style set to a visible state?
"""Is this widget visible in the DOM?

May be set to a boolean to make the node visible (`True`) or invisible (`False`), or to any valid value for the `visibility` rule.
If a widget hasn't had its visibility set explicitly, then it inherits it from its
DOM ancestors.

When a node is invisible, Textual will reserve space for it, but won't display anything there.
This may be set explicitly to override inherited values.
The valid values include the valid values for the `visibility` rule and the booleans
`True` or `False`, to set the widget to be visible or invisible, respectively.

When a node is invisible, Textual will reserve space for it, but won't display anything.
"""
return self.styles.visibility != "hidden"
own_value = self.styles.get_rule("visibility")
if own_value is not None:
return own_value != "hidden"
return self.parent.visible if self.parent else True

@visible.setter
def visible(self, new_value: bool | str) -> None:
Expand Down
39 changes: 32 additions & 7 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

from functools import partial
from operator import attrgetter
from typing import (
TYPE_CHECKING,
Awaitable,
Expand Down Expand Up @@ -293,18 +294,42 @@ def focus_chain(self) -> list[Widget]:

widgets: list[Widget] = []
add_widget = widgets.append
stack: list[Iterator[Widget]] = [iter(self.focusable_children)]
pop = stack.pop
push = stack.append
focus_sorter = attrgetter("_focus_sort_key")
# We traverse the DOM and keep track of where we are at with a node stack.
# Additionally, we manually keep track of the visibility of the DOM
# instead of relying on the property `.visible` to save on DOM traversals.
# node_stack: list[tuple[iterator over node children, node visibility]]
node_stack: list[tuple[Iterator[Widget], bool]] = [
(
iter(sorted(self.displayed_children, key=focus_sorter)),
self.visible,
)
]
pop = node_stack.pop
push = node_stack.append

while stack:
node = next(stack[-1], None)
while node_stack:
children_iterator, parent_visibility = node_stack[-1]
node = next(children_iterator, None)
if node is None:
pop()
else:
if node.disabled:
continue
node_styles_visibility = node.styles.get_rule("visibility")
node_is_visible = (
node_styles_visibility != "hidden"
if node_styles_visibility
else parent_visibility # Inherit visibility if the style is unset.
)
if node.is_container and node.can_focus_children:
push(iter(node.focusable_children))
if node.focusable:
sorted_displayed_children = sorted(
node.displayed_children, key=focus_sorter
)
push((iter(sorted_displayed_children), node_is_visible))
# Same check as `if node.focusable`, but we cached inherited visibility
# and we also skipped disabled nodes altogether.
if node_is_visible and node.can_focus:
add_widget(node)

return widgets
Expand Down
12 changes: 1 addition & 11 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,17 +1507,7 @@ def _self_or_ancestors_disabled(self) -> bool:
@property
def focusable(self) -> bool:
"""Can this widget currently be focused?"""
return self.can_focus and not self._self_or_ancestors_disabled

@property
def focusable_children(self) -> list[Widget]:
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""Get the children which may be focused.

Returns:
List of widgets that can receive focus.
"""
focusable = [child for child in self._nodes if child.display and child.visible]
return sorted(focusable, key=attrgetter("_focus_sort_key"))
return self.can_focus and self.visible and not self._self_or_ancestors_disabled

@property
def _focus_sort_key(self) -> tuple[int, int]:
Expand Down
108 changes: 108 additions & 0 deletions tests/test_focus.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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):
Expand Down Expand Up @@ -201,3 +203,109 @@ def test_focus_next_and_previous_with_str_selector_without_self(screen: Screen):
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,
]
43 changes: 0 additions & 43 deletions tests/test_visibility_change.py

This file was deleted.

78 changes: 78 additions & 0 deletions tests/test_visible.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widget import Widget


async def test_visibility_changes() -> None:
"""Test changing visibility via code and CSS.

See https://github.com/Textualize/textual/issues/1355 as the motivation for these tests.
"""

class VisibleTester(App[None]):
"""An app for testing visibility changes."""

CSS = """
Widget {
height: 1fr;
}
.hidden {
visibility: hidden;
}
"""

def compose(self) -> ComposeResult:
yield VerticalScroll(
Widget(id="keep"), Widget(id="hide-via-code"), Widget(id="hide-via-css")
)

async with VisibleTester().run_test() as pilot:
assert pilot.app.query_one("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").visible is True
assert pilot.app.query_one("#hide-via-css").visible is True

pilot.app.query_one("#hide-via-code").styles.visibility = "hidden"
await pilot.pause(0)
assert pilot.app.query_one("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").visible is False
assert pilot.app.query_one("#hide-via-css").visible is True

pilot.app.query_one("#hide-via-css").set_class(True, "hidden")
await pilot.pause(0)
assert pilot.app.query_one("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").visible is False
assert pilot.app.query_one("#hide-via-css").visible is False


async def test_visible_is_inherited() -> None:
"""Regression test for https://github.com/Textualize/textual/issues/3071"""

class InheritedVisibilityApp(App[None]):
CSS = """
#four {
visibility: visible;
}

#six {
visibility: hidden;
}
"""

def compose(self):
yield Widget(id="one")
with VerticalScroll(id="two"):
yield Widget(id="three")
with VerticalScroll(id="four"):
yield Widget(id="five")
with VerticalScroll(id="six"):
yield Widget(id="seven")

app = InheritedVisibilityApp()
async with app.run_test():
assert app.query_one("#one").visible
assert app.query_one("#two").visible
assert app.query_one("#three").visible
assert app.query_one("#four").visible
assert app.query_one("#five").visible
assert not app.query_one("#six").visible
assert not app.query_one("#seven").visible
Loading