Skip to content

Commit 20ae636

Browse files
authored
Merge pull request #4821 from Textualize/tab-remove-fix
remove tab fix
2 parents 7515a68 + ce39259 commit 20ae636

File tree

7 files changed

+251
-68
lines changed

7 files changed

+251
-68
lines changed

src/textual/_animator.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ def animate(
316316
on_complete: Callback to run after the animation completes.
317317
level: Minimum level required for the animation to take place (inclusive).
318318
"""
319+
self._record_animation(attribute)
319320
animate_callback = partial(
320321
self._animate,
321322
obj,
@@ -336,6 +337,13 @@ def animate(
336337
else:
337338
animate_callback()
338339

340+
def _record_animation(self, attribute: str) -> None:
341+
"""Called when an attribute is to be animated.
342+
343+
Args:
344+
attribute: Attribute being animated.
345+
"""
346+
339347
def _animate(
340348
self,
341349
obj: object,
@@ -438,6 +446,7 @@ def _animate(
438446
),
439447
level=level,
440448
)
449+
441450
assert animation is not None, "animation expected to be non-None"
442451

443452
current_animation = self._animations.get(animation_key)

src/textual/widgets/_tabbed_content.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,7 @@ def remove_pane(self, pane_id: str) -> AwaitComplete:
462462
# other means; so allow that to be a no-op.
463463
pass
464464

465-
async def _remove_content() -> None:
466-
await gather(*removal_awaitables)
467-
468-
return AwaitComplete(_remove_content())
465+
return AwaitComplete(*removal_awaitables)
469466

470467
def clear_panes(self) -> AwaitComplete:
471468
"""Remove all the panes in the tabbed content.

src/textual/widgets/_tabs.py

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import asyncio
43
from dataclasses import dataclass
54
from typing import ClassVar
65

@@ -520,27 +519,20 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete:
520519
except NoMatches:
521520
return AwaitComplete()
522521

523-
removing_active_tab = remove_tab.has_class("-active")
524-
next_tab = self._next_active
525-
remove_await = remove_tab.remove()
526-
527-
highlight_updated = asyncio.Event()
522+
if remove_tab.has_class("-active"):
523+
next_tab = self._next_active
524+
else:
525+
next_tab = None
528526

529527
async def do_remove() -> None:
530528
"""Perform the remove after refresh so the underline bar gets new positions."""
531-
await remove_await
532-
if next_tab is None or (removing_active_tab and next_tab.id is None):
533-
self.active = ""
534-
elif removing_active_tab:
529+
await remove_tab.remove()
530+
if next_tab is not None:
535531
self.active = next_tab.id or ""
536-
next_tab.add_class("-active")
537-
538-
highlight_updated.set()
539-
540-
async def wait_for_highlight_update() -> None:
541-
await highlight_updated.wait()
532+
if not self.query("#tabs-list > Tab"):
533+
self.active = ""
542534

543-
return AwaitComplete(do_remove(), wait_for_highlight_update())
535+
return AwaitComplete(do_remove())
544536

545537
def validate_active(self, active: str) -> str:
546538
"""Check id assigned to active attribute is a valid tab."""
@@ -584,7 +576,9 @@ def watch_active(self, previously_active: str, active: str) -> None:
584576
except NoMatches:
585577
return
586578
active_tab.add_class("-active")
579+
587580
self._highlight_active(animate=previously_active != "")
581+
588582
self._scroll_active_tab()
589583
self.post_message(self.TabActivated(self, active_tab))
590584
else:
@@ -604,29 +598,30 @@ def _highlight_active(
604598
"""
605599
underline = self.query_one(Underline)
606600
try:
607-
active_tab = self.query_one(f"#tabs-list > Tab.-active")
601+
_active_tab = self.query_one("#tabs-list > Tab.-active")
608602
except NoMatches:
609603
underline.show_highlight = False
610604
underline.highlight_start = 0
611605
underline.highlight_end = 0
612606
else:
613607
underline.show_highlight = True
614-
tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter)
615-
start, end = tab_region.column_span
616-
# This is a basic animation, so we only disable it if we want no animations.
617-
if animate and self.app.animation_level != "none":
618608

619-
def animate_underline() -> None:
620-
"""Animate the underline."""
621-
try:
622-
active_tab = self.query_one(f"#tabs-list > Tab.-active")
623-
except NoMatches:
624-
pass
625-
else:
626-
tab_region = active_tab.virtual_region.shrink(
627-
active_tab.styles.gutter
628-
)
629-
start, end = tab_region.column_span
609+
def move_underline(animate: bool) -> None:
610+
"""Move the tab underline.
611+
612+
Args:
613+
animate: animate the underline to its new position.
614+
"""
615+
try:
616+
active_tab = self.query_one("#tabs-list > Tab.-active")
617+
except NoMatches:
618+
pass
619+
else:
620+
tab_region = active_tab.virtual_region.shrink(
621+
active_tab.styles.gutter
622+
)
623+
start, end = tab_region.column_span
624+
if animate:
630625
underline.animate(
631626
"highlight_start",
632627
start,
@@ -639,11 +634,17 @@ def animate_underline() -> None:
639634
duration=0.3,
640635
level="basic",
641636
)
637+
else:
638+
underline.highlight_start = start
639+
underline.highlight_end = end
642640

643-
self.set_timer(0.02, lambda: self.call_after_refresh(animate_underline))
641+
if animate and self.app.animation_level != "none":
642+
self.set_timer(
643+
0.02,
644+
lambda: self.call_after_refresh(move_underline, True),
645+
)
644646
else:
645-
underline.highlight_start = start
646-
underline.highlight_end = end
647+
self.call_after_refresh(move_underline, False)
647648

648649
async def _on_tab_clicked(self, event: Tab.Clicked) -> None:
649650
"""Activate a tab that was clicked."""

tests/animations/test_tabs_underline_animation.py

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from textual.app import App, ComposeResult
77
from textual.widgets import Label, TabbedContent, Tabs
8-
from textual.widgets._tabs import Underline
98

109

1110
class TabbedContentApp(App[None]):
@@ -20,56 +19,44 @@ async def test_tabs_underline_animates_on_full() -> None:
2019
app = TabbedContentApp()
2120
app.animation_level = "full"
2221

22+
animations: list[str] = []
23+
2324
async with app.run_test() as pilot:
24-
underline = app.query_one(Underline)
2525
animator = app.animator
26-
# Freeze time at 0 before triggering the animation.
27-
animator._get_time = lambda *_: 0
26+
animator._record_animation = animations.append
2827
app.query_one(Tabs).action_previous_tab()
2928
await pilot.pause()
30-
# Freeze time after the animation start and before animation end.
31-
animator._get_time = lambda *_: 0.01
32-
# Move to the next frame.
33-
animator()
34-
assert animator.is_being_animated(underline, "highlight_start")
35-
assert animator.is_being_animated(underline, "highlight_end")
29+
assert "highlight_start" in animations
30+
assert "highlight_end" in animations
3631

3732

3833
async def test_tabs_underline_animates_on_basic() -> None:
3934
"""The underline takes some time to move when animated."""
4035
app = TabbedContentApp()
4136
app.animation_level = "basic"
4237

38+
animations: list[str] = []
39+
4340
async with app.run_test() as pilot:
44-
underline = app.query_one(Underline)
4541
animator = app.animator
46-
# Freeze time at 0 before triggering the animation.
47-
animator._get_time = lambda *_: 0
42+
animator._record_animation = animations.append
4843
app.query_one(Tabs).action_previous_tab()
4944
await pilot.pause()
50-
# Freeze time after the animation start and before animation end.
51-
animator._get_time = lambda *_: 0.01
52-
# Move to the next frame.
53-
animator()
54-
assert animator.is_being_animated(underline, "highlight_start")
55-
assert animator.is_being_animated(underline, "highlight_end")
45+
assert "highlight_start" in animations
46+
assert "highlight_end" in animations
5647

5748

5849
async def test_tabs_underline_does_not_animate_on_none() -> None:
5950
"""The underline jumps to its final position when not animated."""
6051
app = TabbedContentApp()
6152
app.animation_level = "none"
6253

54+
animations: list[str] = []
55+
6356
async with app.run_test() as pilot:
64-
underline = app.query_one(Underline)
6557
animator = app.animator
66-
# Freeze time at 0 before triggering the animation.
67-
animator._get_time = lambda *_: 0
58+
animator._record_animation = animations.append
6859
app.query_one(Tabs).action_previous_tab()
6960
await pilot.pause()
70-
# Freeze time after the animation start and before animation end.
71-
animator._get_time = lambda *_: 0.01
72-
# Move to the next frame.
73-
animator()
74-
assert not animator.is_being_animated(underline, "highlight_start")
75-
assert not animator.is_being_animated(underline, "highlight_end")
61+
assert "highlight_start" not in animations
62+
assert "highlight_end" not in animations

0 commit comments

Comments
 (0)