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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from rich.console import ConsoleOptions, Console, RenderResult from rich.console import ConsoleOptions, Console, RenderResult
from rich.segment import Segment from rich.segment import Segment
from rich.style import StyleType from rich.style import StyleType
from rich.text import Text
class UnderlineBar: class UnderlineBar:
@@ -20,11 +21,13 @@ class UnderlineBar:
highlight_range: tuple[float, float] = (0, 0), highlight_range: tuple[float, float] = (0, 0),
highlight_style: StyleType = "magenta", highlight_style: StyleType = "magenta",
background_style: StyleType = "grey37", background_style: StyleType = "grey37",
clickable_ranges: dict[str, tuple[int, int]] | None = None,
width: int | None = None, width: int | None = None,
) -> None: ) -> None:
self.highlight_range = highlight_range self.highlight_range = highlight_range
self.highlight_style = highlight_style self.highlight_style = highlight_style
self.background_style = background_style self.background_style = background_style
self.clickable_ranges = clickable_ranges
self.width = width self.width = width
def __rich_console__( def __rich_console__(
@@ -102,17 +105,13 @@ if __name__ == "__main__":
for range in ranges: for range in ranges:
color = random.choice(list(ANSI_COLOR_NAMES.keys())) color = random.choice(list(ANSI_COLOR_NAMES.keys()))
console.print( console.print(
UnderlineBar( UnderlineBar(range, highlight_style=color, width=20),
range,
highlight_style=color,
width=20,
),
f" {range}", f" {range}",
) )
from rich.live import Live 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: with Live(bar, refresh_per_second=60) as live:
while True: while True:
bar.highlight_range = ( bar.highlight_range = (

View File

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

View File

@@ -1,52 +1,90 @@
from __future__ import annotations 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.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.segment import Segment 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.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.renderables.underline_bar import UnderlineBar
from textual.widget import Widget 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: class TabsRenderable:
def __init__( def __init__(
self, self,
tabs: list[Tab], tabs: Iterable[Tab],
*,
active_tab_name: str, active_tab_name: str,
active_tab_style: StyleType,
active_bar_style: StyleType, active_bar_style: StyleType,
inactive_tab_style: StyleType,
inactive_bar_style: StyleType, inactive_bar_style: StyleType,
inactive_text_opacity: float, inactive_text_opacity: float,
tab_padding: int | None, tab_padding: int | None,
bar_offset: float, bar_offset: float,
width: int | None = None,
): ):
self.tabs = tabs self.tabs = {tab.name: tab for tab in tabs}
self.active_tab_name = active_tab_name
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.active_bar_style = active_bar_style
self.inactive_tab_style = inactive_tab_style
self.inactive_bar_style = inactive_bar_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.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__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
) -> RenderResult: ) -> RenderResult:
headers = TabHeadersRenderable( if self.tabs:
self.tabs, yield from self.get_tab_headers(console, options)
active_tab_name=self.active_tab_name,
tab_padding=self.tab_padding,
inactive_tab_opacity=self.inactive_text_opacity,
)
yield from console.render(headers)
yield Segment.line() yield Segment.line()
yield from self.get_underline_bar(console)
ranges = headers.get_ranges() def get_active_range(self) -> tuple[int, int]:
return self._label_range_cache[self.active_tab_name]
def get_ranges(self):
return self._label_range_cache
def get_underline_bar(self, console):
if self.tabs:
ranges = self.get_ranges()
tab_index = int(self.bar_offset) tab_index = int(self.bar_offset)
next_tab_index = (tab_index + 1) % len(ranges) next_tab_index = (tab_index + 1) % len(ranges)
range_values = list(ranges.values()) range_values = list(ranges.values())
tab1_start, tab1_end = range_values[tab_index] tab1_start, tab1_end = range_values[tab_index]
tab2_start, tab2_end = range_values[next_tab_index] tab2_start, tab2_end = range_values[next_tab_index]
@@ -54,26 +92,78 @@ class TabsRenderable:
self.bar_offset - tab_index self.bar_offset - tab_index
) )
bar_end = tab1_end + (tab2_end - tab1_end) * (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( underline = UnderlineBar(
highlight_range=(bar_start, bar_end), highlight_range=(bar_start, bar_end),
highlight_style=self.active_bar_style, highlight_style=self.active_bar_style,
background_style=self.inactive_bar_style, background_style=self.inactive_bar_style,
clickable_ranges=self._selection_range_cache,
) )
yield from console.render(underline) 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): class Tabs(Widget):
"""Horizontal tabs""" """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) bar_offset: Reactive[float] = Reactive(0.0)
def __init__( def __init__(
self, self,
tabs: list[Tab], tabs: list[Tab],
active_tab: str | None = None, active_tab: str | None = None,
active_tab_style: StyleType = "#f0f0f0 on #021720",
active_bar_style: StyleType = "#1BB152", active_bar_style: StyleType = "#1BB152",
inactive_tab_style: StyleType = "#f0f0f0 on #021720",
inactive_bar_style: StyleType = "#455058", inactive_bar_style: StyleType = "#455058",
inactive_text_opacity: float = 0.5, inactive_text_opacity: float = 0.5,
animation_duration: float = 0.3, animation_duration: float = 0.3,
@@ -83,10 +173,12 @@ class Tabs(Widget):
super().__init__() super().__init__()
self.tabs = tabs self.tabs = tabs
# TODO: Handle empty tabs self.active_tab_name = active_tab or next(iter(self.tabs), None)
self.active_tab_name = active_tab or tabs[0] self.active_tab_style = active_tab_style
self.active_bar_style = active_bar_style self.active_bar_style = active_bar_style
self.inactive_bar_style = inactive_bar_style self.inactive_bar_style = inactive_bar_style
self.inactive_tab_style = inactive_tab_style
self.inactive_text_opacity = inactive_text_opacity self.inactive_text_opacity = inactive_text_opacity
self.bar_offset = float(self.get_tab_index(active_tab) or 0) self.bar_offset = float(self.get_tab_index(active_tab) or 0)
@@ -121,7 +213,9 @@ class Tabs(Widget):
self.tabs, self.tabs,
tab_padding=self.tab_padding, tab_padding=self.tab_padding,
active_tab_name=self.active_tab_name, active_tab_name=self.active_tab_name,
active_tab_style=self.active_tab_style,
active_bar_style=self.active_bar_style, active_bar_style=self.active_bar_style,
inactive_tab_style=self.inactive_tab_style,
inactive_bar_style=self.inactive_bar_style, inactive_bar_style=self.inactive_bar_style,
bar_offset=self.bar_offset, bar_offset=self.bar_offset,
inactive_text_opacity=self.inactive_text_opacity, 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(): def test_custom_styles():
bar = UnderlineBar( bar = UnderlineBar(highlight_range=(2, 4), highlight_style="red", background_style="green", width=6)
highlight_range=(2, 4),
highlight_style="red",
background_style="green",
width=6
)
assert render(bar) == ( assert render(bar) == (
f"{GREEN}{STOP}" f"{GREEN}{STOP}"
f"{GREEN}{STOP}" f"{GREEN}{STOP}"