Skip to content

Commit c966243

Browse files
authored
Merge pull request #2751 from davep/tabbed-content-redux
2 parents 8e6904b + 832208b commit c966243

File tree

5 files changed

+427
-27
lines changed

5 files changed

+427
-27
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1313
- Class variable `CSS` to screens https://github.com/Textualize/textual/issues/2137
1414
- Class variable `CSS_PATH` to screens https://github.com/Textualize/textual/issues/2137
1515
- Added `cursor_foreground_priority` and `cursor_background_priority` to `DataTable` https://github.com/Textualize/textual/pull/2736
16+
- Added `TabbedContent.tab_count` https://github.com/Textualize/textual/pull/2751
17+
- Added `TabbedContnet.add_pane` https://github.com/Textualize/textual/pull/2751
18+
- Added `TabbedContent.remove_pane` https://github.com/Textualize/textual/pull/2751
19+
- Added `TabbedContent.clear_panes` https://github.com/Textualize/textual/pull/2751
20+
- Added `TabbedContent.Cleared` https://github.com/Textualize/textual/pull/2751
1621

1722
### Fixed
1823

@@ -22,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2227
- Fixed issue where internal data of `OptionList` could be invalid for short window after `clear_options` https://github.com/Textualize/textual/pull/2754
2328
- Fixed `Tooltip` causing a `query_one` on a lone `Static` to fail https://github.com/Textualize/textual/issues/2723
2429
- Nested widgets wouldn't lose focus when parent is disabled https://github.com/Textualize/textual/issues/2772
30+
- Fixed the `Tabs` `Underline` highlight getting "lost" in some extreme situations https://github.com/Textualize/textual/pull/2751
2531

2632
### Changed
2733

src/textual/widgets/_content_switcher.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Optional
66

77
from ..containers import Container
8+
from ..css.query import NoMatches
89
from ..events import Mount
910
from ..reactive import reactive
1011
from ..widget import Widget
@@ -84,6 +85,9 @@ def watch_current(self, old: str | None, new: str | None) -> None:
8485
"""
8586
with self.app.batch_update():
8687
if old:
87-
self.get_child_by_id(old).display = False
88+
try:
89+
self.get_child_by_id(old).display = False
90+
except NoMatches:
91+
pass
8892
if new:
8993
self.get_child_by_id(new).display = True

src/textual/widgets/_tabbed_content.py

Lines changed: 169 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from __future__ import annotations
22

3+
from asyncio import gather
34
from itertools import zip_longest
5+
from typing import Generator
46

57
from rich.repr import Result
68
from rich.text import Text, TextType
79

810
from ..app import ComposeResult
11+
from ..await_remove import AwaitRemove
12+
from ..css.query import NoMatches
913
from ..message import Message
1014
from ..reactive import reactive
11-
from ..widget import Widget
15+
from ..widget import AwaitMount, Widget
1216
from ._content_switcher import ContentSwitcher
1317
from ._tabs import Tab, Tabs
1418

@@ -70,6 +74,25 @@ def __init__(
7074
)
7175

7276

77+
class AwaitTabbedContent:
78+
"""An awaitable returned by [`TabbedContent`][textual.widgets.TabbedContent] methods that modify the tabs."""
79+
80+
def __init__(self, *awaitables: AwaitMount | AwaitRemove) -> None:
81+
"""Initialise the awaitable.
82+
83+
Args:
84+
*awaitables: The collection of awaitables to await.
85+
"""
86+
super().__init__()
87+
self._awaitables = awaitables
88+
89+
def __await__(self) -> Generator[None, None, None]:
90+
async def await_tabbed_content() -> None:
91+
await gather(*self._awaitables)
92+
93+
return await_tabbed_content().__await__()
94+
95+
7396
class TabbedContent(Widget):
7497
"""A container with associated tabs to toggle content visibility."""
7598

@@ -117,6 +140,28 @@ def __rich_repr__(self) -> Result:
117140
yield self.tabbed_content
118141
yield self.tab
119142

143+
class Cleared(Message):
144+
"""Posted when there are no more tab panes."""
145+
146+
def __init__(self, tabbed_content: TabbedContent) -> None:
147+
"""Initialize message.
148+
149+
Args:
150+
tabbed_content: The TabbedContent widget.
151+
"""
152+
self.tabbed_content = tabbed_content
153+
"""The `TabbedContent` widget that contains the tab activated."""
154+
super().__init__()
155+
156+
@property
157+
def control(self) -> TabbedContent:
158+
"""The `TabbedContent` widget that was cleared of all tab panes.
159+
160+
This is an alias for [`Cleared.tabbed_content`][textual.widgets.TabbedContent.Cleared.tabbed_content]
161+
and is used by the [`on`][textual.on] decorator.
162+
"""
163+
return self.tabbed_content
164+
120165
def __init__(
121166
self,
122167
*titles: TextType,
@@ -151,37 +196,37 @@ def validate_active(self, active: str) -> str:
151196
Value of `active`.
152197
153198
Raises:
154-
ValueError: If the active attribute is set to empty string.
199+
ValueError: If the active attribute is set to empty string when there are tabs available.
155200
"""
156-
if not active:
201+
if not active and self.get_child_by_type(ContentSwitcher).current:
157202
raise ValueError("'active' tab must not be empty string.")
158203
return active
159204

