ai-station/.venv/lib/python3.12/site-packages/textual/widgets/_tabs.py

867 lines
26 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
import rich.repr
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import ComposeResult, RenderResult
from textual.await_complete import AwaitComplete
from textual.binding import Binding, BindingType
from textual.containers import Container, Horizontal, Vertical
from textual.content import Content, ContentText
from textual.css.query import NoMatches
from textual.events import Mount
from textual.geometry import Offset
from textual.message import Message
from textual.reactive import reactive
from textual.renderables.bar import Bar
from textual.visual import VisualType
from textual.widget import Widget
from textual.widgets import Static
class Underline(Widget):
"""The animated underline beneath tabs."""
DEFAULT_CSS = """
Underline {
width: 1fr;
height: 1;
& > .underline--bar {
color: $block-cursor-background;
background: $foreground 10%;
}
&:ansi {
text-style: dim;
}
}
"""
COMPONENT_CLASSES = {"underline--bar"}
"""
| Class | Description |
| :- | :- |
| `underline--bar` | Style of the bar (may be used to change the color). |
"""
highlight_start = reactive(0)
"""First cell in highlight."""
highlight_end = reactive(0)
"""Last cell (inclusive) in highlight."""
show_highlight: reactive[bool] = reactive(True)
"""Flag to indicate if a highlight should be shown at all."""
class Clicked(Message):
"""Inform ancestors the underline was clicked."""
offset: Offset
"""The offset of the click, relative to the origin of the bar."""
def __init__(self, offset: Offset) -> None:
self.offset = offset
super().__init__()
@property
def _highlight_range(self) -> tuple[int, int]:
"""Highlighted range for underline bar."""
return (
(self.highlight_start, self.highlight_end)
if self.show_highlight
else (0, 0)
)
def render(self) -> RenderResult:
"""Render the bar."""
bar_style = self.get_component_rich_style("underline--bar")
return Bar(
highlight_range=self._highlight_range,
highlight_style=Style.from_color(bar_style.color),
background_style=Style.from_color(bar_style.bgcolor),
)
def _on_click(self, event: events.Click):
"""Catch clicks, so that the underline can activate the tabs."""
event.stop()
self.post_message(self.Clicked(event.screen_offset))
class Tab(Static):
"""A Widget to manage a single tab within a Tabs widget."""
DEFAULT_CSS = """
Tab {
width: auto;
height: 1;
padding: 0 1;
text-align: center;
color: $foreground 50%;
&:hover {
color: $foreground;
}
&:disabled {
color: $foreground 25%;
}
&.-active {
color: $foreground;
}
&.-hidden {
display: none;
}
}
"""
@dataclass
class TabMessage(Message):
"""Tab-related messages.
These are mostly intended for internal use when interacting with `Tabs`.
"""
tab: Tab
"""The tab that is the object of this message."""
@property
def control(self) -> Tab:
"""The tab that is the object of this message.
This is an alias for the attribute `tab` and is used by the
[`on`][textual.on] decorator.
"""
return self.tab
class Clicked(TabMessage):
"""A tab was clicked."""
class Disabled(TabMessage):
"""A tab was disabled."""
class Enabled(TabMessage):
"""A tab was enabled."""
class Relabelled(TabMessage):
"""A tab was relabelled."""
def __init__(
self,
label: ContentText,
*,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialise a Tab.
Args:
label: The label to use in the tab.
id: Optional ID for the widget.
classes: Space separated list of class names.
disabled: Whether the tab is disabled or not.
"""
super().__init__(id=id, classes=classes, disabled=disabled)
self._label: Content
# Setter takes Text or str
self.label = Content.from_text(label)
@property
def label(self) -> Content:
"""The label for the tab."""
return self._label
@label.setter
def label(self, label: ContentText) -> None:
self._label = Content.from_text(label)
self.update(self._label)
def update(self, content: VisualType = "") -> None:
self.post_message(self.Relabelled(self))
return super().update(content)
@property
def label_text(self) -> str:
"""Undecorated text of the label."""
return self.label.plain
def _on_click(self):
"""Inform the message that the tab was clicked."""
self.post_message(self.Clicked(self))
def _watch_disabled(self, disabled: bool) -> None:
"""Notify the parent `Tabs` that a tab was enabled/disabled."""
self.post_message(self.Disabled(self) if disabled else self.Enabled(self))
class Tabs(Widget, can_focus=True):
"""A row of tabs."""
DEFAULT_CSS = """
Tabs {
width: 100%;
height: 2;
&:focus {
.underline--bar {
background: $foreground 30%;
}
& .-active {
text-style: $block-cursor-text-style;
color: $block-cursor-foreground;
background: $block-cursor-background;
}
}
& > #tabs-scroll {
overflow: hidden;
}
#tabs-list {
width: auto;
}
#tabs-list-bar, #tabs-list {
width: auto;
height: auto;
min-width: 100%;
overflow: hidden hidden;
}
&:ansi {
#tabs-list {
text-style: dim;
}
& #tabs-list > .-active {
text-style: not dim;
}
&:focus {
#tabs-list > .-active {
text-style: bold not dim;
}
}
& .underline--bar {
color: ansi_bright_blue;
background: ansi_default;
}
& .-active {
color: transparent;
background: transparent;
}
}
}
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("left", "previous_tab", "Previous tab", show=False),
Binding("right", "next_tab", "Next tab", show=False),
]
"""
| Key(s) | Description |
| :- | :- |
| left | Move to the previous tab. |
| right | Move to the next tab. |
"""
class TabError(Exception):
"""Exception raised when there is an error relating to tabs."""
class TabMessage(Message):
"""Parent class for all messages that have to do with a specific tab."""
ALLOW_SELECTOR_MATCH = {"tab"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
def __init__(self, tabs: Tabs, tab: Tab) -> None:
"""Initialize event.
Args:
tabs: The Tabs widget.
tab: The tab that is the object of this message.
"""
self.tabs: Tabs = tabs
"""The tabs widget containing the tab."""
self.tab: Tab = tab
"""The tab that is the object of this message."""
super().__init__()
@property
def control(self) -> Tabs:
"""The tabs widget containing the tab that is the object of this message.
This is an alias for the attribute `tabs` and is used by the
[`on`][textual.on] decorator.
"""
return self.tabs
def __rich_repr__(self) -> rich.repr.Result:
yield self.tabs
yield self.tab
class TabActivated(TabMessage):
"""Sent when a new tab is activated."""
class TabDisabled(TabMessage):
"""Sent when a tab is disabled."""
class TabEnabled(TabMessage):
"""Sent when a tab is enabled."""
class TabHidden(TabMessage):
"""Sent when a tab is hidden."""
class TabShown(TabMessage):
"""Sent when a tab is shown."""
class Cleared(Message):
"""Sent when there are no active tabs.
This can occur when Tabs are cleared, if all tabs are hidden, or if the
currently active tab is unset.
"""
def __init__(self, tabs: Tabs) -> None:
"""Initialize the event.
Args:
tabs: The tabs widget.
"""
self.tabs: Tabs = tabs
"""The tabs widget which was cleared."""
super().__init__()
@property
def control(self) -> Tabs:
"""The tabs widget which was cleared.
This is an alias for [`Cleared.tabs`][textual.widgets.Tabs.Cleared] which
is used by the [`on`][textual.on] decorator.
"""
return self.tabs
def __rich_repr__(self) -> rich.repr.Result:
yield self.tabs
active: reactive[str] = reactive("", init=False)
"""The ID of the active tab, or empty string if none are active."""
def __init__(
self,
*tabs: Tab | ContentText,
active: str | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
):
"""Construct a Tabs widget.
Args:
*tabs: Positional argument should be explicit Tab objects, or a str or Text.
active: ID of the tab which should be active on start.
name: Optional name for the tabs widget.
id: Optional ID for the widget.
classes: Optional initial classes for the widget.
disabled: Whether the widget is disabled or not.
"""
self._tabs_counter = 0
add_tabs = [
(
Tab(tab, id=f"tab-{self._new_tab_id}")
if isinstance(tab, (str, Content, Text))
else self._auto_tab_id(tab)
)
for tab in tabs
]
super().__init__(
name=name,
id=id,
classes=classes,
disabled=disabled,
)
self._tabs = add_tabs
self._first_active = active
def _auto_tab_id(self, tab: Tab) -> Tab:
"""Set an automatic ID if not supplied."""
if tab.id is None:
tab.id = f"tab-{self._new_tab_id}"
return tab
@property
def _new_tab_id(self) -> int:
"""Get the next tab id in a sequence."""
self._tabs_counter += 1
return self._tabs_counter
@property
def tab_count(self) -> int:
"""Total number of tabs."""
return len(self.query("#tabs-list > Tab"))
@property
def _potentially_active_tabs(self) -> list[Tab]:
"""List of all tabs that could be active.
This list is comprised of all tabs that are shown and enabled,
plus the active tab in case it is disabled.
"""
return [
tab
for tab in self.query("#tabs-list > Tab").results(Tab)
if ((not tab.disabled or tab is self.active_tab) and tab.display)
]
@property
def _next_active(self) -> Tab | None:
"""Next tab to make active if the active tab is removed."""
tabs = self._potentially_active_tabs
if self.active_tab is None:
return None
try:
active_index = tabs.index(self.active_tab)
except ValueError:
return None
del tabs[active_index]
try:
return tabs[active_index]
except IndexError:
try:
return tabs[active_index - 1]
except IndexError:
pass
return None
def add_tab(
self,
tab: Tab | ContentText,
*,
before: Tab | str | None = None,
after: Tab | str | None = None,
) -> AwaitComplete:
"""Add a new tab to the end of the tab list.
Args:
tab: A new tab object, or a label (str or Text).
before: Optional tab or tab ID to add the tab before.
after: Optional tab or tab ID to add the tab after.
Returns:
An optionally awaitable object that waits for the tab to be mounted and
internal state to be fully updated to reflect the new tab.
Raises:
Tabs.TabError: If there is a problem with the addition request.
Note:
Only one of `before` or `after` can be provided. If both are
provided a `Tabs.TabError` will be raised.
"""
if before and after:
raise self.TabError("Unable to add a tab both before and after a tab")
if isinstance(before, str):
try:
before = self.query_one(f"#tabs-list > #{before}", Tab)
except NoMatches:
raise self.TabError(
f"There is no tab with ID '{before}' to mount before"
)
elif isinstance(before, Tab) and self not in before.ancestors:
raise self.TabError(
"Request to add a tab before a tab that isn't part of this tab collection"
)
if isinstance(after, str):
try:
after = self.query_one(f"#tabs-list > #{after}", Tab)
except NoMatches:
raise self.TabError(f"There is no tab with ID '{after}' to mount after")
elif isinstance(after, Tab) and self not in after.ancestors:
raise self.TabError(
"Request to add a tab after a tab that isn't part of this tab collection"
)
from_empty = self.tab_count == 0
tab_widget = (
Tab(tab, id=f"tab-{self._new_tab_id}")
if isinstance(tab, (str, Content, Text))
else self._auto_tab_id(tab)
)
mount_await = self.query_one("#tabs-list").mount(
tab_widget, before=before, after=after
)
if from_empty:
tab_widget.add_class("-active")
activated_message = self.TabActivated(self, tab_widget)
async def refresh_active() -> None:
"""Wait for things to be mounted before highlighting."""
await mount_await
self.active = tab_widget.id or ""
self._highlight_active(animate=False)
self.post_message(activated_message)
return AwaitComplete(refresh_active())
elif before or after:
async def refresh_active() -> None:
await mount_await
self._highlight_active(animate=False)
return AwaitComplete(refresh_active())
return AwaitComplete(mount_await())
def clear(self) -> AwaitComplete:
"""Clear all the tabs.
Returns:
An awaitable object that waits for the tabs to be removed.
"""
underline = self.query_one(Underline)
underline.highlight_start = 0
underline.highlight_end = 0
self.post_message(self.Cleared(self))
self.active = ""
return AwaitComplete(self.query("#tabs-list > Tab").remove())
def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete:
"""Remove a tab.
Args:
tab_or_id: The Tab to remove or its id.
Returns:
An optionally awaitable object that waits for the tab to be removed.
"""
if not tab_or_id:
return AwaitComplete()
if isinstance(tab_or_id, Tab):
remove_tab = tab_or_id
else:
try:
remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab)
except NoMatches:
return AwaitComplete()
if remove_tab.has_class("-active"):
next_tab = self._next_active
else:
next_tab = None
async def do_remove() -> None:
"""Perform the remove after refresh so the underline bar gets new positions."""
await remove_tab.remove()
if not self.query("#tabs-list > Tab"):
self.active = ""
elif next_tab is not None:
self.active = next_tab.id or ""
else:
self._highlight_active(animate=False)
return AwaitComplete(do_remove())
def validate_active(self, active: str) -> str:
"""Check id assigned to active attribute is a valid tab."""
if active and not self.query(f"#tabs-list > #{active}"):
raise ValueError(f"No Tab with id {active!r}")
return active
@property
def active_tab(self) -> Tab | None:
"""The currently active tab, or None if there are no active tabs."""
try:
return self.query_one("#tabs-list Tab.-active", Tab)
except NoMatches:
return None
def _on_mount(self, _: Mount) -> None:
"""Make the first tab active."""
if self._first_active is not None:
self.active = self._first_active
if not self.active:
try:
tab = self.query("#tabs-list > Tab").first(Tab)
except NoMatches:
# Tabs are empty!
return
self.active = tab.id or ""
def compose(self) -> ComposeResult:
with Container(id="tabs-scroll"):
with Vertical(id="tabs-list-bar"):
with Horizontal(id="tabs-list"):
yield from self._tabs
yield Underline()
def watch_active(self, previously_active: str, active: str) -> None:
"""Handle a change to the active tab."""
self.query("#tabs-list > Tab.-active").remove_class("-active")
if active:
try:
active_tab = self.query_one(f"#tabs-list > #{active}", Tab)
except NoMatches:
return
active_tab.add_class("-active")
self._highlight_active(animate=previously_active != "")
self._scroll_active_tab()
self.post_message(self.TabActivated(self, active_tab))
else:
underline = self.query_one(Underline)
underline.highlight_start = 0
underline.highlight_end = 0
self.post_message(self.Cleared(self))
def _highlight_active(
self,
animate: bool = True,
) -> None:
"""Move the underline bar to under the active tab.
Args:
animate: Should the bar animate?
"""
underline = self.query_one(Underline)
try:
_active_tab = self.query_one("#tabs-list > Tab.-active")
except NoMatches:
underline.show_highlight = False
underline.highlight_start = 0
underline.highlight_end = 0
else:
underline.show_highlight = True
def move_underline(animate: bool) -> None:
"""Move the tab underline.
Args:
animate: animate the underline to its new position.
"""
try:
active_tab = self.query_one("#tabs-list > Tab.-active")
except NoMatches:
pass
else:
tab_region = active_tab.virtual_region.shrink(
active_tab.styles.gutter
)
start, end = tab_region.column_span
if animate:
underline.animate(
"highlight_start",
start,
duration=0.3,
level="basic",
)
underline.animate(
"highlight_end",
end,
duration=0.3,
level="basic",
)
else:
underline.highlight_start = start
underline.highlight_end = end
if animate and self.app.animation_level != "none":
self.set_timer(
0.02,
lambda: self.call_after_refresh(move_underline, True),
)
else:
self.call_after_refresh(move_underline, False)
async def _on_tab_clicked(self, event: Tab.Clicked) -> None:
"""Activate a tab that was clicked."""
self.focus()
event.stop()
self._activate_tab(event.tab)
def _activate_tab(self, tab: Tab) -> None:
"""Activate a tab.
Args:
tab: The Tab that was clicked.
"""
self.query("#tabs-list Tab.-active").remove_class("-active")
tab.add_class("-active")
self.active = tab.id or ""
def _on_underline_clicked(self, event: Underline.Clicked) -> None:
"""The underline was clicked.
Activate the tab above to make a larger clickable area.
Args:
event: The Underline.Clicked event.
"""
event.stop()
offset = event.offset + (0, -1)
self.focus()
for tab in self.query(Tab):
if offset in tab.region and not tab.disabled:
self._activate_tab(tab)
break
def _scroll_active_tab(self) -> None:
"""Scroll the active tab into view."""
if self.active_tab:
try:
self.query_one("#tabs-scroll").scroll_to_center(
self.active_tab, force=True
)
except NoMatches:
pass
def _on_resize(self):
"""Make the active tab visible on resize."""
self._highlight_active(animate=False)
self._scroll_active_tab()
def action_next_tab(self) -> None:
"""Make the next tab active."""
self._move_tab(+1)
def action_previous_tab(self) -> None:
"""Make the previous tab active."""
self._move_tab(-1)
def _move_tab(self, direction: int) -> None:
"""Activate the next enabled tab in the given direction.
Tab selection wraps around. If no tab is currently active, the "next"
tab is set to be the first and the "previous" tab is the last one.
Args:
direction: +1 for the next tab, -1 for the previous.
"""
active_tab = self.active_tab
tabs = self._potentially_active_tabs
if not tabs:
return
if not active_tab:
self.active = tabs[0 if direction == 1 else -1].id or ""
return
tab_count = len(tabs)
new_tab_index = (tabs.index(active_tab) + direction) % tab_count
self.active = tabs[new_tab_index].id or ""
def _on_tab_disabled(self, event: Tab.Disabled) -> None:
"""Re-post the disabled message."""
event.stop()
self.post_message(self.TabDisabled(self, event.tab))
def _on_tab_enabled(self, event: Tab.Enabled) -> None:
"""Re-post the enabled message."""
event.stop()
self.post_message(self.TabEnabled(self, event.tab))
def _on_tab_relabelled(self, event: Tab.Relabelled) -> None:
"""Redraw the highlight when tab is relabelled."""
event.stop()
self._highlight_active()
def disable(self, tab_id: str) -> Tab:
"""Disable the indicated tab.
Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to disable.
Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.
Raises:
TabError: If there are any issues with the request.
"""
try:
tab_to_disable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
except NoMatches:
raise self.TabError(
f"There is no tab with ID {tab_id!r} to disable."
) from None
tab_to_disable.disabled = True
return tab_to_disable
def enable(self, tab_id: str) -> Tab:
"""Enable the indicated tab.
Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to enable.
Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.
Raises:
TabError: If there are any issues with the request.
"""
try:
tab_to_enable = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
except NoMatches:
raise self.TabError(
f"There is no tab with ID {tab_id!r} to enable."
) from None
tab_to_enable.disabled = False
return tab_to_enable
def hide(self, tab_id: str) -> Tab:
"""Hide the indicated tab.
Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to hide.
Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.
Raises:
TabError: If there are any issues with the request.
"""
try:
tab_to_hide = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
except NoMatches:
raise self.TabError(f"There is no tab with ID {tab_id!r} to hide.")
if tab_to_hide.has_class("-active"):
next_tab = self._next_active
self.active = next_tab.id or "" if next_tab else ""
tab_to_hide.add_class("-hidden")
self.post_message(self.TabHidden(self, tab_to_hide).set_sender(self))
self.call_after_refresh(self._highlight_active)
return tab_to_hide
def show(self, tab_id: str) -> Tab:
"""Show the indicated tab.
Args:
tab_id: The ID of the [`Tab`][textual.widgets.Tab] to show.
Returns:
The [`Tab`][textual.widgets.Tab] that was targeted.
Raises:
TabError: If there are any issues with the request.
"""
try:
tab_to_show = self.query_one(f"#tabs-list > Tab#{tab_id}", Tab)
except NoMatches:
raise self.TabError(f"There is no tab with ID {tab_id!r} to show.")
tab_to_show.remove_class("-hidden")
self.post_message(self.TabShown(self, tab_to_show).set_sender(self))
if not self.active:
self._activate_tab(tab_to_show)
self.call_after_refresh(self._highlight_active)
return tab_to_show