Skip to content

Commit

Permalink
Merge pull request #954 from davep/bug/939/loss-of-focus-take-2
Browse files Browse the repository at this point in the history
Try and better settle focus after a focused widget is removed (redux)
  • Loading branch information
davep authored Oct 19, 2022
2 parents d485949 + 11ddcdd commit 84e514e
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 13 deletions.
2 changes: 2 additions & 0 deletions sandbox/davep/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
all:
poetry run textual run --dev focus_removal_tester.py
45 changes: 45 additions & 0 deletions sandbox/davep/focus_removal_tester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Focus removal tester.
https://github.com/Textualize/textual/issues/939
"""

from textual.app import App
from textual.containers import Container
from textual.widgets import Static, Header, Footer, Button


class LeftButton(Button):
pass


class RightButton(Button):
pass


class NonFocusParent(Static):
def compose(self):
yield LeftButton("Do Not Press")
yield Static("Test")
yield RightButton("Really Do Not Press")


class FocusRemovalTester(App[None]):

BINDINGS = [("a", "add_widget", "Add Widget"), ("d", "del_widget", "Delete Widget")]

def compose(self):
yield Header()
yield Container()
yield Footer()

def action_add_widget(self):
self.query_one(Container).mount(NonFocusParent())

def action_del_widget(self):
candidates = self.query(NonFocusParent)
if candidates:
candidates.last().remove()


if __name__ == "__main__":
FocusRemovalTester().run()
13 changes: 9 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1448,11 +1448,16 @@ async def _on_remove(self, event: events.Remove) -> None:
widget = event.widget
parent = widget.parent

widget.reset_focus()

remove_widgets = widget.walk_children(
Widget, with_self=True, method="depth", reverse=True
remove_widgets = list(
widget.walk_children(Widget, with_self=True, method="depth", reverse=True)
)

if self.screen.focused in remove_widgets:
self.screen._reset_focus(
self.screen.focused,
[to_remove for to_remove in remove_widgets if to_remove.can_focus],
)

for child in remove_widgets:
await child._close_messages()
self._unregister(child)
Expand Down
49 changes: 41 additions & 8 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ._compositor import Compositor, MapGeometry
from .timer import Timer
from ._types import CallbackType
from .dom import DOMNode
from .geometry import Offset, Region, Size
from .reactive import Reactive
from .renderables.blank import Blank
Expand Down Expand Up @@ -223,19 +224,51 @@ def focus_previous(self) -> Widget | None:
"""
return self._move_focus(-1)

def _reset_focus(self, widget: Widget) -> None:
def _reset_focus(
self, widget: Widget, avoiding: list[DOMNode] | None = None
) -> None:
"""Reset the focus when a widget is removed
Args:
widget (Widget): A widget that is removed.
avoiding (list[DOMNode] | None, optional): Optional list of nodes to avoid.
"""
if self.focused is widget:
for sibling in widget.siblings:
if sibling.can_focus:
sibling.focus()
break
else:
self.focused = None

avoiding = avoiding or []

# Make this a NOP if we're being asked to deal with a widget that
# isn't actually the currently-focused widget.
if self.focused is not widget:
return

# Grab the list of widgets that we can set focus to.
focusable_widgets = self.focus_chain
if not focusable_widgets:
# If there's nothing to focus... give up now.
return

try:
# Find the location of the widget we're taking focus from, in
# the focus chain.
widget_index = focusable_widgets.index(widget)
except ValueError:
# Seems we can't find it. There's no good reason this should
# happen but, on the off-chance, let's go into a "no focus" state.
self.set_focus(None)
return

# Now go looking for something before it, that isn't about to be
# removed, and which can receive focus, and go focus that.
chosen: DOMNode | None = None
for candidate in reversed(
focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
):
if candidate not in avoiding and candidate.can_focus:
chosen = candidate
break

# Go with the what was found.
self.set_focus(chosen)

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.
Expand Down
1 change: 0 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -1775,7 +1775,6 @@ def refresh(

def remove(self) -> None:
"""Remove the Widget from the DOM (effectively deleting it)"""
self.display = False
self.app.post_message_no_wait(events.Remove(self, widget=self))

def render(self) -> RenderableType:
Expand Down

0 comments on commit 84e514e

Please sign in to comment.