160-
def compose(self) -> ComposeResult:
161-
"""Compose the tabbed content."""
205+
@staticmethod
206+
def _set_id(content: TabPane, new_id: int) -> TabPane:
207+
"""Set an id on the content, if not already present.
162208
163-
def set_id(content: TabPane, new_id: str) -> TabPane:
164-
"""Set an id on the content, if not already present.
209+
Args:
210+
content: a TabPane.
211+
new_id: Numeric ID to make the pane ID from.
165212
166-
Args:
167-
content: a TabPane.
168-
new_id: New `is` attribute, if it is not already set.
213+
Returns:
214+
The same TabPane.
215+
"""
216+
if content.id is None:
217+
content.id = f"tab-{new_id}"
218+
return content
169219

170-
Returns:
171-
The same TabPane.
172-
"""
173-
if content.id is None:
174-
content.id = new_id
175-
return content
220+
def compose(self) -> ComposeResult:
221+
"""Compose the tabbed content."""
176222

177223
# Wrap content in a `TabPane` if required.
178224
pane_content = [
179-
(
180-
set_id(content, f"tab-{index}")
225+
self._set_id(
226+
content
181227
if isinstance(content, TabPane)
182-
else TabPane(
183-
title or self.render_str(f"Tab {index}"), content, id=f"tab-{index}"
184-
)
228+
else TabPane(title or self.render_str(f"Tab {index}"), content),
229+
index,
185230
)
186231
for index, (title, content) in enumerate(
187232
zip_longest(self.titles, self._tab_content), 1
@@ -197,6 +242,99 @@ def set_id(content: TabPane, new_id: str) -> TabPane:
197242
with ContentSwitcher(initial=self._initial or None):
198243
yield from pane_content
199244

245+
def add_pane(
246+
self,
247+
pane: TabPane,
248+
*,
249+
before: TabPane | str | None = None,
250+
after: TabPane | str | None = None,
251+
) -> AwaitTabbedContent:
252+
"""Add a new pane to the tabbed content.
253+
254+
Args:
255+
pane: The pane to add.
256+
before: Optional pane or pane ID to add the pane before.
257+
after: Optional pane or pane ID to add the pane after.
258+
259+
Returns:
260+
An awaitable object that waits for the pane to be added.
261+
262+
Raises:
263+
Tabs.TabError: If there is a problem with the addition request.
264+
265+
Note:
266+
Only one of `before` or `after` can be provided. If both are
267+
provided a `Tabs.TabError` will be raised.
268+
"""
269+
if isinstance(before, TabPane):
270+
before = before.id
271+
if isinstance(after, TabPane):
272+
after = after.id
273+
tabs = self.get_child_by_type(Tabs)
274+
pane = self._set_id(pane, tabs.tab_count + 1)
275+
assert pane.id is not None
276+
pane.display = False
277+
return AwaitTabbedContent(
278+
tabs.add_tab(ContentTab(pane._title, pane.id), before=before, after=after),
279+
self.get_child_by_type(ContentSwitcher).mount(pane),
280+
)
281+
282+
def remove_pane(self, pane_id: str) -> AwaitTabbedContent:
283+
"""Remove a given pane from the tabbed content.
284+
285+
Args:
286+
pane_id: The ID of the pane to remove.
287+
288+
Returns:
289+
An awaitable object that waits for the pane to be removed.
290+
"""
291+
removals = [self.get_child_by_type(Tabs).remove_tab(pane_id)]
292+
try:
293+
removals.append(
294+
self.get_child_by_type(ContentSwitcher)
295+
.get_child_by_id(pane_id)
296+
.remove()
297+
)
298+
except NoMatches:
299+
# It's possible that the content itself may have gone away via
300+
# other means; so allow that to be a no-op.
301+
pass
302+
await_remove = AwaitTabbedContent(*removals)
303+
304+
async def _remove_content(cleared_message: TabbedContent.Cleared) -> None:
305+
await await_remove
306+
if self.tab_count == 0:
307+
self.post_message(cleared_message)
308+
309+
# Note that I create the message out here, rather than in
310+
# _remove_content, to ensure that the message's internal
311+
# understanding of who the sender is is correct.
312+
#
313+
# https://github.com/Textualize/textual/issues/2750
314+
self.call_after_refresh(_remove_content, self.Cleared(self))
315+
316+
return await_remove
317+
318+
def clear_panes(self) -> AwaitTabbedContent:
319+
"""Remove all the panes in the tabbed content."""
320+
await_clear = AwaitTabbedContent(
321+
self.get_child_by_type(Tabs).clear(),
322+
self.get_child_by_type(ContentSwitcher).remove_children(),
323+
)
324+
325+
async def _clear_content(cleared_message: TabbedContent.Cleared) -> None:
326+
await await_clear
327+
self.post_message(cleared_message)
328+
329+
# Note that I create the message out here, rather than in
330+
# _clear_content, to ensure that the message's internal
331+
# understanding of who the sender is is correct.
332+
#
333+
# https://github.com/Textualize/textual/issues/2750
334+
self.call_after_refresh(_clear_content, self.Cleared(self))
335+
336+
return await_clear
337+
200338
def compose_add_child(self, widget: Widget) -> None:
201339
"""When using the context manager compose syntax, we want to attach nodes to the switcher.
202340
@@ -207,9 +345,10 @@ def compose_add_child(self, widget: Widget) -> None:
207345

208346
def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
209347
"""User clicked a tab."""
348+
assert isinstance(event.tab, ContentTab)
349+
assert isinstance(event.tab.id, str)
210350
event.stop()
211351
switcher = self.get_child_by_type(ContentSwitcher)
212-
assert isinstance(event.tab, ContentTab)
213352
switcher.current = event.tab.id
214353
self.active = event.tab.id
215354
self.post_message(
@@ -222,9 +361,16 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
222361
def _on_tabs_cleared(self, event: Tabs.Cleared) -> None:
223362
"""All tabs were removed."""
224363
event.stop()
364+
self.get_child_by_type(ContentSwitcher).current = None
365+
self.active = ""
225366

226-
def watch_active(self, active: str) -> None:
367+
def _watch_active(self, active: str) -> None:
227368
"""Switch tabs when the active attributes changes."""
228369
with self.prevent(Tabs.TabActivated):
229370
self.get_child_by_type(Tabs).active = active
230371
self.get_child_by_type(ContentSwitcher).current = active
372+
373+
@property
374+
def tab_count(self) -> int:
375+
"""Total number of tabs."""
376+
return self.get_child_by_type(Tabs).tab_count

src/textual/widgets/_tabs.py

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

3-
from typing import TYPE_CHECKING, ClassVar
3+
from typing import ClassVar
44

55
import rich.repr
66
from rich.style import Style
@@ -46,6 +46,8 @@ class Underline(Widget):
4646
"""First cell in highlight."""
4747
highlight_end = reactive(0)
4848
"""Last cell (inclusive) in highlight."""
49+
show_highlight: reactive[bool] = reactive(True)
50+
"""Flag to indicate if a highlight should be shown at all."""
4951

5052
class Clicked(Message):
5153
"""Inform ancestors the underline was clicked."""
@@ -60,7 +62,11 @@ def __init__(self, offset: Offset) -> None:
6062
@property
6163
def _highlight_range(self) -> tuple[int, int]:
6264
"""Highlighted range for underline bar."""
63-
return (self.highlight_start, self.highlight_end)
65+
return (
66+
(self.highlight_start, self.highlight_end)
67+
if self.show_highlight
68+
else (0, 0)
69+
)
6470

6571
def render(self) -> RenderResult:
6672
"""Render the bar."""
@@ -504,9 +510,11 @@ def _highlight_active(self, animate: bool = True) -> None:
504510
try:
505511
active_tab = self.query_one(f"#tabs-list > Tab.-active")
506512
except NoMatches:
513+
underline.show_highlight = False
507514
underline.highlight_start = 0
508515
underline.highlight_end = 0
509516
else:
517+
underline.show_highlight = True
510518
tab_region = active_tab.virtual_region.shrink(active_tab.styles.gutter)
511519
start, end = tab_region.column_span
512520
if animate:

0 commit comments

Comments
 (0)