mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
merged changelog
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
- Removed `screen.visible_widgets` and `screen.widgets`
|
||||
|
||||
|
||||
## [0.11.1] Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- DataTable fix issue where offset cache was not being used https://github.com/Textualize/textual/pull/1810
|
||||
- DataTable scrollbars resize correctly when header is toggled https://github.com/Textualize/textual/pull/1803
|
||||
- DataTable location mapping cleared when clear called https://github.com/Textualize/textual/pull/1809
|
||||
|
||||
|
||||
## [0.11.0] - 2023-02-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -61,7 +61,9 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
|
||||
* [ ] Validation
|
||||
* [ ] Error / warning states
|
||||
* [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc
|
||||
- [ ] Markdown viewer (more dynamic than Rich markdown, with scrollable code areas / collapsible sections)
|
||||
- [X] Markdown viewer
|
||||
* [ ] Collapsible sections
|
||||
* [ ] Custom widgets
|
||||
- [ ] Plots
|
||||
* [ ] bar chart
|
||||
* [ ] line chart
|
||||
|
||||
@@ -260,9 +260,6 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
bindings: list[Bindings] = []
|
||||
|
||||
# To start with, assume that bindings won't be priority bindings.
|
||||
priority = False
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
if issubclass(base, DOMNode):
|
||||
if not base._inherit_bindings:
|
||||
|
||||
@@ -574,7 +574,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
else:
|
||||
for row in self.ordered_rows:
|
||||
y_offsets += [(row.key, y) for y in range(row.height)]
|
||||
self._offset_cache = y_offsets
|
||||
self._offset_cache[self._update_count] = y_offsets
|
||||
return y_offsets
|
||||
|
||||
@property
|
||||
@@ -755,6 +755,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self._cell_render_cache.clear()
|
||||
self._line_cache.clear()
|
||||
self._styles_cache.clear()
|
||||
self._offset_cache.clear()
|
||||
self._ordered_row_cache.clear()
|
||||
|
||||
def get_row_height(self, row_key: RowKey) -> int:
|
||||
"""Given a row key, return the height of that row in terminal cells.
|
||||
@@ -786,7 +788,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
elif self.cursor_type == "column":
|
||||
self._highlight_column(self.cursor_column)
|
||||
|
||||
def watch_show_header(self) -> None:
|
||||
def watch_show_header(self, show: bool) -> None:
|
||||
width, height = self.virtual_size
|
||||
height_change = self.header_height if show else -self.header_height
|
||||
self.virtual_size = Size(width, height + height_change)
|
||||
self._scroll_cursor_into_view()
|
||||
self._clear_caches()
|
||||
|
||||
def watch_fixed_rows(self) -> None:
|
||||
@@ -1014,8 +1020,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self._y_offsets.clear()
|
||||
self._data.clear()
|
||||
self.rows.clear()
|
||||
self._row_locations = TwoWayDict({})
|
||||
if columns:
|
||||
self.columns.clear()
|
||||
self._column_locations = TwoWayDict({})
|
||||
self._require_update_dimensions = True
|
||||
self.cursor_coordinate = Coordinate(0, 0)
|
||||
self.hover_coordinate = Coordinate(0, 0)
|
||||
@@ -1111,6 +1119,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
if cell_now_available and visible_cursor:
|
||||
self._highlight_cursor()
|
||||
|
||||
self._update_count += 1
|
||||
self.check_idle()
|
||||
return row_key
|
||||
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
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 Style, StyleType
|
||||
from rich.text import Text
|
||||
|
||||
from textual import events
|
||||
from textual._layout_resolve import Edge, layout_resolve
|
||||
from textual.keys import Keys
|
||||
from textual.reactive import Reactive
|
||||
from textual.renderables.text_opacity import TextOpacity
|
||||
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: The user-facing label that will appear inside the tab.
|
||||
name: 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 = TextOpacity(
|
||||
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: A list of Tab objects defining the tabs which should be rendered.
|
||||
active_tab: The name of the tab that should be active on first render.
|
||||
active_tab_style: Style to apply to the label of the active tab.
|
||||
active_bar_style: Style to apply to the underline of the active tab.
|
||||
inactive_tab_style: Style to apply to the label of inactive tabs.
|
||||
inactive_bar_style: Style to apply to the underline of inactive tabs.
|
||||
inactive_text_opacity: Opacity of the text labels of inactive tabs.
|
||||
animation_duration: The duration of the tab change animation, in seconds.
|
||||
animation_function: The easing function to use for the tab change animation.
|
||||
tab_padding: 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: 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.
|
||||
"""
|
||||
|
||||
_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: The Key event being handled
|
||||
"""
|
||||
if not self.tabs:
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
if event.key == Keys.Escape:
|
||||
self.screen.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: 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: 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: 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,
|
||||
)
|
||||
@@ -279,10 +279,13 @@ async def test_clear():
|
||||
assert table._data == {}
|
||||
assert table.rows == {}
|
||||
assert table.row_count == 0
|
||||
assert len(table._row_locations) == 0
|
||||
assert len(table._column_locations) == 1
|
||||
assert len(table.columns) == 1
|
||||
|
||||
# Clearing the columns too
|
||||
table.clear(columns=True)
|
||||
assert len(table._column_locations) == 0
|
||||
assert len(table.columns) == 0
|
||||
|
||||
|
||||
@@ -891,6 +894,36 @@ async def test_column_cursor_highlight_events():
|
||||
assert latest_message.cursor_column == 0
|
||||
|
||||
|
||||
async def test_reuse_row_key_after_clear():
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/1806"""
|
||||
app = DataTableApp()
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.add_columns("A", "B")
|
||||
table.add_row(0, 1, key="ROW1")
|
||||
table.add_row(2, 3, key="ROW2")
|
||||
table.clear()
|
||||
table.add_row(4, 5, key="ROW1") # Reusing the same keys as above
|
||||
table.add_row(7, 8, key="ROW2")
|
||||
assert table.get_row("ROW1") == [4, 5]
|
||||
assert table.get_row("ROW2") == [7, 8]
|
||||
|
||||
|
||||
async def test_reuse_column_key_after_clear():
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/1806"""
|
||||
app = DataTableApp()
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.add_column("A", key="COLUMN1")
|
||||
table.add_column("B", key="COLUMN2")
|
||||
table.clear(columns=True)
|
||||
table.add_column("C", key="COLUMN1") # Reusing the same keys as above
|
||||
table.add_column("D", key="COLUMN2")
|
||||
table.add_row(1, 2)
|
||||
assert list(table.get_column("COLUMN1")) == [1]
|
||||
assert list(table.get_column("COLUMN2")) == [2]
|
||||
|
||||
|
||||
def test_key_equals_equivalent_string():
|
||||
text = "Hello"
|
||||
key = RowKey(text)
|
||||
|
||||
Reference in New Issue
Block a user