Unroll tab headers into single renderable, more parameterisation of tabs, more examples

This commit is contained in:
Darren Burns
2022-02-17 12:20:44 +00:00
parent dee99f947f
commit 4e61770a7c
8 changed files with 202 additions and 100 deletions

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
from rich.console import RenderableType
from rich.padding import Padding
from rich.text import Text
from textual import events
from textual.app import App
@@ -11,14 +12,14 @@ from textual.widgets.tabs import Tabs
class Info(Widget):
def __init__(self, text: str, emoji: bool = True) -> None:
DEFAULT_STYLES = "height: 1;"
def __init__(self, text: str) -> None:
super().__init__()
self.text = text
self.emoji = emoji
def render(self) -> RenderableType:
prefix = " " if self.emoji else ""
return Padding(f"{prefix}{self.text}", pad=0)
return Padding(f"{self.text}", pad=(0, 1))
@dataclass
@@ -32,27 +33,15 @@ class BasicApp(App):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tab_keys = {
"1": "one",
"2": "two",
"3": "three",
"4": "four",
"5": "five",
"6": "six",
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 = [
Tab("January", name="one"),
Tab("に月", name="two"),
Tab("March", name="three"),
Tab("April", name="four"),
Tab("May", name="five"),
Tab("And a really long tab!", name="six"),
# Tab("Four", name="five"),
# Tab("Four", name="six"),
# Tab("Four", name="seven"),
# Tab("Four", name="eight"),
# Tab("Four", name="nine"),
]
tabs = list(self.keys_to_tabs.values())
self.examples = [
WidgetDescription(
"Customise the spacing between tabs, e.g. tab_padding=1",
@@ -71,15 +60,6 @@ class BasicApp(App):
tab_padding=2,
),
),
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,
),
),
WidgetDescription(
"Change the color of the inactive portions of the underline, e.g. inactive_bar_style='blue'",
Tabs(
@@ -97,17 +77,38 @@ class BasicApp(App):
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="#695CC8",
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):
@@ -119,7 +120,9 @@ class BasicApp(App):
def on_key(self, event: events.Key) -> None:
for example in self.examples:
example.widget.active_tab_name = self.tab_keys.get(event.key, "one")
tab = self.keys_to_tabs.get(event.key)
if tab:
example.widget.active_tab_name = tab.name
def on_mount(self):
"""Build layout here."""
@@ -128,11 +131,11 @@ class BasicApp(App):
"\n"
"• The examples below show customisation options for the [#1493FF]Tabs[/] widget.\n"
"• Press keys 1-6 on your keyboard to switch tabs, or click on a tab.",
emoji=False,
)
)
for example in self.examples:
self.mount(Info(example.description))
info = Info(example.description)
self.mount(info)
self.mount(example.widget)

View File

@@ -1,7 +1,6 @@
$background: #262626;
$background: #021720;
App > View {
docks: side=left/1;
text: on $background;
}

View File

@@ -5,10 +5,11 @@ from typing import Iterable
from rich.cells import cell_len
from rich.console import Console, ConsoleOptions, RenderResult
from rich.style import Style
from rich.style import Style, StyleType
from rich.text import Text
from textual.renderables.opacity import Opacity
from textual.renderables.underline_bar import UnderlineBar
@dataclass
@@ -29,16 +30,22 @@ class TabHeadersRenderable:
self,
tabs: Iterable[Tab],
*,
active_tab_name: str | None = None,
active_tab_name: str,
active_bar_style: StyleType,
inactive_bar_style: StyleType,
inactive_text_opacity: float,
tab_padding: int | None,
bar_offset: float,
width: int | None = None,
tab_padding: int | None = None,
inactive_tab_opacity: float = 0.5,
):
self.tabs = {tab.name: tab for tab in tabs}
self.active_tab_name = active_tab_name or next(iter(self.tabs))
self.active_bar_style = active_bar_style
self.inactive_bar_style = inactive_bar_style
self.bar_offset = bar_offset
self.width = width
self.tab_padding = tab_padding
self.inactive_tab_opacity = inactive_tab_opacity
self.inactive_text_opacity = inactive_text_opacity
self._range_cache: dict[str, tuple[int, int]] = {}
@@ -86,23 +93,30 @@ class TabHeadersRenderable:
yield tab_content
else:
dimmed_tab_content = Opacity(
tab_content, opacity=self.inactive_tab_opacity
tab_content, opacity=self.inactive_text_opacity
)
segments = list(console.render(dimmed_tab_content))
yield from segments
yield pad
ranges = self.get_ranges()
tab_index = int(self.bar_offset)
next_tab_index = (tab_index + 1) % len(ranges)
if __name__ == "__main__":
console = Console()
range_values = list(ranges.values())
h = TabHeadersRenderable(
[
Tab("One"),
Tab("Two"),
Tab("Three"),
]
)
tab1_start, tab1_end = range_values[tab_index]
tab2_start, tab2_end = range_values[next_tab_index]
console.print(h)
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)
underline = UnderlineBar(
highlight_range=(bar_start, bar_end),
highlight_style=self.active_bar_style,
background_style=self.inactive_bar_style,
)
yield from console.render(underline)

