mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
1
docs/reference/tabs.md
Normal file
1
docs/reference/tabs.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.tabs.Tabs
|
||||
@@ -1,33 +0,0 @@
|
||||
from rich.console import RenderableType
|
||||
from rich.panel import Panel
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class PanelWidget(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Panel("hello world!", title="Title")
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""Sandbox application used for testing/development by Textual developers"""
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("tab", "toggle_class('#sidebar', '-active')")
|
||||
self.bind("a", "toggle_class('#header', '-visible')")
|
||||
self.bind("c", "toggle_class('#content', '-content-visible')")
|
||||
self.bind("d", "toggle_class('#footer', 'dim')")
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
header=Widget(),
|
||||
content=PanelWidget(),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(),
|
||||
)
|
||||
|
||||
|
||||
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log")
|
||||
@@ -1,63 +0,0 @@
|
||||
/* CSS file for dev_sandbox.py */
|
||||
|
||||
$text: #f0f0f0;
|
||||
$primary: #021720;
|
||||
$secondary:#95d52a;
|
||||
$background: #262626;
|
||||
|
||||
$primary-style: $text on $background;
|
||||
$animation-speed: 500ms;
|
||||
$animation: offset $animation-speed in_out_cubic;
|
||||
|
||||
App > View {
|
||||
docks: side=left/1;
|
||||
text: on $background;
|
||||
}
|
||||
|
||||
Widget:hover {
|
||||
outline: heavy;
|
||||
text: bold !important;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
text: $primary-style;
|
||||
dock: side;
|
||||
width: 30;
|
||||
offset-x: -100%;
|
||||
transition: $animation;
|
||||
border-right: outer $secondary;
|
||||
}
|
||||
|
||||
#sidebar.-active {
|
||||
offset-x: 0;
|
||||
}
|
||||
|
||||
#header {
|
||||
text: $text on $primary;
|
||||
height: 3;
|
||||
border-bottom: hkey $secondary;
|
||||
}
|
||||
|
||||
#header.-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#content {
|
||||
text: $text on $background;
|
||||
offset-y: -3;
|
||||
}
|
||||
|
||||
#content.-content-visible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#footer {
|
||||
opacity: 1;
|
||||
text: $text on $primary;
|
||||
height: 3;
|
||||
border-top: hkey $secondary;
|
||||
}
|
||||
|
||||
#footer.dim {
|
||||
opacity: 0.5;
|
||||
}
|
||||
147
sandbox/tabs.py
Normal file
147
sandbox/tabs.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets.tabs import Tabs, Tab
|
||||
|
||||
|
||||
class Hr(Widget):
|
||||
def render(self) -> RenderableType:
|
||||
return Rule()
|
||||
|
||||
|
||||
class Info(Widget):
|
||||
DEFAULT_STYLES = "height: 2;"
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
super().__init__()
|
||||
self.text = text
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return Padding(f"{self.text}", pad=(0, 1))
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetDescription:
|
||||
description: str
|
||||
widget: Widget
|
||||
|
||||
|
||||
class BasicApp(App):
|
||||
"""Sandbox application used for testing/development by Textual developers"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.keys_to_tabs = {
|
||||
"1": Tab("January", name="one"),
|
||||
"2": Tab("に月", name="two"),
|
||||
"3": Tab("March", name="three"),
|
||||
"4": Tab("April", name="four"),
|
||||
"5": Tab("May", name="five"),
|
||||
"6": Tab("And a really long tab!", name="six"),
|
||||
}
|
||||
tabs = list(self.keys_to_tabs.values())
|
||||
self.examples = [
|
||||
WidgetDescription(
|
||||
"Customise the spacing between tabs, e.g. tab_padding=1",
|
||||
Tabs(
|
||||
tabs,
|
||||
tab_padding=1,
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Change the opacity of inactive tab text, e.g. inactive_text_opacity=.2",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="two",
|
||||
active_bar_style="#1493FF",
|
||||
inactive_text_opacity=0.2,
|
||||
tab_padding=2,
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Change the color of the inactive portions of the underline, e.g. inactive_bar_style='blue'",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="four",
|
||||
inactive_bar_style="blue",
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Change the color of the active portion of the underline, e.g. active_bar_style='red'",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="five",
|
||||
active_bar_style="red",
|
||||
inactive_text_opacity=1,
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Change the styling of active and inactive labels (active_tab_style, inactive_tab_style)",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="one",
|
||||
active_bar_style="#DA812D",
|
||||
active_tab_style="bold #FFCB4D on #021720",
|
||||
inactive_tab_style="italic #887AEF on #021720",
|
||||
inactive_bar_style="#695CC8",
|
||||
inactive_text_opacity=0.6,
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Change the animation duration and function (animation_duration=1, animation_function='out_quad')",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="one",
|
||||
active_bar_style="#887AEF",
|
||||
inactive_text_opacity=0.2,
|
||||
animation_duration=1,
|
||||
animation_function="out_quad",
|
||||
),
|
||||
),
|
||||
WidgetDescription(
|
||||
"Choose which tab to start on by name, e.g. active_tab='three'",
|
||||
Tabs(
|
||||
tabs,
|
||||
active_tab="three",
|
||||
active_bar_style="#FFCB4D",
|
||||
tab_padding=3,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("tab", "toggle_class('#sidebar', '-active')")
|
||||
self.bind("a", "toggle_class('#header', '-visible')")
|
||||
self.bind("c", "toggle_class('#content', '-content-visible')")
|
||||
self.bind("d", "toggle_class('#footer', 'dim')")
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
for example in self.examples:
|
||||
tab = self.keys_to_tabs.get(event.key)
|
||||
if tab:
|
||||
example.widget._active_tab_name = tab.name
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
self.mount(
|
||||
info=Info(
|
||||
"\n"
|
||||
"• The examples below show customisation options for the [bold #1493FF]Tabs[/] widget.\n"
|
||||
"• Press keys 1-6 on your keyboard to switch tabs, or click on a tab.",
|
||||
)
|
||||
)
|
||||
for example in self.examples:
|
||||
info = Info(example.description)
|
||||
self.mount(Hr())
|
||||
self.mount(info)
|
||||
self.mount(example.widget)
|
||||
|
||||
|
||||
BasicApp.run(css_file="tabs.scss", watch_css=True, log="textual.log")
|
||||
9
sandbox/tabs.scss
Normal file
9
sandbox/tabs.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
$background: #021720;
|
||||
|
||||
App > View {
|
||||
text: on $background;
|
||||
}
|
||||
|
||||
#info {
|
||||
height: 4;
|
||||
}
|
||||
@@ -24,5 +24,4 @@ def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArg
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
print(extract_handler_actions("mouse.down", {"@mouse.down.hot": "app.bell()"}))
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from fractions import Fraction
|
||||
from typing import cast, List, Optional, Sequence
|
||||
from typing import cast, List, Sequence, NamedTuple
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Protocol
|
||||
@@ -10,15 +10,21 @@ else:
|
||||
from typing_extensions import Protocol # pragma: no cover
|
||||
|
||||
|
||||
class Edge(Protocol):
|
||||
class EdgeProtocol(Protocol):
|
||||
"""Any object that defines an edge (such as Layout)."""
|
||||
|
||||
size: Optional[int] = None
|
||||
fraction: int = 1
|
||||
size: int | None
|
||||
min_size: int
|
||||
fraction: int | None
|
||||
|
||||
|
||||
class Edge(NamedTuple):
|
||||
size: int | None = None
|
||||
min_size: int = 1
|
||||
fraction: int | None = 1
|
||||
|
||||
|
||||
def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]:
|
||||
def layout_resolve(total: int, edges: Sequence[EdgeProtocol]) -> List[int]:
|
||||
"""Divide total space to satisfy size, fraction, and min_size, constraints.
|
||||
|
||||
The returned list of integers should add up to total in most cases, unless it is
|
||||
@@ -29,7 +35,7 @@ def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]:
|
||||
|
||||
Args:
|
||||
total (int): Total number of characters.
|
||||
edges (List[Edge]): Edges within total space.
|
||||
edges (List[EdgeProtocol]): Edges within total space.
|
||||
|
||||
Returns:
|
||||
List[int]: Number of characters for each edge.
|
||||
|
||||
@@ -33,11 +33,12 @@ class Opacity:
|
||||
fg = style.color
|
||||
bg = style.bgcolor
|
||||
if fg and fg.triplet and bg and bg.triplet:
|
||||
color_style = _get_blended_style_cached(
|
||||
fg_color=fg, bg_color=bg, opacity=opacity
|
||||
)
|
||||
yield Segment(
|
||||
segment.text,
|
||||
_get_blended_style_cached(
|
||||
fg_color=fg, bg_color=bg, opacity=opacity
|
||||
),
|
||||
style + color_style,
|
||||
segment.control,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import ConsoleOptions, Console, RenderResult
|
||||
from rich.segment import Segment
|
||||
from rich.style import StyleType
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
class UnderlineBar:
|
||||
@@ -20,11 +20,13 @@ class UnderlineBar:
|
||||
highlight_range: tuple[float, float] = (0, 0),
|
||||
highlight_style: StyleType = "magenta",
|
||||
background_style: StyleType = "grey37",
|
||||
clickable_ranges: dict[str, tuple[int, int]] | None = None,
|
||||
width: int | None = None,
|
||||
) -> None:
|
||||
self.highlight_range = highlight_range
|
||||
self.highlight_style = highlight_style
|
||||
self.background_style = background_style
|
||||
self.clickable_ranges = clickable_ranges or {}
|
||||
self.width = width
|
||||
|
||||
def __rich_console__(
|
||||
@@ -43,8 +45,11 @@ class UnderlineBar:
|
||||
start = max(start, 0)
|
||||
end = min(end, width)
|
||||
|
||||
output_bar = Text("", end="")
|
||||
|
||||
if start == end == 0 or end < 0 or start > end:
|
||||
yield Segment(bar * width, style=background_style)
|
||||
output_bar.append(Text(bar * width, style=background_style, end=""))
|
||||
yield output_bar
|
||||
return
|
||||
|
||||
# Round start and end to nearest half
|
||||
@@ -56,23 +61,39 @@ class UnderlineBar:
|
||||
half_end = end - int(end) > 0
|
||||
|
||||
# Initial non-highlighted portion of bar
|
||||
yield Segment(bar * (int(start - 0.5)), style=background_style)
|
||||
output_bar.append(
|
||||
Text(bar * (int(start - 0.5)), style=background_style, end="")
|
||||
)
|
||||
if not half_start and start > 0:
|
||||
yield Segment(half_bar_right, style=background_style)
|
||||
output_bar.append(Text(half_bar_right, style=background_style, end=""))
|
||||
|
||||
# The highlighted portion
|
||||
bar_width = int(end) - int(start)
|
||||
if half_start:
|
||||
yield Segment(half_bar_left + bar * (bar_width - 1), style=highlight_style)
|
||||
output_bar.append(
|
||||
Text(
|
||||
half_bar_left + bar * (bar_width - 1), style=highlight_style, end=""
|
||||
)
|
||||
)
|
||||
else:
|
||||
yield Segment(bar * bar_width, style=highlight_style)
|
||||
output_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
|
||||
if half_end:
|
||||
yield Segment(half_bar_right, style=highlight_style)
|
||||
output_bar.append(Text(half_bar_right, style=highlight_style, end=""))
|
||||
|
||||
# The non-highlighted tail
|
||||
if not half_end and end - width != 0:
|
||||
yield Segment(half_bar_left, style=background_style)
|
||||
yield Segment(bar * (int(width) - int(end) - 1), style=background_style)
|
||||
output_bar.append(Text(half_bar_left, style=background_style, end=""))
|
||||
output_bar.append(
|
||||
Text(bar * (int(width) - int(end) - 1), style=background_style, end="")
|
||||
)
|
||||
|
||||
# Fire actions when certain ranges are clicked (e.g. for tabs)
|
||||
for range_name, (start, end) in self.clickable_ranges.items():
|
||||
output_bar.apply_meta(
|
||||
{"@click": f"range_clicked('{range_name}')"}, start, end
|
||||
)
|
||||
|
||||
yield output_bar
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -102,17 +123,13 @@ if __name__ == "__main__":
|
||||
for range in ranges:
|
||||
color = random.choice(list(ANSI_COLOR_NAMES.keys()))
|
||||
console.print(
|
||||
UnderlineBar(
|
||||
range,
|
||||
highlight_style=color,
|
||||
width=20,
|
||||
),
|
||||
UnderlineBar(range, highlight_style=color, width=20),
|
||||
f" {range}",
|
||||
)
|
||||
|
||||
from rich.live import Live
|
||||
|
||||
bar = UnderlineBar(width=80, highlight_range=(0, 4.5))
|
||||
bar = UnderlineBar(highlight_range=(0, 4.5), width=80)
|
||||
with Live(bar, refresh_per_second=60) as live:
|
||||
while True:
|
||||
bar.highlight_range = (
|
||||
|
||||
@@ -3,16 +3,15 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
|
||||
from rich.console import Console, ConsoleOptions, RenderableType
|
||||
from rich.console import RenderableType
|
||||
from rich.panel import Panel
|
||||
from rich.repr import rich_repr, Result
|
||||
from rich.repr import Result
|
||||
from rich.style import StyleType
|
||||
from rich.table import Table
|
||||
from rich.text import TextType
|
||||
|
||||
from .. import events
|
||||
from ..widget import Widget
|
||||
from ..reactive import watch, Reactive
|
||||
from ..widget import Widget
|
||||
|
||||
log = getLogger("rich")
|
||||
|
||||
|
||||
344
src/textual/widgets/tabs.py
Normal file
344
src/textual/widgets/tabs.py
Normal file
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
||||
from rich.segment import Segment
|
||||
from rich.style import StyleType, Style
|
||||
from rich.text import Text
|
||||
|
||||
from textual import events
|
||||
from textual._layout_resolve import layout_resolve, Edge
|
||||
from textual.keys import Keys
|
||||
from textual.reactive import Reactive
|
||||
from textual.renderables.opacity import Opacity
|
||||
from textual.renderables.underline_bar import UnderlineBar
|
||||
from textual.widget import Widget
|
||||
|
||||
__all__ = ["Tab", "Tabs"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tab:
|
||||
"""Data container representing a single tab.
|
||||
|
||||
Attributes:
|
||||
label (str): The user-facing label that will appear inside the tab.
|
||||
name (str, optional): A unique string key that will identify the tab. If None, it will default to the label.
|
||||
If the name is not unique within a single list of tabs, only the final Tab will be displayed.
|
||||
"""
|
||||
|
||||
label: str
|
||||
name: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.name is None:
|
||||
self.name = self.label
|
||||
|
||||
def __str__(self):
|
||||
return self.label
|
||||
|
||||
|
||||
class TabsRenderable:
|
||||
"""Renderable for the Tabs widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tabs: Iterable[Tab],
|
||||
*,
|
||||
active_tab_name: str,
|
||||
active_tab_style: StyleType,
|
||||
active_bar_style: StyleType,
|
||||
inactive_tab_style: StyleType,
|
||||
inactive_bar_style: StyleType,
|
||||
inactive_text_opacity: float,
|
||||
tab_padding: int | None,
|
||||
bar_offset: float,
|
||||
width: int | None = None,
|
||||
):
|
||||
self.tabs = {tab.name: tab for tab in tabs}
|
||||
|
||||
try:
|
||||
self.active_tab_name = active_tab_name or next(iter(self.tabs))
|
||||
except StopIteration:
|
||||
self.active_tab_name = None
|
||||
|
||||
self.active_tab_style = active_tab_style
|
||||
self.active_bar_style = active_bar_style
|
||||
|
||||
self.inactive_tab_style = inactive_tab_style
|
||||
self.inactive_bar_style = inactive_bar_style
|
||||
|
||||
self.bar_offset = bar_offset
|
||||
self.width = width
|
||||
self.tab_padding = tab_padding
|
||||
self.inactive_text_opacity = inactive_text_opacity
|
||||
|
||||
self._label_range_cache: dict[str, tuple[int, int]] = {}
|
||||
self._selection_range_cache: dict[str, tuple[int, int]] = {}
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
if self.tabs:
|
||||
yield from self.get_tab_labels(console, options)
|
||||
yield Segment.line()
|
||||
yield from self.get_underline_bar(console)
|
||||
|
||||
def get_tab_labels(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
||||
"""Yields the spaced-out labels that appear above the line for the Tabs widget"""
|
||||
width = self.width or options.max_width
|
||||
tab_values = self.tabs.values()
|
||||
|
||||
space = Edge(size=self.tab_padding or None, min_size=1, fraction=1)
|
||||
edges = []
|
||||
for tab in tab_values:
|
||||
tab = Edge(size=cell_len(tab.label), min_size=1, fraction=None)
|
||||
edges.extend([space, tab, space])
|
||||
|
||||
spacing = layout_resolve(width, edges=edges)
|
||||
|
||||
active_tab_style = console.get_style(self.active_tab_style)
|
||||
inactive_tab_style = console.get_style(self.inactive_tab_style)
|
||||
|
||||
label_cell_cursor = 0
|
||||
for tab_index, tab in enumerate(tab_values):
|
||||
tab_edge_index = tab_index * 3 + 1
|
||||
|
||||
len_label_text = spacing[tab_edge_index]
|
||||
lpad = spacing[tab_edge_index - 1]
|
||||
rpad = spacing[tab_edge_index + 1]
|
||||
|
||||
label_left_padding = Text(" " * lpad, end="")
|
||||
label_right_padding = Text(" " * rpad, end="")
|
||||
|
||||
padded_label = f"{label_left_padding}{tab.label}{label_right_padding}"
|
||||
if tab.name == self.active_tab_name:
|
||||
yield Text(padded_label, end="", style=active_tab_style)
|
||||
else:
|
||||
tab_content = Text(
|
||||
padded_label,
|
||||
end="",
|
||||
style=inactive_tab_style
|
||||
+ Style.from_meta({"@click": f"range_clicked('{tab.name}')"}),
|
||||
)
|
||||
dimmed_tab_content = Opacity(
|
||||
tab_content, opacity=self.inactive_text_opacity
|
||||
)
|
||||
segments = console.render(dimmed_tab_content)
|
||||
yield from segments
|
||||
|
||||
# Cache the position of the label text within this tab
|
||||
label_cell_cursor += lpad
|
||||
self._label_range_cache[tab.name] = (
|
||||
label_cell_cursor,
|
||||
label_cell_cursor + len_label_text,
|
||||
)
|
||||
label_cell_cursor += len_label_text + rpad
|
||||
|
||||
# Cache the position of the whole tab, i.e. the range that can be clicked
|
||||
self._selection_range_cache[tab.name] = (
|
||||
label_cell_cursor - lpad,
|
||||
label_cell_cursor + len_label_text + rpad,
|
||||
)
|
||||
|
||||
def get_underline_bar(self, console: Console) -> RenderResult:
|
||||
"""Yields the bar that appears below the tab labels in the Tabs widget"""
|
||||
if self.tabs:
|
||||
ranges = self._label_range_cache
|
||||
tab_index = int(self.bar_offset)
|
||||
next_tab_index = (tab_index + 1) % len(ranges)
|
||||
range_values = list(ranges.values())
|
||||
tab1_start, tab1_end = range_values[tab_index]
|
||||
tab2_start, tab2_end = range_values[next_tab_index]
|
||||
|
||||
bar_start = tab1_start + (tab2_start - tab1_start) * (
|
||||
self.bar_offset - tab_index
|
||||
)
|
||||
bar_end = tab1_end + (tab2_end - tab1_end) * (self.bar_offset - tab_index)
|
||||
else:
|
||||
bar_start = 0
|
||||
bar_end = 0
|
||||
underline = UnderlineBar(
|
||||
highlight_range=(bar_start, bar_end),
|
||||
highlight_style=self.active_bar_style,
|
||||
background_style=self.inactive_bar_style,
|
||||
clickable_ranges=self._selection_range_cache,
|
||||
)
|
||||
yield from console.render(underline)
|
||||
|
||||
|
||||
class Tabs(Widget):
|
||||
"""Widget which displays a set of horizontal tabs.
|
||||
|
||||
Args:
|
||||
tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered.
|
||||
active_tab (str, optional): The name of the tab that should be active on first render.
|
||||
active_tab_style (StyleType): Style to apply to the label of the active tab.
|
||||
active_bar_style (StyleType): Style to apply to the underline of the active tab.
|
||||
inactive_tab_style (StyleType): Style to apply to the label of inactive tabs.
|
||||
inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs.
|
||||
inactive_text_opacity (float): Opacity of the text labels of inactive tabs.
|
||||
animation_duration (float): The duration of the tab change animation, in seconds.
|
||||
animation_function (str): The easing function to use for the tab change animation.
|
||||
tab_padding (int, optional): The padding at the side of each tab. If None, tabs will
|
||||
automatically be padded such that they fit the available horizontal space.
|
||||
search_by_first_character (bool): If True, entering a character on your keyboard
|
||||
will activate the next tab (in left-to-right order) with a label starting with
|
||||
that character.
|
||||
"""
|
||||
|
||||
DEFAULT_STYLES = "height: 2;"
|
||||
|
||||
_active_tab_name: Reactive[str | None] = Reactive("")
|
||||
_bar_offset: Reactive[float] = Reactive(0.0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tabs: list[Tab],
|
||||
active_tab: str | None = None,
|
||||
active_tab_style: StyleType = "#f0f0f0 on #021720",
|
||||
active_bar_style: StyleType = "#1BB152",
|
||||
inactive_tab_style: StyleType = "#f0f0f0 on #021720",
|
||||
inactive_bar_style: StyleType = "#455058",
|
||||
inactive_text_opacity: float = 0.5,
|
||||
animation_duration: float = 0.3,
|
||||
animation_function: str = "out_cubic",
|
||||
tab_padding: int | None = None,
|
||||
search_by_first_character: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tabs = tabs
|
||||
|
||||
self._bar_offset = float(self.find_tab_by_name(active_tab) or 0)
|
||||
self._active_tab_name = active_tab or next(iter(self.tabs), None)
|
||||
|
||||
self.active_tab_style = active_tab_style
|
||||
self.active_bar_style = active_bar_style
|
||||
|
||||
self.inactive_bar_style = inactive_bar_style
|
||||
self.inactive_tab_style = inactive_tab_style
|
||||
self.inactive_text_opacity = inactive_text_opacity
|
||||
|
||||
self.animation_function = animation_function
|
||||
self.animation_duration = animation_duration
|
||||
|
||||
self.tab_padding = tab_padding
|
||||
|
||||
self.search_by_first_character = search_by_first_character
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
"""Handles key press events when this widget is in focus.
|
||||
Pressing "escape" removes focus from this widget. Use the left and
|
||||
right arrow keys to cycle through tabs. Use number keys to jump to tabs
|
||||
based in their number ("1" jumps to the leftmost tab). Type a character
|
||||
to cycle through tabs with labels beginning with that character.
|
||||
|
||||
Args:
|
||||
event (events.Key): The Key event being handled
|
||||
"""
|
||||
if not self.tabs:
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
if event.key == Keys.Escape:
|
||||
self.app.set_focus(None)
|
||||
elif event.key == Keys.Right:
|
||||
self.activate_next_tab()
|
||||
elif event.key == Keys.Left:
|
||||
self.activate_previous_tab()
|
||||
elif event.key in string.digits:
|
||||
self.activate_tab_by_number(int(event.key))
|
||||
elif self.search_by_first_character:
|
||||
self.activate_tab_by_first_char(event.key)
|
||||
|
||||
event.prevent_default()
|
||||
|
||||
def activate_next_tab(self) -> None:
|
||||
"""Activate the tab to the right of the currently active tab"""
|
||||
current_tab_index = self.find_tab_by_name(self._active_tab_name)
|
||||
next_tab_index = (current_tab_index + 1) % len(self.tabs)
|
||||
next_tab_name = self.tabs[next_tab_index].name
|
||||
self._active_tab_name = next_tab_name
|
||||
|
||||
def activate_previous_tab(self) -> None:
|
||||
"""Activate the tab to the left of the currently active tab"""
|
||||
current_tab_index = self.find_tab_by_name(self._active_tab_name)
|
||||
previous_tab_index = current_tab_index - 1
|
||||
previous_tab_name = self.tabs[previous_tab_index].name
|
||||
self._active_tab_name = previous_tab_name
|
||||
|
||||
def activate_tab_by_first_char(self, char: str) -> None:
|
||||
"""Activate the next tab that begins with the character
|
||||
|
||||
Args:
|
||||
char (str): The character to search for
|
||||
"""
|
||||
|
||||
def find_next_matching_tab(
|
||||
char: str, start: int | None, end: int | None
|
||||
) -> Tab | None:
|
||||
for tab in self.tabs[start:end]:
|
||||
if tab.label.lower().startswith(char.lower()):
|
||||
return tab
|
||||
|
||||
current_tab_index = self.find_tab_by_name(self._active_tab_name)
|
||||
next_tab_index = (current_tab_index + 1) % len(self.tabs)
|
||||
|
||||
next_matching_tab = find_next_matching_tab(char, next_tab_index, None)
|
||||
if not next_matching_tab:
|
||||
next_matching_tab = find_next_matching_tab(char, None, current_tab_index)
|
||||
|
||||
if next_matching_tab:
|
||||
self._active_tab_name = next_matching_tab.name
|
||||
|
||||
def activate_tab_by_number(self, tab_number: int) -> None:
|
||||
"""Activate a tab using the tab number.
|
||||
|
||||
Args:
|
||||
tab_number (int): The number of the tab.
|
||||
The leftmost tab is number 1, the next is 2, and so on. 0 represents the 10th tab.
|
||||
"""
|
||||
if tab_number > len(self.tabs):
|
||||
return
|
||||
if tab_number == 0 and len(self.tabs) >= 10:
|
||||
tab_number = 10
|
||||
self._active_tab_name = self.tabs[tab_number - 1].name
|
||||
|
||||
def action_range_clicked(self, target_tab_name: str) -> None:
|
||||
"""Handles 'range_clicked' actions which are fired when tabs are clicked"""
|
||||
self._active_tab_name = target_tab_name
|
||||
|
||||
def watch__active_tab_name(self, tab_name: str) -> None:
|
||||
"""Animates the underline bar position when the active tab changes"""
|
||||
target_tab_index = self.find_tab_by_name(tab_name)
|
||||
self.animate(
|
||||
"_bar_offset",
|
||||
float(target_tab_index),
|
||||
easing=self.animation_function,
|
||||
duration=self.animation_duration,
|
||||
)
|
||||
|
||||
def find_tab_by_name(self, tab_name: str) -> int:
|
||||
"""Return the index of the first tab with a certain name
|
||||
|
||||
Args:
|
||||
tab_name (str): The name to search for.
|
||||
"""
|
||||
return next((i for i, tab in enumerate(self.tabs) if tab.name == tab_name), 0)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return TabsRenderable(
|
||||
self.tabs,
|
||||
tab_padding=self.tab_padding,
|
||||
active_tab_name=self._active_tab_name,
|
||||
active_tab_style=self.active_tab_style,
|
||||
active_bar_style=self.active_bar_style,
|
||||
inactive_tab_style=self.inactive_tab_style,
|
||||
inactive_bar_style=self.inactive_bar_style,
|
||||
bar_offset=self._bar_offset,
|
||||
inactive_text_opacity=self.inactive_text_opacity,
|
||||
)
|
||||
@@ -1,3 +1,9 @@
|
||||
from unittest.mock import create_autospec
|
||||
|
||||
from rich.console import Console
|
||||
from rich.console import ConsoleOptions
|
||||
from rich.text import Text
|
||||
|
||||
from tests.utilities.render import render
|
||||
from textual.renderables.underline_bar import UnderlineBar
|
||||
|
||||
@@ -111,12 +117,7 @@ def test_highlight_full_range_out_of_bounds_start():
|
||||
|
||||
|
||||
def test_custom_styles():
|
||||
bar = UnderlineBar(
|
||||
highlight_range=(2, 4),
|
||||
highlight_style="red",
|
||||
background_style="green",
|
||||
width=6
|
||||
)
|
||||
bar = UnderlineBar(highlight_range=(2, 4), highlight_style="red", background_style="green", width=6)
|
||||
assert render(bar) == (
|
||||
f"{GREEN}━{STOP}"
|
||||
f"{GREEN}╸{STOP}"
|
||||
@@ -124,3 +125,19 @@ def test_custom_styles():
|
||||
f"{GREEN}╺{STOP}"
|
||||
f"{GREEN}━{STOP}"
|
||||
)
|
||||
|
||||
|
||||
def test_clickable_ranges():
|
||||
bar = UnderlineBar(highlight_range=(0, 1), width=6, clickable_ranges={"foo": (0, 2), "bar": (4, 5)})
|
||||
|
||||
console = create_autospec(Console)
|
||||
options = create_autospec(ConsoleOptions)
|
||||
text: Text = list(bar.__rich_console__(console, options))[0]
|
||||
|
||||
start, end, style = text.spans[-2]
|
||||
assert (start, end) == (0, 2)
|
||||
assert style.meta == {'@click': "range_clicked('foo')"}
|
||||
|
||||
start, end, style = text.spans[-1]
|
||||
assert (start, end) == (4, 5)
|
||||
assert style.meta == {'@click': "range_clicked('bar')"}
|
||||
|
||||
Reference in New Issue
Block a user