diff --git a/CHANGELOG.md b/CHANGELOG.md index 3513cba79..972f8c5e3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/roadmap.md b/docs/roadmap.md index f486260b8..2d3d14460 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/src/textual/dom.py b/src/textual/dom.py index 24a1b7b1d..77fc6c76d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 3a3d5e100..1b3c7e030 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -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 diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py deleted file mode 100644 index 193cb5d45..000000000 --- a/src/textual/widgets/tabs.py +++ /dev/null @@ -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, - ) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 2a000332d..5ef92e47b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -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)