Skip to content

Commit 234567b

Browse files
committed
Added first draft for a tabbed widget
1 parent b972b41 commit 234567b

File tree

3 files changed

+190
-0
lines changed

3 files changed

+190
-0
lines changed

examples/tabs.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from textual.app import App
2+
from textual import events
3+
from textual.widgets import Placeholder, Tabs, Tab
4+
5+
6+
class TabTest(App):
7+
async def on_load(self, event: events.Load) -> None:
8+
await self.bind("q", "quit", "Quit")
9+
10+
async def on_mount(self, event: events.Mount) -> None:
11+
"""Make a simple tab arrangement."""
12+
tab1 = Tab("First Tab Label")
13+
await tab1.view.dock(Placeholder())
14+
15+
tab2 = Tab("Second Tab Label")
16+
await tab2.view.dock(Placeholder())
17+
18+
tabs = Tabs([tab1, tab2])
19+
await self.view.dock(tabs)
20+
21+
22+
TabTest.run(title="Tab Test", log="textual.log")

src/textual/widgets/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ._placeholder import Placeholder
55
from ._scroll_view import ScrollView
66
from ._static import Static
7+
from ._tabs import Tabs, Tab
78
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
89
from ._directory_tree import DirectoryTree, FileClick
910

@@ -17,6 +18,8 @@
1718
"Placeholder",
1819
"ScrollView",
1920
"Static",
21+
"Tab",
22+
"Tabs",
2023
"TreeClick",
2124
"TreeControl",
2225
"TreeNode",

src/textual/widgets/_tabs.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field, InitVar
4+
from logging import getLogger
5+
from typing import Generic, Literal, TypeVar, TypedDict, cast
6+
7+
from rich.console import RenderableType
8+
from rich.style import StyleType
9+
import rich.repr
10+
11+
from ._button import ButtonRenderable
12+
from .. import events, views
13+
from ..widget import Reactive, Widget
14+
from ..view import View
15+
16+
log = getLogger("rich")
17+
18+
ViewType = TypeVar("ViewType", bound=View)
19+
20+
21+
class HandleStyle(TypedDict):
22+
default: StyleType
23+
default_hover: StyleType
24+
selected: StyleType
25+
selected_hover: StyleType
26+
27+
28+
StyleMode = Literal["default", "default_hover", "selected", "selected_hover"]
29+
30+
31+
class TabHandle(Widget):
32+
def __init__(
33+
self,
34+
label: str,
35+
name: str | None = None,
36+
styles: HandleStyle | None = None,
37+
) -> None:
38+
self.label = label
39+
self.styles: HandleStyle = styles or cast(
40+
HandleStyle,
41+
{
42+
"default": "default",
43+
"default_hover": "bold",
44+
"selected": "reverse",
45+
"selected_hover": "bold reverse",
46+
},
47+
)
48+
super().__init__(name=name)
49+
50+
@property
51+
def container(self) -> Tabs:
52+
if not hasattr(self, "_container"):
53+
raise RuntimeError("Tab handle must be bound to container before use.")
54+
return self._container
55+
56+
@container.setter
57+
def container(self, new: Tabs):
58+
if hasattr(self, "_container"):
59+
raise RuntimeError("Can only bind tab handle to one container.")
60+
self._container = new
61+
62+
selected: Reactive[bool] = Reactive(False)
63+
current_style: Reactive[StyleMode] = Reactive("default")
64+
65+
def render(self) -> RenderableType:
66+
return ButtonRenderable(self.label, style=self.styles[self.current_style])
67+
68+
async def on_click(self, event: events.Click) -> None:
69+
self._container.current = self.label
70+
71+
async def on_enter(self, event: events.Enter) -> None:
72+
self.current_style = "selected_hover" if self.selected else "default_hover"
73+
74+
async def on_leave(self, event: events.Leave) -> None:
75+
self.current_style = "selected" if self.selected else "default"
76+
77+
async def watch_selected(self, selected: bool):
78+
self.current_style = "selected" if selected else "default"
79+
80+
81+
class _InitTabType(str):
82+
"""Simple sentinel for an uninitialized tab selection."""
83+
84+
85+
INIT_TAB = _InitTabType()
86+
87+
88+
@dataclass
89+
class Tab(Generic[ViewType]):
90+
name: str
91+
view_type: InitVar[type[ViewType]] = views.DockView
92+
handle_styles: InitVar[HandleStyle | None] = None
93+
view: ViewType = field(init=False)
94+
handle: TabHandle = field(init=False)
95+
_selected: bool = field(init=False, default=False)
96+
97+
def __post_init__(
98+
self, view_type: type[ViewType], handle_styles: HandleStyle | None
99+
):
100+
if not self.name:
101+
raise ValueError("A Tabs name must be at least one character long.")
102+
self.view = view_type()
103+
self.handle = TabHandle(self.name, styles=handle_styles)
104+
105+
def bind(self, container: Tabs):
106+
self.handle.container = container
107+
108+
@property
109+
def selected(self) -> bool:
110+
return self._selected
111+
112+
@selected.setter
113+
def selected(self, new: bool):
114+
self.view.visible = new
115+
self.handle.selected = new
116+
117+
118+
@rich.repr.auto(angular=False)
119+
class Tabs(views.GridView, can_focus=True):
120+
_tabs: dict[str, Tab]
121+
122+
def __init__(
123+
self,
124+
tabs: list[Tab],
125+
initial_selection: str | None = None,
126+
name: str | None = None,
127+
) -> None:
128+
if not tabs:
129+
raise ValueError("Tabs requires at least on Tab to function.")
130+
self._tabs = {tab.name: tab for tab in tabs}
131+
self._init = initial_selection or tabs[0].name
132+
super().__init__(name=name)
133+
134+
async def on_mount(self, event: events.Mount) -> None:
135+
for tab in self._tabs.values():
136+
tab.bind(self)
137+
138+
self.grid.set_gap(1, 2)
139+
self.grid.set_gutter(1)
140+
141+
max_column = len(self._tabs)
142+
self.grid.add_column("col", fraction=1, repeat=max_column)
143+
self.grid.add_row("labels", max_size=1)
144+
self.grid.place(*(tab.handle for tab in self._tabs.values()))
145+
146+
self.grid.add_row("content", fraction=1)
147+
self.grid.add_areas(content=f"col1-start|col{max_column}-end,content")
148+
for tab in self._tabs.values():
149+
self.grid.place(content=tab.view)
150+
151+
await super().on_mount(event)
152+
153+
self.current = self._init
154+
155+
current: Reactive[str] = Reactive(INIT_TAB)
156+
157+
def validate_current(self, val: str) -> str:
158+
if val not in self._tabs:
159+
raise RuntimeError(f"Unknown tab with name: {val}.")
160+
return val
161+
162+
async def watch_current(self, old: str, new: str) -> None:
163+
if old is not INIT_TAB:
164+
self._tabs[old].selected = False
165+
self._tabs[new].selected = True

0 commit comments

Comments
 (0)