View File

@@ -36,11 +36,9 @@ class Opacity:
color_style = _get_blended_style_cached(
fg_color=fg, bg_color=bg, opacity=opacity
)
meta_style = Style.from_meta(style.meta)
new_style = color_style + meta_style
yield Segment(
segment.text,
new_style,
style + color_style,
segment.control,
)
else:

View File

@@ -3,6 +3,7 @@ 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 +21,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
self.width = width
def __rich_console__(
@@ -102,17 +105,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

@@ -174,8 +174,8 @@ class Widget(DOMNode):
style=renderable_text_style,
)
if styles.opacity:
renderable = Opacity(renderable, opacity=styles.opacity)
# if styles.opacity:
# renderable = Opacity(renderable, opacity=styles.opacity)
return renderable

View File

@@ -1,79 +1,169 @@
from __future__ import annotations
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
from rich.style import StyleType, Style
from rich.text import Text
from textual.reactive import Reactive
from textual.renderables._tab_headers import TabHeadersRenderable, Tab
from textual.renderables._tab_headers import Tab
from textual.renderables.opacity import Opacity
from textual.renderables.underline_bar import UnderlineBar
from textual.widget import Widget
@dataclass
class Tab:
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:
def __init__(
self,
tabs: list[Tab],
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 = tabs
self.active_tab_name = active_tab_name
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.inactive_text_opacity = inactive_text_opacity
self.tab_padding = tab_padding
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:
headers = TabHeadersRenderable(
self.tabs,
active_tab_name=self.active_tab_name,
tab_padding=self.tab_padding,
inactive_tab_opacity=self.inactive_text_opacity,
)
yield from console.render(headers)
if self.tabs:
yield from self.get_tab_headers(console, options)
yield Segment.line()
yield from self.get_underline_bar(console)
ranges = headers.get_ranges()
tab_index = int(self.bar_offset)
next_tab_index = (tab_index + 1) % len(ranges)
def get_active_range(self) -> tuple[int, int]:
return self._label_range_cache[self.active_tab_name]
range_values = list(ranges.values())
def get_ranges(self):
return self._label_range_cache
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)
def get_underline_bar(self, console):
if self.tabs:
ranges = self.get_ranges()
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)
def get_tab_headers(self, console, options):
width = self.width or options.max_width
tabs = self.tabs
tab_values = self.tabs.values()
if self.tab_padding is None:
total_len = sum(cell_len(header.label) for header in tab_values)
free_space = width - total_len
label_pad = (free_space // len(tabs) + 1) // 2
else:
label_pad = self.tab_padding
pad = Text(" " * label_pad, end="")
char_index = label_pad
active_tab_style = console.get_style(self.active_tab_style)
inactive_tab_style = console.get_style(self.inactive_tab_style)
for tab_index, tab in enumerate(tab_values):
# Cache and move to next label
len_label = cell_len(tab.label)
self._label_range_cache[tab.name] = (char_index, char_index + len_label)
self._selection_range_cache[tab.name] = (
char_index - label_pad,
char_index + len_label + label_pad,
)
char_index += len_label + label_pad * 2
if tab.name == self.active_tab_name:
tab_content = Text(
f"{pad}{tab.label}{pad}",
end="",
style=active_tab_style,
)
yield tab_content
else:
tab_content = Text(
f"{pad}{tab.label}{pad}",
end="",
style=inactive_tab_style
+ Style.from_meta({"@click": f"activate_tab('{tab.name}')"}),
)
dimmed_tab_content = Opacity(
tab_content, opacity=self.inactive_text_opacity
)
segments = list(console.render(dimmed_tab_content))
yield from segments
class Tabs(Widget):
"""Horizontal tabs"""
active_tab_name: Reactive[str] = Reactive("")
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,
@@ -83,10 +173,12 @@ class Tabs(Widget):
super().__init__()
self.tabs = tabs
# TODO: Handle empty tabs
self.active_tab_name = active_tab or tabs[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.bar_offset = float(self.get_tab_index(active_tab) or 0)
@@ -121,7 +213,9 @@ class Tabs(Widget):
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

@@ -111,12 +111,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}"