diff --git a/src/textual/app.py b/src/textual/app.py
index d81bb817e..e2199a47f 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -1253,6 +1253,7 @@ class App(Generic[ReturnType], DOMNode):
will be added, and this method is called to apply the corresponding
:hover styles.
"""
+
descendants = node.walk_children(with_self=True)
self.stylesheet.update_nodes(descendants, animate=True)
diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py
index 282a42826..923ad9cc5 100644
--- a/src/textual/cli/previews/borders.py
+++ b/src/textual/cli/previews/borders.py
@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult
-from textual.containers import VerticalScroll
+from textual.containers import Vertical
from textual.css.constants import VALID_BORDER
from textual.widgets import Button, Label
@@ -12,7 +12,7 @@ And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
-class BorderButtons(VerticalScroll):
+class BorderButtons(Vertical):
DEFAULT_CSS = """
BorderButtons {
dock: left;
diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py
index d8028f7c5..4d578f6dc 100644
--- a/src/textual/cli/previews/colors.py
+++ b/src/textual/cli/previews/colors.py
@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult
-from textual.containers import Horizontal, VerticalScroll
+from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.design import ColorSystem
from textual.widget import Widget
from textual.widgets import Button, Footer, Label, Static
@@ -20,11 +20,11 @@ class ColorItem(Horizontal):
pass
-class ColorGroup(VerticalScroll):
+class ColorGroup(Vertical):
pass
-class Content(VerticalScroll):
+class Content(Vertical):
pass
diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py
index b62faa7ca..49baab974 100644
--- a/src/textual/cli/previews/easing.py
+++ b/src/textual/cli/previews/easing.py
@@ -5,7 +5,7 @@ from rich.console import RenderableType
from textual._easing import EASING
from textual.app import App, ComposeResult
from textual.cli.previews.borders import TEXT
-from textual.containers import Container, Horizontal, VerticalScroll
+from textual.containers import Horizontal, Vertical
from textual.reactive import reactive, var
from textual.scrollbar import ScrollBarRender
from textual.widget import Widget
@@ -72,13 +72,13 @@ class EasingApp(App):
)
yield EasingButtons()
- with VerticalScroll():
+ with Vertical():
with Horizontal(id="inputs"):
yield Label("Animation Duration:", id="label")
yield duration_input
with Horizontal():
yield self.animated_bar
- yield VerticalScroll(self.opacity_widget, id="other")
+ yield Vertical(self.opacity_widget, id="other")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
diff --git a/src/textual/screen.py b/src/textual/screen.py
index dac819314..34db473ef 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -31,6 +31,7 @@ from .binding import Binding
from .css.match import match
from .css.parse import parse_selectors
from .css.query import QueryType
+from .dom import DOMNode
from .geometry import Offset, Region, Size
from .reactive import Reactive
from .renderables.background_screen import BackgroundScreen
@@ -404,6 +405,32 @@ class Screen(Generic[ScreenResultType], Widget):
# Go with the what was found.
self.set_focus(chosen)
+ def _update_focus_styles(
+ self, focused: Widget | None = None, blurred: Widget | None = None
+ ) -> None:
+ """Update CSS for focus changes.
+
+ Args:
+ focused: The widget that was focused.
+ blurred: The widget that was blurred.
+ """
+ widgets: set[DOMNode] = set()
+
+ if focused is not None:
+ for widget in reversed(focused.ancestors_with_self):
+ if widget._has_focus_within:
+ widgets.update(widget.walk_children(with_self=True))
+ break
+ if blurred is not None:
+ for widget in reversed(blurred.ancestors_with_self):
+ if widget._has_focus_within:
+ widgets.update(widget.walk_children(with_self=True))
+ break
+ if widgets:
+ self.app.stylesheet.update_nodes(
+ [widget for widget in widgets if widget._has_focus_within], animate=True
+ )
+
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.
@@ -415,25 +442,35 @@ class Screen(Generic[ScreenResultType], Widget):
# Widget is already focused
return
+ focused: Widget | None = None
+ blurred: Widget | None = None
+
if widget is None:
# No focus, so blur currently focused widget if it exists
if self.focused is not None:
self.focused.post_message(events.Blur())
self.focused = None
+ blurred = self.focused
self.log.debug("focus was removed")
elif widget.focusable:
if self.focused != widget:
if self.focused is not None:
# Blur currently focused widget
self.focused.post_message(events.Blur())
+ blurred = self.focused
# Change focus
self.focused = widget
# Send focus event
if scroll_visible:
self.screen.scroll_to_widget(widget)
widget.post_message(events.Focus())
+ focused = widget
+
+ self._update_focus_styles(self.focused, widget)
self.log.debug(widget, "was focused")
+ self._update_focus_styles(focused, blurred)
+
async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 841593298..b4e9e506e 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3077,21 +3077,11 @@ class Widget(DOMNode):
def _on_focus(self, event: events.Focus) -> None:
self.has_focus = True
self.refresh()
- for widget in reversed(self.ancestors_with_self):
- if widget._has_focus_within:
- widget._update_styles()
- break
-
self.post_message(events.DescendantFocus())
def _on_blur(self, event: events.Blur) -> None:
self.has_focus = False
self.refresh()
- for widget in reversed(self.ancestors_with_self):
- if widget._has_focus_within:
- widget._update_styles()
- break
-
self.post_message(events.DescendantBlur())
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 7d57deece..b69b5dfe4 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -14265,148 +14265,147 @@
font-weight: 700;
}
- .terminal-391329861-matrix {
+ .terminal-2863933047-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-391329861-title {
+ .terminal-2863933047-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-391329861-r1 { fill: #454a50 }
- .terminal-391329861-r2 { fill: #e1e1e1 }
- .terminal-391329861-r3 { fill: #c5c8c6 }
- .terminal-391329861-r4 { fill: #e2e3e3;font-weight: bold }
- .terminal-391329861-r5 { fill: #262626 }
- .terminal-391329861-r6 { fill: #000000 }
- .terminal-391329861-r7 { fill: #e2e2e2 }
- .terminal-391329861-r8 { fill: #e3e3e3 }
- .terminal-391329861-r9 { fill: #14191f }
- .terminal-391329861-r10 { fill: #b93c5b }
- .terminal-391329861-r11 { fill: #121212 }
- .terminal-391329861-r12 { fill: #1e1e1e }
- .terminal-391329861-r13 { fill: #e2e3e3 }
- .terminal-391329861-r14 { fill: #fea62b }
- .terminal-391329861-r15 { fill: #211505;font-weight: bold }
- .terminal-391329861-r16 { fill: #211505 }
- .terminal-391329861-r17 { fill: #dde8f3;font-weight: bold }
- .terminal-391329861-r18 { fill: #ddedf9 }
+ .terminal-2863933047-r1 { fill: #454a50 }
+ .terminal-2863933047-r2 { fill: #e1e1e1 }
+ .terminal-2863933047-r3 { fill: #c5c8c6 }
+ .terminal-2863933047-r4 { fill: #e2e3e3;font-weight: bold }
+ .terminal-2863933047-r5 { fill: #262626 }
+ .terminal-2863933047-r6 { fill: #000000 }
+ .terminal-2863933047-r7 { fill: #e2e2e2 }
+ .terminal-2863933047-r8 { fill: #e3e3e3 }
+ .terminal-2863933047-r9 { fill: #14191f }
+ .terminal-2863933047-r10 { fill: #b93c5b }
+ .terminal-2863933047-r11 { fill: #121212 }
+ .terminal-2863933047-r12 { fill: #1e1e1e }
+ .terminal-2863933047-r13 { fill: #fea62b }
+ .terminal-2863933047-r14 { fill: #211505;font-weight: bold }
+ .terminal-2863933047-r15 { fill: #211505 }
+ .terminal-2863933047-r16 { fill: #dde8f3;font-weight: bold }
+ .terminal-2863933047-r17 { fill: #ddedf9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- EasingApp
+ EasingApp
-
-
-
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
- round▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:▊1.0▎
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- out_sine
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
- out_quint▏▎▊
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎Welcome to Textual!▊
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎▊
- out_quart▏▎I must not fear.▊
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎Fear is the ▊
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎mind-killer.▊
- out_quad▏▎Fear is the ▊
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎little-death that ▊
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎brings total ▊
- out_expo▏▎obliteration.▊▆▆
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎I will face my fear.▊
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎I will permit it to ▊
- out_elastic▏▎pass over me and ▊
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎through me.▊
- ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎And when it has gone ▊
- out_cubic▏
- ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P Focus: Duration Input CTRL+B Toggle Dark
+
+
+
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+ round▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:▊1.0▎
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ out_sine
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+ out_quint▏▎▊
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎Welcome to Textual!▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎▊
+ out_quart▏▎I must not fear.▊
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎Fear is the ▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎mind-killer.▊
+ out_quad▏▎Fear is the ▊
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎little-death that ▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎brings total ▊
+ out_expo▏▎obliteration.▊
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎I will face my fear.▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎I will permit it to ▊
+ out_elastic▏▎pass over me and ▊
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▏▎through me.▊
+ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▏▎And when it has gone ▊
+ out_cubic▏
+ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P Focus: Duration Input CTRL+B Toggle Dark
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index b55405306..c9aee9636 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -294,7 +294,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):
def test_borders_preview(snap_compare):
- assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "tab", "enter"])
+ assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "enter"])
def test_colors_preview(snap_compare):