Merge pull request #293 from Textualize/tabs

Tabs
This commit is contained in:
Will McGugan
2022-02-21 10:39:26 +00:00
committed by GitHub
12 changed files with 575 additions and 131 deletions

1
docs/reference/tabs.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.tabs.Tabs

View File

@@ -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")

View File

@@ -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
View 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
View File

@@ -0,0 +1,9 @@
$background: #021720;
App > View {
text: on $background;
}
#info {
height: 4;
}

View File

@@ -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()"}))

View File

@@ -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.

View File

@@ -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:

View File

@@ -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 = (

View File

@@ -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
View 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,
)

View File

@@ -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')"}