From d43eb9028cf2c923c32e7fe5fa50ac4f8a6d94ca Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 14:24:35 +0000 Subject: [PATCH 001/155] Merge latest main --- src/textual/widgets/_data_table.py | 44 ++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f5b33dec2..7e1372cfd 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field from itertools import chain, zip_longest -from typing import ClassVar, Generic, Iterable, TypeVar, cast +from typing import ClassVar, Generic, Iterable, TypeVar, cast, NamedTuple import rich.repr from rich.console import RenderableType @@ -35,6 +35,16 @@ class CellDoesNotExist(Exception): pass +class Key(NamedTuple): + value: str | None + + def __hash__(self): + # TODO: Revisit + # If a string is supplied, we use the hash of the string. + # If no string was supplied, we use the default hash to ensure uniqueness amongst instances. + return hash(self.value) if self.value is not None else super().__hash__(self) + + def default_cell_formatter(obj: object) -> RenderableType | None: """Format a cell in to a renderable. @@ -438,21 +448,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.hover_cell = Coordinate(0, 0) self.refresh() - def add_columns(self, *labels: TextType) -> None: - """Add a number of columns. - - Args: - *labels: Column headers. - """ - for label in labels: - self.add_column(label, width=None) - - def add_column(self, label: TextType, *, width: int | None = None) -> None: + def add_column( + self, label: TextType, *, width: int | None = None, key: str | None = None + ) -> None: """Add a column to the table. Args: label: A str or Text object containing the label (shown top of column). width: Width of the column in cells or None to fit content. Defaults to None. + key: A key which uniquely identifies this column. If None, it will be generated for you. Defaults to None. """ text_label = Text.from_markup(label) if isinstance(label, str) else label @@ -474,12 +478,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions = True self.check_idle() - def add_row(self, *cells: CellType, height: int = 1) -> None: - """Add a row. + def add_row( + self, *cells: CellType, height: int = 1, key: str | None = None + ) -> None: + """Add a row at the bottom of the DataTable. Args: *cells: Positional arguments should contain cell data. height: The height of a row (in lines). Defaults to 1. + key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. """ row_index = self.row_count @@ -506,8 +513,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if cell_now_available and visible_cursor: self._highlight_cursor() + def add_columns(self, *labels: TextType) -> None: + """Add a number of columns. + + Args: + *labels: Column headers. + """ + for label in labels: + self.add_column(label, width=None) + def add_rows(self, rows: Iterable[Iterable[CellType]]) -> None: - """Add a number of rows. + """Add a number of rows at the bottom of the DataTable. Args: rows: Iterable of rows. A row is an iterable of cells. From 57863cd6cd4f805d48426f11e17fda8067489004 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 14:50:54 +0000 Subject: [PATCH 002/155] Creating keys for rows and columns on creation --- src/textual/widgets/_data_table.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 7e1372cfd..482faa405 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -65,6 +65,7 @@ def default_cell_formatter(obj: object) -> RenderableType | None: class Column: """Table column.""" + key: Key label: Text width: int = 0 visible: bool = False @@ -87,6 +88,7 @@ class Column: class Row: """Table row.""" + key: Key index: int height: int y: int @@ -460,9 +462,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ text_label = Text.from_markup(label) if isinstance(label, str) else label + column_key = Key(key) content_width = measure(self.app.console, text_label, 1) if width is None: column = Column( + column_key, text_label, content_width, index=len(self.columns), @@ -471,7 +475,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) else: column = Column( - text_label, width, content_width=content_width, index=len(self.columns) + column_key, + text_label, + width, + content_width=content_width, + index=len(self.columns), ) self.columns.append(column) @@ -489,9 +497,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. """ row_index = self.row_count + row_key = Key(key) self.data[row_index] = list(cells) - self.rows[row_index] = Row(row_index, height, self._line_no) + self.rows[row_index] = Row(row_key, row_index, height, self._line_no) for line_no in range(height): self._y_offsets.append((row_index, line_no)) From 501952abd50ca13ce0635b4eb64bd098c9e23fb4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 14:58:14 +0000 Subject: [PATCH 003/155] Add tests around DataTable key generation --- src/textual/widgets/_data_table.py | 7 ++++++- tests/test_data_table.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 482faa405..b8f208eb3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -35,15 +35,20 @@ class CellDoesNotExist(Exception): pass +# TODO: Revisit? class Key(NamedTuple): value: str | None def __hash__(self): - # TODO: Revisit # If a string is supplied, we use the hash of the string. # If no string was supplied, we use the default hash to ensure uniqueness amongst instances. return hash(self.value) if self.value is not None else super().__hash__(self) + def __eq__(self, other: object) -> bool: + # Strings will match Keys containing the same string value. + # Otherwise, you'll need to supply the exact same key object. + return hash(self) == hash(other) + def default_cell_formatter(obj: object) -> RenderableType | None: """Format a cell in to a renderable. diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 6ff0192b9..322cc9de4 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -2,6 +2,7 @@ from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable +from textual.widgets._data_table import Key class DataTableApp(App): @@ -147,3 +148,27 @@ async def test_clear(): # Clearing the columns too table.clear(columns=True) assert len(table.columns) == 0 + + +def test_key_equals_equivalent_string(): + text = "Hello" + key = Key(text) + assert key == text + assert hash(key) == hash(text) + + +def test_key_doesnt_match_non_equal_string(): + key = Key("123") + text = "laksjdlaskjd" + assert key != text + assert hash(key) != hash(text) + + +def test_key_string_lookup(): + # Indirectly covered by other tests, but let's explicitly show how + # we intend for the keys to work for cache lookups. + dictionary = { + "hello": "world", + } + assert dictionary["hello"] == "world" + assert dictionary[Key("hello")] == "world" From 16c50870a7e144c4db4376a567613aeb59abaf5b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:10:36 +0000 Subject: [PATCH 004/155] Some updates to DataTable cell key tests --- tests/test_data_table.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 322cc9de4..6c1945215 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -165,10 +165,13 @@ def test_key_doesnt_match_non_equal_string(): def test_key_string_lookup(): - # Indirectly covered by other tests, but let's explicitly show how - # we intend for the keys to work for cache lookups. + # Indirectly covered by other tests, but let's explicitly document + # in tests how we intend for the keys to work for cache lookups. dictionary = { - "hello": "world", + "foo": "bar", + Key("hello"): "world", } + assert dictionary["foo"] == "bar" + assert dictionary[Key("foo")] == "bar" assert dictionary["hello"] == "world" assert dictionary[Key("hello")] == "world" From b514507472546efff6ef7a8031c6d199742fc7d2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:11:46 +0000 Subject: [PATCH 005/155] Rename Key to StringKey to prevent clash with keyboard Keys --- src/textual/widgets/_data_table.py | 10 +++++----- tests/test_data_table.py | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b8f208eb3..227b91e35 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -36,7 +36,7 @@ class CellDoesNotExist(Exception): # TODO: Revisit? -class Key(NamedTuple): +class StringKey(NamedTuple): value: str | None def __hash__(self): @@ -70,7 +70,7 @@ def default_cell_formatter(obj: object) -> RenderableType | None: class Column: """Table column.""" - key: Key + key: StringKey label: Text width: int = 0 visible: bool = False @@ -93,7 +93,7 @@ class Column: class Row: """Table row.""" - key: Key + key: StringKey index: int height: int y: int @@ -467,7 +467,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ text_label = Text.from_markup(label) if isinstance(label, str) else label - column_key = Key(key) + column_key = StringKey(key) content_width = measure(self.app.console, text_label, 1) if width is None: column = Column( @@ -502,7 +502,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. """ row_index = self.row_count - row_key = Key(key) + row_key = StringKey(key) self.data[row_index] = list(cells) self.rows[row_index] = Row(row_key, row_index, height, self._line_no) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 6c1945215..cf5a4e25d 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -2,7 +2,7 @@ from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable -from textual.widgets._data_table import Key +from textual.widgets._data_table import StringKey class DataTableApp(App): @@ -152,13 +152,13 @@ async def test_clear(): def test_key_equals_equivalent_string(): text = "Hello" - key = Key(text) + key = StringKey(text) assert key == text assert hash(key) == hash(text) def test_key_doesnt_match_non_equal_string(): - key = Key("123") + key = StringKey("123") text = "laksjdlaskjd" assert key != text assert hash(key) != hash(text) @@ -169,9 +169,9 @@ def test_key_string_lookup(): # in tests how we intend for the keys to work for cache lookups. dictionary = { "foo": "bar", - Key("hello"): "world", + StringKey("hello"): "world", } assert dictionary["foo"] == "bar" - assert dictionary[Key("foo")] == "bar" + assert dictionary[StringKey("foo")] == "bar" assert dictionary["hello"] == "world" - assert dictionary[Key("hello")] == "world" + assert dictionary[StringKey("hello")] == "world" From 7604eacdb1b859a82ed47f199a13c710418fcc4e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:12:04 +0000 Subject: [PATCH 006/155] Delete a comment --- src/textual/widgets/_data_table.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 227b91e35..53d0136bd 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -35,7 +35,6 @@ class CellDoesNotExist(Exception): pass -# TODO: Revisit? class StringKey(NamedTuple): value: str | None From 9d7e8a8d4de240e23acb41a534cec26bbd102956 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:14:13 +0000 Subject: [PATCH 007/155] Create CellKey NamedTuple --- src/textual/widgets/_data_table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 53d0136bd..cf06d921b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -49,6 +49,11 @@ class StringKey(NamedTuple): return hash(self) == hash(other) +class CellKey(NamedTuple): + row_key: StringKey + column_key: StringKey + + def default_cell_formatter(obj: object) -> RenderableType | None: """Format a cell in to a renderable. From 27734d5fdbc48c1a1ac35a0921c2afbe6ab047f7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:16:22 +0000 Subject: [PATCH 008/155] Return the StringKey objects from add_row/add_column in DataTable --- src/textual/widgets/_data_table.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index cf06d921b..d246bcf40 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -461,7 +461,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def add_column( self, label: TextType, *, width: int | None = None, key: str | None = None - ) -> None: + ) -> StringKey: """Add a column to the table. Args: @@ -495,9 +495,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions = True self.check_idle() + return column_key + def add_row( self, *cells: CellType, height: int = 1, key: str | None = None - ) -> None: + ) -> StringKey: """Add a row at the bottom of the DataTable. Args: @@ -531,6 +533,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if cell_now_available and visible_cursor: self._highlight_cursor() + return row_key + def add_columns(self, *labels: TextType) -> None: """Add a number of columns. From 69adb5b94c840c0d09828a3f700de51bba31f818 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 15:36:10 +0000 Subject: [PATCH 009/155] Return keys from batch add_row/column in DataTable, docstring it --- src/textual/widgets/_data_table.py | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d246bcf40..d9fc3968b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -468,6 +468,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): label: A str or Text object containing the label (shown top of column). width: Width of the column in cells or None to fit content. Defaults to None. key: A key which uniquely identifies this column. If None, it will be generated for you. Defaults to None. + + Returns: + StringKey: Uniquely identifies this column. Can be used to retrieve this column regardless + of its current location in the DataTable (it could have moved after being added + due to sorting or insertion/deletion of other columns). """ text_label = Text.from_markup(label) if isinstance(label, str) else label @@ -506,6 +511,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): *cells: Positional arguments should contain cell data. height: The height of a row (in lines). Defaults to 1. key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. + + Returns: + StringKey: Uniquely identifies this row. Can be used to retrieve this row regardless + of its current location in the DataTable (it could have moved after being added + due to sorting or insertion/deletion of other rows). """ row_index = self.row_count row_key = StringKey(key) @@ -535,23 +545,39 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_key - def add_columns(self, *labels: TextType) -> None: + def add_columns(self, *labels: TextType) -> list[StringKey]: """Add a number of columns. Args: *labels: Column headers. - """ - for label in labels: - self.add_column(label, width=None) - def add_rows(self, rows: Iterable[Iterable[CellType]]) -> None: + Returns: + A list of the keys for the columns that were added. See + the `add_column` method docstring for more information on how + these keys are used. + """ + column_keys = [] + for label in labels: + column_key = self.add_column(label, width=None) + column_keys.append(column_key) + return column_keys + + def add_rows(self, rows: Iterable[Iterable[CellType]]) -> list[StringKey]: """Add a number of rows at the bottom of the DataTable. Args: rows: Iterable of rows. A row is an iterable of cells. + + Returns: + A list of the keys for the rows that were added. See + the `add_row` method docstring for more information on how + these keys are used. """ + row_keys = [] for row in rows: - self.add_row(*row) + row_key = self.add_row(*row) + row_keys.append(row_key) + return row_keys def on_idle(self) -> None: if self._require_update_dimensions: From 737accfc3bd603fb9a1ddaadce91f2cdf3bc6dea Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 19 Jan 2023 17:05:26 +0000 Subject: [PATCH 010/155] Initial work on a two-way mapping data structure --- src/textual/_two_way_mapping.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/textual/_two_way_mapping.py diff --git a/src/textual/_two_way_mapping.py b/src/textual/_two_way_mapping.py new file mode 100644 index 000000000..a62287b43 --- /dev/null +++ b/src/textual/_two_way_mapping.py @@ -0,0 +1,22 @@ +from typing import TypeVar, Generic + +Left = TypeVar("Left") +Right = TypeVar("Right") + + +class TwoWayMapping(Generic[Left, Right]): + def __init__(self, initial: dict[Left, Right]) -> None: + self._forward: dict[Left, Right] = initial + self._reverse: dict[Right, Left] = {value: key for key, value in initial} + + def __setitem__(self, left: Left, right: Right) -> None: + self._forward.__setitem__(left, right) + self._reverse.__setitem__(right, left) + + def __delitem__(self, left: Left) -> None: + right = self._forward[left] + self._forward.__delitem__(left) + self._reverse.__delitem__(right) + + def __len__(self): + return len(self._forward) From 38179451ef2b7010bc0fa3478a656ca780d33419 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 09:40:57 +0000 Subject: [PATCH 011/155] Add lookup methods --- src/textual/_two_way_mapping.py | 57 +++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/src/textual/_two_way_mapping.py b/src/textual/_two_way_mapping.py index a62287b43..e2592a45e 100644 --- a/src/textual/_two_way_mapping.py +++ b/src/textual/_two_way_mapping.py @@ -1,22 +1,53 @@ +from __future__ import annotations + from typing import TypeVar, Generic -Left = TypeVar("Left") -Right = TypeVar("Right") +Key = TypeVar("Key") +Value = TypeVar("Value") -class TwoWayMapping(Generic[Left, Right]): - def __init__(self, initial: dict[Left, Right]) -> None: - self._forward: dict[Left, Right] = initial - self._reverse: dict[Right, Left] = {value: key for key, value in initial} +class TwoWayMapping(Generic[Key, Value]): + """ + Wraps two dictionaries and uses them to provide efficient access to + both values (given keys) and keys (given values). + """ - def __setitem__(self, left: Left, right: Right) -> None: - self._forward.__setitem__(left, right) - self._reverse.__setitem__(right, left) + def __init__(self, initial: dict[Key, Value]) -> None: + self._forward: dict[Key, Value] = initial + self._reverse: dict[Value, Key] = {value: key for key, value in initial} - def __delitem__(self, left: Left) -> None: - right = self._forward[left] - self._forward.__delitem__(left) - self._reverse.__delitem__(right) + def __setitem__(self, key: Key, value: Value) -> None: + self._forward.__setitem__(key, value) + self._reverse.__setitem__(value, key) + + def __delitem__(self, key: Key) -> None: + value = self._forward[key] + self._forward.__delitem__(key) + self._reverse.__delitem__(value) + + def get(self, key: Key, default: Value | None = None) -> Value: + """Given a key, efficiently lookup and return the associated value. + + Args: + key: The key + default: The default return value if not found. Defaults to None. + + Returns: + The value + """ + return self._forward.get(key, default) + + def get_key(self, value: Value, default: Key | None = None) -> Key: + """Given a value, efficiently lookup and return the associated key. + + Args: + value: The value + default: The default return value if not found. Defaults to None. + + Returns: + The key + """ + return self._reverse.get(value, default) def __len__(self): return len(self._forward) From 2b7c56981108c750c46e2c7e5b0eb81392198960 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 10:06:17 +0000 Subject: [PATCH 012/155] Testing two-way mapping --- src/textual/_two_way_mapping.py | 2 +- tests/test_two_way_mapping.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/test_two_way_mapping.py diff --git a/src/textual/_two_way_mapping.py b/src/textual/_two_way_mapping.py index e2592a45e..f3f04f369 100644 --- a/src/textual/_two_way_mapping.py +++ b/src/textual/_two_way_mapping.py @@ -14,7 +14,7 @@ class TwoWayMapping(Generic[Key, Value]): def __init__(self, initial: dict[Key, Value]) -> None: self._forward: dict[Key, Value] = initial - self._reverse: dict[Value, Key] = {value: key for key, value in initial} + self._reverse: dict[Value, Key] = {value: key for key, value in initial.items()} def __setitem__(self, key: Key, value: Value) -> None: self._forward.__setitem__(key, value) diff --git a/tests/test_two_way_mapping.py b/tests/test_two_way_mapping.py new file mode 100644 index 000000000..9a88bdeec --- /dev/null +++ b/tests/test_two_way_mapping.py @@ -0,0 +1,56 @@ +import pytest + +from textual._two_way_mapping import TwoWayMapping + + +@pytest.fixture +def map(): + return TwoWayMapping( + { + 1: 10, + 2: 20, + 3: 30, + } + ) + + +def test_get(map): + assert map.get(1) == 10 + + +def test_get_default_none(map): + assert map.get(9999) is None + + +def test_get_default_supplied(map): + assert map.get(9999, -123) == -123 + + +def test_get_key(map): + assert map.get_key(30) == 3 + + +def test_get_key_default_none(map): + assert map.get_key(9999) is None + + +def test_get_key_default_supplied(map): + assert map.get_key(9999, -123) == -123 + + +def test_set_item(map): + map[40] = 400 + assert map.get(40) == 400 + assert map.get_key(400) == 40 + + +def test_len(map): + assert len(map) == 3 + + +def test_delitem(map): + assert map.get(3) == 30 + assert map.get_key(30) == 3 + del map[3] + assert map.get(3) is None + assert map.get_key(30) is None From 0b832a3a3a190f2c2eb1c8409955479ff3682273 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 10:29:45 +0000 Subject: [PATCH 013/155] Update to new docsting format for Returns --- src/textual/widgets/_data_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d9fc3968b..119d0b7d3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -470,7 +470,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): key: A key which uniquely identifies this column. If None, it will be generated for you. Defaults to None. Returns: - StringKey: Uniquely identifies this column. Can be used to retrieve this column regardless + Uniquely identifies this column. Can be used to retrieve this column regardless of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other columns). """ @@ -513,7 +513,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. Returns: - StringKey: Uniquely identifies this row. Can be used to retrieve this row regardless + Uniquely identifies this row. Can be used to retrieve this row regardless of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other rows). """ From cef3983524d82a33a853f33feb6281805f1e8038 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 10:43:31 +0000 Subject: [PATCH 014/155] Use custom object instead of NamedTuple for keys --- src/textual/widgets/_data_table.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 119d0b7d3..7ffc9c847 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -35,9 +35,12 @@ class CellDoesNotExist(Exception): pass -class StringKey(NamedTuple): +class StringKey: value: str | None + def __init__(self, value: str | None = None): + self.value = value + def __hash__(self): # If a string is supplied, we use the hash of the string. # If no string was supplied, we use the default hash to ensure uniqueness amongst instances. From e8bdc43fe4e622ad2f016cf49c44e7a897fcd098 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 10:43:58 +0000 Subject: [PATCH 015/155] Formatting docstrings in DataTable module --- src/textual/widgets/_data_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 7ffc9c847..b2082f1e7 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -42,8 +42,8 @@ class StringKey: self.value = value def __hash__(self): - # If a string is supplied, we use the hash of the string. - # If no string was supplied, we use the default hash to ensure uniqueness amongst instances. + # If a string is supplied, we use the hash of the string. If no string was + # supplied, we use the default hash to ensure uniqueness amongst instances. return hash(self.value) if self.value is not None else super().__hash__(self) def __eq__(self, other: object) -> bool: From 61c8a1dfabb682834eb5702278c83141de383c67 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 11:21:08 +0000 Subject: [PATCH 016/155] Update a docstring that was very wrong --- src/textual/widgets/_data_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b2082f1e7..76db2c05e 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -729,7 +729,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: row_index: Index of the row. - line_no: Line number (on screen, 0 is top) + line_no: Line number (y-coordinate) within row. 0 is top strip of terminal + cells, 1 is the next, and so on... base_style: Base style of row. cursor_location: The location of the cursor in the DataTable. hover_location: The location of the hover cursor in the DataTable. From b6e278f15b0020d3a09d3b0d8620ceaa03dcff64 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 14:06:24 +0000 Subject: [PATCH 017/155] Light datatable refactoring/renaming --- src/textual/_types.py | 1 - src/textual/widgets/_data_table.py | 47 ++++++++++++++++++------------ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/textual/_types.py b/src/textual/_types.py index 52b2390cf..56a3efd33 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -7,7 +7,6 @@ from ._typing import Protocol if TYPE_CHECKING: from .message import Message - from .strip import Strip class MessageTarget(Protocol): diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 76db2c05e..d31142a0d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -52,9 +52,17 @@ class StringKey: return hash(self) == hash(other) +class RowKey(StringKey): + pass + + +class ColumnKey(StringKey): + pass + + class CellKey(NamedTuple): - row_key: StringKey - column_key: StringKey + row_key: RowKey + column_key: ColumnKey def default_cell_formatter(obj: object) -> RenderableType | None: @@ -77,7 +85,7 @@ def default_cell_formatter(obj: object) -> RenderableType | None: class Column: """Table column.""" - key: StringKey + key: ColumnKey label: Text width: int = 0 visible: bool = False @@ -100,7 +108,7 @@ class Column: class Row: """Table row.""" - key: StringKey + key: RowKey index: int height: int y: int @@ -209,9 +217,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.rows: dict[int, Row] = {} self.data: dict[int, list[CellType]] = {} self.row_count = 0 - self._y_offsets: list[tuple[int, int]] = [] + + # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs + self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ - tuple[int, int, Style, int, int], tuple[SegmentLines, SegmentLines] + tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] ] self._row_render_cache = LRUCache(1000) self._cell_render_cache: LRUCache[ @@ -464,7 +474,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def add_column( self, label: TextType, *, width: int | None = None, key: str | None = None - ) -> StringKey: + ) -> ColumnKey: """Add a column to the table. Args: @@ -479,7 +489,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ text_label = Text.from_markup(label) if isinstance(label, str) else label - column_key = StringKey(key) + column_key = ColumnKey(key) content_width = measure(self.app.console, text_label, 1) if width is None: column = Column( @@ -507,7 +517,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def add_row( self, *cells: CellType, height: int = 1, key: str | None = None - ) -> StringKey: + ) -> RowKey: """Add a row at the bottom of the DataTable. Args: @@ -521,7 +531,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): due to sorting or insertion/deletion of other rows). """ row_index = self.row_count - row_key = StringKey(key) + row_key = RowKey(key) self.data[row_index] = list(cells) self.rows[row_index] = Row(row_key, row_index, height, self._line_no) @@ -548,7 +558,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_key - def add_columns(self, *labels: TextType) -> list[StringKey]: + def add_columns(self, *labels: TextType) -> list[ColumnKey]: """Add a number of columns. Args: @@ -565,7 +575,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_keys.append(column_key) return column_keys - def add_rows(self, rows: Iterable[Iterable[CellType]]) -> list[StringKey]: + def add_rows(self, rows: Iterable[Iterable[CellType]]) -> list[RowKey]: """Add a number of rows at the bottom of the DataTable. Args: @@ -690,9 +700,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if hover and show_cursor and self._show_hover_cursor: style += self.get_component_styles("datatable--highlight").rich_style if is_fixed_style: - # Apply subtle variation in style for the fixed (blue background by default) - # rows and columns affected by the cursor, to ensure we can still differentiate - # between the labels and the data. + # Apply subtle variation in style for the fixed (blue background by + # default) rows and columns affected by the cursor, to ensure we can + # still differentiate between the labels and the data. style += self.get_component_styles( "datatable--highlight-fixed" ).rich_style @@ -717,7 +727,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._cell_render_cache[cell_key] = lines return self._cell_render_cache[cell_key] - def _render_row( + def _render_line_in_row( self, row_index: int, line_no: int, @@ -740,6 +750,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ cursor_type = self.cursor_type show_cursor = self.show_cursor + cache_key = ( row_index, line_no, @@ -829,7 +840,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Get row number and line offset for a given line. Args: - y: Y coordinate relative to screen top. + y: Y coordinate relative to DataTable top. Returns: Line number and line offset within cell. @@ -876,7 +887,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if cache_key in self._line_cache: return self._line_cache[cache_key] - fixed, scrollable = self._render_row( + fixed, scrollable = self._render_line_in_row( row_index, line_no, base_style, From b3ad3d863a0c0d7d8937cf369e9dcaad2773d277 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 14:15:42 +0000 Subject: [PATCH 018/155] Create 2way mapping for row/col keys to row/col indices in DataTable --- src/textual/widgets/_data_table.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d31142a0d..69aac1b00 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -15,6 +15,7 @@ from rich.text import Text, TextType from .. import events, messages from .._cache import LRUCache from .._segment_tools import line_crop +from .._two_way_mapping import TwoWayMapping from .._types import SegmentLines from .._typing import Literal from ..binding import Binding @@ -218,6 +219,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.data: dict[int, list[CellType]] = {} self.row_count = 0 + # Keep tracking of key -> index for rows/cols. + # For a given key, what is the current location of the corresponding row/col? + self._column_locations: TwoWayMapping[ColumnKey, int] = TwoWayMapping({}) + self._row_locations: TwoWayMapping[RowKey, int] = TwoWayMapping({}) + # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ From f983ac308df9c7ed15a0a9eed72e958efcb8e6dd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 14:38:00 +0000 Subject: [PATCH 019/155] Tracking row_key to row_index using internal mapping --- src/textual/widgets/_data_table.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 69aac1b00..bb6665f83 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -45,7 +45,7 @@ class StringKey: def __hash__(self): # If a string is supplied, we use the hash of the string. If no string was # supplied, we use the default hash to ensure uniqueness amongst instances. - return hash(self.value) if self.value is not None else super().__hash__(self) + return hash(self.value) if self.value is not None else id(self) def __eq__(self, other: object) -> bool: # Strings will match Keys containing the same string value. @@ -225,7 +225,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_locations: TwoWayMapping[RowKey, int] = TwoWayMapping({}) # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs - self._y_offsets: list[tuple[RowKey, int]] = [] + # TODO: Update types + self._y_offsets: list[tuple[int, int]] = [] self._row_render_cache: LRUCache[ tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] ] @@ -539,6 +540,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index = self.row_count row_key = RowKey(key) + # Map the key of this row to its current index + self._row_locations[row_key] = row_index + self.data[row_index] = list(cells) self.rows[row_index] = Row(row_key, row_index, height, self._line_no) From 5b14f8996dfe1a4670366154527aa4e5ad9ed68a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 15:37:38 +0000 Subject: [PATCH 020/155] Rows are internally tracked using RowKey in DataTable --- .../{_two_way_mapping.py => _two_way_dict.py} | 2 +- src/textual/widgets/_data_table.py | 59 ++++++++++++------- ...wo_way_mapping.py => test_two_way_dict.py} | 4 +- 3 files changed, 42 insertions(+), 23 deletions(-) rename src/textual/{_two_way_mapping.py => _two_way_dict.py} (97%) rename tests/{test_two_way_mapping.py => test_two_way_dict.py} (91%) diff --git a/src/textual/_two_way_mapping.py b/src/textual/_two_way_dict.py similarity index 97% rename from src/textual/_two_way_mapping.py rename to src/textual/_two_way_dict.py index f3f04f369..00fffb8d4 100644 --- a/src/textual/_two_way_mapping.py +++ b/src/textual/_two_way_dict.py @@ -6,7 +6,7 @@ Key = TypeVar("Key") Value = TypeVar("Value") -class TwoWayMapping(Generic[Key, Value]): +class TwoWayDict(Generic[Key, Value]): """ Wraps two dictionaries and uses them to provide efficient access to both values (given keys) and keys (given values). diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index bb6665f83..57a09f2e8 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -15,7 +15,7 @@ from rich.text import Text, TextType from .. import events, messages from .._cache import LRUCache from .._segment_tools import line_crop -from .._two_way_mapping import TwoWayMapping +from .._two_way_dict import TwoWayDict from .._types import SegmentLines from .._typing import Literal from ..binding import Binding @@ -215,14 +215,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): super().__init__(name=name, id=id, classes=classes) self.columns: list[Column] = [] - self.rows: dict[int, Row] = {} + self.rows: dict[RowKey, Row] = {} self.data: dict[int, list[CellType]] = {} self.row_count = 0 # Keep tracking of key -> index for rows/cols. # For a given key, what is the current location of the corresponding row/col? - self._column_locations: TwoWayMapping[ColumnKey, int] = TwoWayMapping({}) - self._row_locations: TwoWayMapping[RowKey, int] = TwoWayMapping({}) + self._column_locations: TwoWayDict[ColumnKey, int] = TwoWayDict({}) + self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs # TODO: Update types @@ -240,6 +240,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_no = 0 self._require_update_dimensions: bool = False + + # TODO: Check what this is used for and if it needs updated to use keys self._new_rows: set[int] = set() self.show_header = show_header @@ -291,10 +293,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_cache.clear() self._styles_cache.clear() - def get_row_height(self, row_index: int) -> int: - if row_index == -1: + def get_row_height(self, row_key: int | RowKey) -> int: + # TODO: Update to generate header key ourselves instead of -1, + # and remember to update type signature + if row_key == -1: return self.header_height - return self.rows[row_index].height + return self.rows[row_key].height async def on_styles_updated(self, message: messages.StylesUpdated) -> None: self._clear_caches() @@ -422,9 +426,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_cell_region(self, row_index: int, column_index: int) -> Region: """Get the region of the cell at the given coordinate (row_index, column_index)""" - if row_index not in self.rows: + # This IS used to get the cell region under given a cursor coordinate. + # So we don't want to change this to the key approach, but of course we + # need to look up the row_key first now before proceeding. + # TODO: This is pre-existing method, we'll simply map the indices + # over to the row_keys for now, and likely provide a new means of + row_key = self._row_locations.get_key(row_index) + if row_key not in self.rows: return Region(0, 0, 0, 0) - row = self.rows[row_index] + row = self.rows[row_key] x = sum(column.render_width for column in self.columns[:column_index]) width = self.columns[column_index].render_width height = row.height @@ -439,7 +449,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): rows = self.rows if row_index < 0 or row_index >= len(rows): return Region(0, 0, 0, 0) - row = rows[row_index] + + row_key = self._row_locations.get_key(row_index) + row = rows[row_key] row_width = sum(column.render_width for column in self.columns) y = row.y if self.show_header: @@ -544,7 +556,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_locations[row_key] = row_index self.data[row_index] = list(cells) - self.rows[row_index] = Row(row_key, row_index, height, self._line_no) + self.rows[row_key] = Row(row_key, row_index, height, self._line_no) for line_no in range(height): self._y_offsets.append((row_index, line_no)) @@ -722,12 +734,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if is_fixed_style: style += self.get_component_styles("datatable--cursor-fixed").rich_style - cell_key = (row_index, column_index, style, cursor, hover) + # TODO: We can hoist `row_key` lookup waaay up to do it inside `_get_offsets` + # then just pass it through to here instead of the row_index. + row_key = self._row_locations.get_key(row_index) + cell_key = (row_key, column_index, style, cursor, hover) if cell_key not in self._cell_render_cache: style += Style.from_meta({"row": row_index, "column": column_index}) - height = ( - self.header_height if is_header_row else self.rows[row_index].height - ) + height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] lines = self.app.console.render_lines( Padding(cell, (0, 1)), @@ -920,13 +933,19 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def render_line(self, y: int) -> Strip: width, height = self.size scroll_x, scroll_y = self.scroll_offset - fixed_top_row_count = sum( - self.get_row_height(row_index) for row_index in range(self.fixed_rows) + + fixed_row_keys: list[RowKey] = [ + self._row_locations.get_key(row_index) + for row_index in range(self.fixed_rows) + ] + + fixed_rows_height = sum( + self.get_row_height(row_key) for row_key in fixed_row_keys ) if self.show_header: - fixed_top_row_count += self.get_row_height(-1) + fixed_rows_height += self.get_row_height(-1) - if y >= fixed_top_row_count: + if y >= fixed_rows_height: y += scroll_y return self._render_line(y, scroll_x, scroll_x + width, self.rich_style) @@ -943,7 +962,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_fixed_offset(self) -> Spacing: top = self.header_height if self.show_header else 0 top += sum( - self.rows[row_index].height + self.rows[self._row_locations.get_key(row_index)].height for row_index in range(self.fixed_rows) if row_index in self.rows ) diff --git a/tests/test_two_way_mapping.py b/tests/test_two_way_dict.py similarity index 91% rename from tests/test_two_way_mapping.py rename to tests/test_two_way_dict.py index 9a88bdeec..26e1cb58e 100644 --- a/tests/test_two_way_mapping.py +++ b/tests/test_two_way_dict.py @@ -1,11 +1,11 @@ import pytest -from textual._two_way_mapping import TwoWayMapping +from textual._two_way_dict import TwoWayDict @pytest.fixture def map(): - return TwoWayMapping( + return TwoWayDict( { 1: 10, 2: 20, From f338381b731aeb44ea54f570f009fe557b264b0d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 15:47:25 +0000 Subject: [PATCH 021/155] Testing around row key generation, add_rows etc --- tests/test_data_table.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index cf5a4e25d..16567785b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,8 +1,12 @@ +import pytest + from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable -from textual.widgets._data_table import StringKey +from textual.widgets._data_table import StringKey, CellDoesNotExist + +ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] class DataTableApp(App): @@ -41,7 +45,7 @@ async def test_datatable_message_emission(): assert messages == expected_messages table.add_columns("Column0", "Column1") - table.add_rows([["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]]) + table.add_rows(ROWS) # A CellHighlighted is emitted because there were no rows (and # therefore no highlighted cells), but then a row was added, and @@ -150,6 +154,28 @@ async def test_clear(): assert len(table.columns) == 0 +def test_add_rows_generates_keys(): + table = DataTable() + keys = table.add_rows(ROWS) + + # Ensure the keys are returned in order, and there's one for each row + for key, row in zip(keys, range(len(ROWS))): + assert table.rows[key].index == row + + +def test_get_cell_value_returns_value_at_cell(): + table = DataTable() + table.add_rows(ROWS) + assert table.get_cell_value(Coordinate(0, 0)) == "0/0" + + +def test_get_cell_value_exception(): + table = DataTable() + table.add_rows(ROWS) + with pytest.raises(CellDoesNotExist): + table.get_cell_value(Coordinate(9999, 0)) + + def test_key_equals_equivalent_string(): text = "Hello" key = StringKey(text) From 486e580e44cb892500862946b6385398ceb85f94 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 23 Jan 2023 15:51:17 +0000 Subject: [PATCH 022/155] Rewording docstring in DataTable --- src/textual/widgets/_data_table.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 57a09f2e8..ed60569a1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -280,6 +280,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: CellDoesNotExist: If there is no cell with the given coordinate. """ + # TODO: Rename to get_value_at()? + # We need to clearly distinguish between coordinates and cell keys row, column = coordinate try: cell_value = self.data[row][column] @@ -762,8 +764,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: row_index: Index of the row. - line_no: Line number (y-coordinate) within row. 0 is top strip of terminal - cells, 1 is the next, and so on... + line_no: Line number (y-coordinate) within row. 0 is the first strip of + cells in the row, line_no=1 is the next, and so on... base_style: Base style of row. cursor_location: The location of the cursor in the DataTable. hover_location: The location of the hover cursor in the DataTable. From 197a399166c5dd4fa34f9fb2ca910e71f70ce35f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 10:11:13 +0000 Subject: [PATCH 023/155] Update y_offsets to use row_key --- src/textual/widgets/_data_table.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index ed60569a1..0ba73f9fc 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -226,7 +226,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs # TODO: Update types - self._y_offsets: list[tuple[int, int]] = [] + self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] ] @@ -561,7 +561,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.rows[row_key] = Row(row_key, row_index, height, self._line_no) for line_no in range(height): - self._y_offsets.append((row_index, line_no)) + self._y_offsets.append((row_key, line_no)) self.row_count += 1 self._line_no += height @@ -870,13 +870,18 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: Line number and line offset within cell. """ + header_height = self.header_height + y_offsets = self._y_offsets if self.show_header: - if y < self.header_height: - return (-1, y) - y -= self.header_height - if y > len(self._y_offsets): + if y < header_height: + return -1, y + y -= header_height + if y > len(y_offsets): raise LookupError("Y coord {y!r} is greater than total height") - return self._y_offsets[y] + + row_key, y_offset_in_row = y_offsets[y] + row_index = self._row_locations.get(row_key) + return row_index, y_offset_in_row def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: """Render a line in to a list of segments. From 2fbe4e0a793db53507d998eff1fabaf1bf1d62ec Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 10:27:27 +0000 Subject: [PATCH 024/155] Add update counter to cache keys --- src/textual/_two_way_dict.py | 2 ++ src/textual/widgets/_data_table.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/textual/_two_way_dict.py b/src/textual/_two_way_dict.py index 00fffb8d4..1c5839478 100644 --- a/src/textual/_two_way_dict.py +++ b/src/textual/_two_way_dict.py @@ -17,6 +17,8 @@ class TwoWayDict(Generic[Key, Value]): self._reverse: dict[Value, Key] = {value: key for key, value in initial.items()} def __setitem__(self, key: Key, value: Value) -> None: + # TODO: Duplicate values need to be managed to ensure consistency, + # decide on best approach. self._forward.__setitem__(key, value) self._reverse.__setitem__(value, key) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 0ba73f9fc..476d7eb06 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -252,6 +252,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.show_cursor = show_cursor self._show_hover_cursor = False + # TODO: Could track "updates above row_index" instead - better cache efficiency + self._update_count = 0 + @property def hover_row(self) -> int: return self.hover_cell.row @@ -785,6 +788,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_type, show_cursor, self._show_hover_cursor, + self._update_count, ) if cache_key in self._row_render_cache: @@ -913,6 +917,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): base_style, self.cursor_type, self._show_hover_cursor, + self._update_count, ) if cache_key in self._line_cache: return self._line_cache[cache_key] From d1413ee352a79b8b5e0a8f23cee9276893896903 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 10:38:47 +0000 Subject: [PATCH 025/155] Updating test to ensure row_key mapped data is correct --- src/textual/widgets/_data_table.py | 15 +++++++++------ tests/test_table.py | 9 +++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 476d7eb06..12d1aadf1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -216,7 +216,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.columns: list[Column] = [] self.rows: dict[RowKey, Row] = {} - self.data: dict[int, list[CellType]] = {} + self.data: dict[RowKey, list[CellType]] = {} self.row_count = 0 # Keep tracking of key -> index for rows/cols. @@ -285,9 +285,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ # TODO: Rename to get_value_at()? # We need to clearly distinguish between coordinates and cell keys - row, column = coordinate + row_index, column_index = coordinate + row_key = self._row_locations.get_key(row_index) try: - cell_value = self.data[row][column] + cell_value = self.data[row_key][column_index] except KeyError: raise CellDoesNotExist(f"No cell exists at {coordinate!r}") from None return cell_value @@ -366,7 +367,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _highlight_row(self, row_index: int) -> None: """Apply highlighting to the row at the given index, and emit event.""" self.refresh_row(row_index) - if row_index in self.data: + is_valid_row = row_index < len(self.data) + if is_valid_row: self.emit_no_wait(DataTable.RowHighlighted(self, row_index)) def _highlight_column(self, column_index: int) -> None: @@ -560,7 +562,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Map the key of this row to its current index self._row_locations[row_key] = row_index - self.data[row_index] = list(cells) + self.data[row_key] = list(cells) self.rows[row_key] = Row(row_key, row_index, height, self._line_no) for line_no in range(height): @@ -686,7 +688,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = [column.label for column in self.columns] return row - data = self.data.get(row_index) + row_key = self._row_locations.get_key(row_index) + data = self.data.get(row_key) empty = Text() if data is None: return [empty for _ in self.columns] diff --git a/tests/test_table.py b/tests/test_table.py index 827242e85..d57be5a60 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -19,9 +19,9 @@ async def test_table_clear() -> None: table = app.query_one(DataTable) table.add_columns("foo", "bar") assert table.row_count == 0 - table.add_row("Hello", "World!") + row_key = table.add_row("Hello", "World!") assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] - assert table.data == {0: ["Hello", "World!"]} + assert table.data == {row_key: ["Hello", "World!"]} assert table.row_count == 1 table.clear() assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] @@ -37,15 +37,16 @@ async def test_table_clear_with_columns() -> None: table = app.query_one(DataTable) table.add_columns("foo", "bar") assert table.row_count == 0 - table.add_row("Hello", "World!") + row_key = table.add_row("Hello", "World!") assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] - assert table.data == {0: ["Hello", "World!"]} + assert table.data == {row_key: ["Hello", "World!"]} assert table.row_count == 1 table.clear(columns=True) assert [col.label for col in table.columns] == [] assert table.data == {} assert table.row_count == 0 + async def test_table_add_row() -> None: app = TableApp() From 12d429dbd0c9e86fb45251c17dbb9e15ad6624cf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 11:33:35 +0000 Subject: [PATCH 026/155] Replace DataTable row_count with property, test improvements --- src/textual/widgets/_data_table.py | 7 +-- tests/test_data_table.py | 72 +++++++++++++++++++++++++++--- tests/test_table.py | 67 --------------------------- 3 files changed, 69 insertions(+), 77 deletions(-) delete mode 100644 tests/test_table.py diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 12d1aadf1..7e9d86416 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -217,7 +217,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.columns: list[Column] = [] self.rows: dict[RowKey, Row] = {} self.data: dict[RowKey, list[CellType]] = {} - self.row_count = 0 # Keep tracking of key -> index for rows/cols. # For a given key, what is the current location of the corresponding row/col? @@ -271,6 +270,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def cursor_column(self) -> int: return self.cursor_cell.column + @property + def row_count(self) -> int: + return len(self.rows) + def get_cell_value(self, coordinate: Coordinate) -> CellType: """Get the value from the cell at the given coordinate. @@ -485,7 +488,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: columns: Also clear the columns. Defaults to False. """ - self.row_count = 0 self._clear_caches() self._y_offsets.clear() self.data.clear() @@ -568,7 +570,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): for line_no in range(height): self._y_offsets.append((row_key, line_no)) - self.row_count += 1 self._line_no += height self._new_rows.add(row_index) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 16567785b..29413497c 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,10 +1,11 @@ import pytest +from rich.text import Text from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable -from textual.widgets._data_table import StringKey, CellDoesNotExist +from textual.widgets._data_table import StringKey, CellDoesNotExist, RowKey, Row ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] @@ -126,6 +127,44 @@ async def test_datatable_message_emission(): assert messages == expected_messages +async def test_add_rows(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + row_keys = table.add_rows(ROWS) + # We're given a key for each row + assert len(row_keys) == len(ROWS) + assert len(row_keys) == len(table.data) + assert table.row_count == len(ROWS) + # Each key can be used to fetch a row from the DataTable + assert all(key in table.data for key in row_keys) + # Ensure the keys are returned *in order*, and there's one for each row + for key, row in zip(row_keys, range(len(ROWS))): + assert table.rows[key].index == row + + +async def test_add_data_user_defined_keys(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + algernon_key = table.add_row(*ROWS[0], key="algernon") + table.add_row(*ROWS[1], key="charlie") + auto_key = table.add_row(*ROWS[2]) + + assert algernon_key == "algernon" + # We get a RowKey object back, but we can use our own string *or* this object + # to find the row we're looking for, they're considered equivalent for lookups. + assert isinstance(algernon_key, RowKey) + assert table.data[algernon_key] == ROWS[0] + assert table.data["algernon"] == ROWS[0] + assert table.data["charlie"] == ROWS[1] + assert table.data[auto_key] == ROWS[2] + + first_row = Row(algernon_key, index=0, height=1, y=0) + assert table.rows[algernon_key] == first_row + assert table.rows["algernon"] == first_row + + async def test_clear(): app = DataTableApp() async with app.run_test(): @@ -147,6 +186,7 @@ async def test_clear(): # Ensure that the table has been cleared assert table.data == {} assert table.rows == {} + assert table.row_count == 0 assert len(table.columns) == 1 # Clearing the columns too @@ -154,13 +194,31 @@ async def test_clear(): assert len(table.columns) == 0 -def test_add_rows_generates_keys(): - table = DataTable() - keys = table.add_rows(ROWS) +async def test_column_labels() -> None: + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("1", "2", "3") + assert [col.label for col in table.columns] == [Text("1"), Text("2"), Text("3")] - # Ensure the keys are returned in order, and there's one for each row - for key, row in zip(keys, range(len(ROWS))): - assert table.rows[key].index == row + +async def test_row_widths() -> None: + app = DataTableApp() + async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_columns("foo", "bar") + + assert table.columns[0].width == 3 + assert table.columns[1].width == 3 + table.add_row("Hello", "World!") + await pilot.pause() + assert table.columns[0].content_width == 5 + assert table.columns[1].content_width == 6 + + table.add_row("Hello World!!!", "fo") + await pilot.pause() + assert table.columns[0].content_width == 14 + assert table.columns[1].content_width == 6 def test_get_cell_value_returns_value_at_cell(): diff --git a/tests/test_table.py b/tests/test_table.py deleted file mode 100644 index d57be5a60..000000000 --- a/tests/test_table.py +++ /dev/null @@ -1,67 +0,0 @@ -import asyncio - -from rich.text import Text - -from textual.app import App, ComposeResult -from textual.widgets import DataTable - - -class TableApp(App): - def compose(self) -> ComposeResult: - yield DataTable() - - -async def test_table_clear() -> None: - """Check DataTable.clear""" - - app = TableApp() - async with app.run_test() as pilot: - table = app.query_one(DataTable) - table.add_columns("foo", "bar") - assert table.row_count == 0 - row_key = table.add_row("Hello", "World!") - assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] - assert table.data == {row_key: ["Hello", "World!"]} - assert table.row_count == 1 - table.clear() - assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] - assert table.data == {} - assert table.row_count == 0 - - -async def test_table_clear_with_columns() -> None: - """Check DataTable.clear(columns=True)""" - - app = TableApp() - async with app.run_test() as pilot: - table = app.query_one(DataTable) - table.add_columns("foo", "bar") - assert table.row_count == 0 - row_key = table.add_row("Hello", "World!") - assert [col.label for col in table.columns] == [Text("foo"), Text("bar")] - assert table.data == {row_key: ["Hello", "World!"]} - assert table.row_count == 1 - table.clear(columns=True) - assert [col.label for col in table.columns] == [] - assert table.data == {} - assert table.row_count == 0 - - -async def test_table_add_row() -> None: - - app = TableApp() - async with app.run_test(): - table = app.query_one(DataTable) - table.add_columns("foo", "bar") - - assert table.columns[0].width == 3 - assert table.columns[1].width == 3 - table.add_row("Hello", "World!") - await asyncio.sleep(0) - assert table.columns[0].content_width == 5 - assert table.columns[1].content_width == 6 - - table.add_row("Hello World!!!", "fo") - await asyncio.sleep(0) - assert table.columns[0].content_width == 14 - assert table.columns[1].content_width == 6 From a4db3426b80b6306dc99f884081c04476cf842fc Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 11:35:30 +0000 Subject: [PATCH 027/155] Rename a DataTable test --- tests/test_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 29413497c..0c9f79b6c 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -143,7 +143,7 @@ async def test_add_rows(): assert table.rows[key].index == row -async def test_add_data_user_defined_keys(): +async def test_add_rows_user_defined_keys(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) From 6ea3380e8feba0e88745460011da3636a26d8f34 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 11:36:24 +0000 Subject: [PATCH 028/155] Increase a pause in a Tree test --- tests/tree/test_tree_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index f271d4e42..f44ab0f2c 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -61,7 +61,7 @@ async def test_tree_node_collapsed_message() -> None: """Collapsing a node should result in a collapsed message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "enter") - await pilot.pause(2 / 100) + await pilot.pause(4 / 100) assert pilot.app.messages == [ "NodeExpanded", "NodeSelected", From 27250741948862dc442c071bf3ef006319d36e1e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 11:43:13 +0000 Subject: [PATCH 029/155] Sorting imports in tests for DataTable --- tests/tree/test_tree_messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index f44ab0f2c..ad01e596e 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -1,9 +1,10 @@ from __future__ import annotations from typing import Any + from textual.app import App, ComposeResult -from textual.widgets import Tree from textual.message import Message +from textual.widgets import Tree class MyTree(Tree[None]): From 0eb38e79da732373c1310a9bf5d686e03f3e73b1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 12:47:43 +0000 Subject: [PATCH 030/155] Add test for adding columns to data table --- tests/test_data_table.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 0c9f79b6c..aa2d84468 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -165,6 +165,15 @@ async def test_add_rows_user_defined_keys(): assert table.rows["algernon"] == first_row +async def test_add_columns(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column_keys = table.add_columns("1", "2", "3") + assert len(column_keys) == 3 + assert len(table.columns) == 3 + + async def test_clear(): app = DataTableApp() async with app.run_test(): @@ -202,7 +211,7 @@ async def test_column_labels() -> None: assert [col.label for col in table.columns] == [Text("1"), Text("2"), Text("3")] -async def test_row_widths() -> None: +async def test_column_widths() -> None: app = DataTableApp() async with app.run_test() as pilot: table = app.query_one(DataTable) From b5e5a66e321d604a7469c51c7461e47f18db46bd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 12:52:31 +0000 Subject: [PATCH 031/155] Some additional tests around row/col keys in DataTable --- tests/test_data_table.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index aa2d84468..e1203f55d 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -5,7 +5,13 @@ from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable -from textual.widgets._data_table import StringKey, CellDoesNotExist, RowKey, Row +from textual.widgets._data_table import ( + StringKey, + CellDoesNotExist, + RowKey, + Row, + ColumnKey, +) ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] @@ -174,6 +180,16 @@ async def test_add_columns(): assert len(table.columns) == 3 +# TODO: Ensure we can use the key to retrieve the column. +async def test_add_columns_user_defined_keys(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + key = table.add_column("Column", key="donut") + assert key == "donut" + assert key == key + + async def test_clear(): app = DataTableApp() async with app.run_test(): @@ -257,6 +273,14 @@ def test_key_doesnt_match_non_equal_string(): assert hash(key) != hash(text) +def test_key_equals_self(): + row_key = RowKey() + column_key = ColumnKey() + assert row_key == row_key + assert column_key == column_key + assert row_key != column_key + + def test_key_string_lookup(): # Indirectly covered by other tests, but let's explicitly document # in tests how we intend for the keys to work for cache lookups. From d5c7db41f37a362d1c0d5dcc3f45d1ab17a705ed Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 13:03:39 +0000 Subject: [PATCH 032/155] Add sleeps after key presses --- src/textual/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 068b94e0b..f726e5dbf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -796,7 +796,6 @@ class App(Generic[ReturnType], DOMNode): app = self driver = app._driver assert driver is not None - await asyncio.sleep(0.02) for key in keys: if key == "_": print("(pause 50ms)") @@ -825,11 +824,12 @@ class App(Generic[ReturnType], DOMNode): # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race # condition between widget-level key handling and app/screen level handling. # More information here: https://github.com/Textualize/textual/issues/1009 - # This conditional sleep can be removed after that issue is closed. + # This conditional sleep can be removed after that issue is resolved. if key == "tab": await asyncio.sleep(0.05) await asyncio.sleep(0.025) await app._animator.wait_for_idle() + await asyncio.sleep(2 / 100) @asynccontextmanager async def run_test( From 2d498d516d8ed10c8009789e3db5edb448d5afc7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 13:04:49 +0000 Subject: [PATCH 033/155] Remove pauses from DataTable tests --- tests/test_data_table.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index e1203f55d..9092c7909 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -58,52 +58,44 @@ async def test_datatable_message_emission(): # therefore no highlighted cells), but then a row was added, and # so the cell at (0, 0) became highlighted. expected_messages.append("CellHighlighted") - await pilot.pause(2 / 100) assert messages == expected_messages # Pressing Enter when the cursor is on a cell emits a CellSelected await pilot.press("enter") expected_messages.append("CellSelected") - await pilot.pause(2 / 100) assert messages == expected_messages # Moving the cursor left and up when the cursor is at origin # emits no events, since the cursor doesn't move at all. await pilot.press("left", "up") - await pilot.pause(2 / 100) assert messages == expected_messages # ROW CURSOR # Switch over to the row cursor... should emit a `RowHighlighted` table.cursor_type = "row" expected_messages.append("RowHighlighted") - await pilot.pause(2 / 100) assert messages == expected_messages # Select the row... await pilot.press("enter") expected_messages.append("RowSelected") - await pilot.pause(2 / 100) assert messages == expected_messages # COLUMN CURSOR # Switching to the column cursor emits a `ColumnHighlighted` table.cursor_type = "column" expected_messages.append("ColumnHighlighted") - await pilot.pause(2 / 100) assert messages == expected_messages # Select the column... await pilot.press("enter") expected_messages.append("ColumnSelected") - await pilot.pause(2 / 100) assert messages == expected_messages # NONE CURSOR # No messages get emitted at all... table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") - await pilot.pause(2 / 100) # No new messages since cursor not visible assert messages == expected_messages @@ -113,7 +105,6 @@ async def test_datatable_message_emission(): table.show_cursor = False table.cursor_type = "cell" await pilot.press("up", "down", "left", "right", "enter") - await pilot.pause(2 / 100) # No new messages since show_cursor = False assert messages == expected_messages @@ -121,7 +112,6 @@ async def test_datatable_message_emission(): # message should be emitted for highlighting the cell. table.show_cursor = True expected_messages.append("CellHighlighted") - await pilot.pause(2 / 100) assert messages == expected_messages # Likewise, if the cursor_type is "none", and we change the @@ -129,7 +119,6 @@ async def test_datatable_message_emission(): # the cursor is still not visible to the user. table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") - await pilot.pause(2 / 100) assert messages == expected_messages From a958c666712570a6ef252d05700a31eac7eafe9a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 14:17:29 +0000 Subject: [PATCH 034/155] Keys for columns in the DataTable --- src/textual/widgets/_data_table.py | 106 +++++++++++++++++------------ tests/test_data_table.py | 22 +++--- 2 files changed, 76 insertions(+), 52 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 7e9d86416..d17fd6fe9 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -214,7 +214,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) -> None: super().__init__(name=name, id=id, classes=classes) - self.columns: list[Column] = [] + self.columns: dict[ColumnKey, Column] = {} self.rows: dict[RowKey, Row] = {} self.data: dict[RowKey, list[CellType]] = {} @@ -224,7 +224,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs - # TODO: Update types self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] @@ -421,13 +420,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Called to recalculate the virtual (scrollable) size.""" for row_index in new_rows: for column, renderable in zip( - self.columns, self._get_row_renderables(row_index) + self.columns.values(), self._get_row_renderables(row_index) ): content_width = measure(self.app.console, renderable, 1) column.content_width = max(column.content_width, content_width) self._clear_caches() - total_width = sum(column.render_width for column in self.columns) + total_width = sum(column.render_width for column in self.columns.values()) header_height = self.header_height if self.show_header else 0 self.virtual_size = Size( total_width, @@ -435,18 +434,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) def _get_cell_region(self, row_index: int, column_index: int) -> Region: - """Get the region of the cell at the given coordinate (row_index, column_index)""" - # This IS used to get the cell region under given a cursor coordinate. - # So we don't want to change this to the key approach, but of course we - # need to look up the row_key first now before proceeding. - # TODO: This is pre-existing method, we'll simply map the indices - # over to the row_keys for now, and likely provide a new means of - row_key = self._row_locations.get_key(row_index) - if row_key not in self.rows: + """Get the region of the cell at the given spatial coordinate.""" + valid_row = 0 <= row_index < len(self.rows) + valid_column = 0 <= column_index < len(self.columns) + valid_cell = valid_row and valid_column + if not valid_cell: return Region(0, 0, 0, 0) + + row_key = self._row_locations.get_key(row_index) row = self.rows[row_key] - x = sum(column.render_width for column in self.columns[:column_index]) - width = self.columns[column_index].render_width + + # The x-coordinate of a cell is the sum of widths of cells to the left. + x = sum(column.render_width for column in self._ordered_columns[:column_index]) + column_key = self._column_locations.get_key(column_index) + width = self.columns[column_key].render_width height = row.height y = row.y if self.show_header: @@ -457,12 +458,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_row_region(self, row_index: int) -> Region: """Get the region of the row at the given index.""" rows = self.rows - if row_index < 0 or row_index >= len(rows): + valid_row = 0 <= row_index < len(rows) + if not valid_row: return Region(0, 0, 0, 0) row_key = self._row_locations.get_key(row_index) row = rows[row_key] - row_width = sum(column.render_width for column in self.columns) + row_width = sum(column.render_width for column in self.columns.values()) y = row.y if self.show_header: y += self.header_height @@ -472,11 +474,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_column_region(self, column_index: int) -> Region: """Get the region of the column at the given index.""" columns = self.columns - if column_index < 0 or column_index >= len(columns): + valid_column = 0 <= column_index < len(columns) + if not valid_column: return Region(0, 0, 0, 0) - x = sum(column.render_width for column in self.columns[:column_index]) - width = columns[column_index].render_width + x = sum(column.render_width for column in self._ordered_columns[:column_index]) + column_key = self._column_locations.get_key(column_index) + width = columns[column_key].render_width header_height = self.header_height if self.show_header else 0 height = len(self._y_offsets) + header_height full_column_region = Region(x, 0, width, height) @@ -518,13 +522,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): text_label = Text.from_markup(label) if isinstance(label, str) else label column_key = ColumnKey(key) + column_index = len(self.columns) content_width = measure(self.app.console, text_label, 1) if width is None: column = Column( column_key, text_label, content_width, - index=len(self.columns), + index=column_index, content_width=content_width, auto_width=True, ) @@ -534,10 +539,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): text_label, width, content_width=content_width, - index=len(self.columns), + index=column_index, ) - self.columns.append(column) + self.columns[column_key] = column + self._column_locations[column_key] = column_index self._require_update_dimensions = True self.check_idle() @@ -675,6 +681,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): region = region.translate(-self.scroll_offset) self.refresh(region) + @property + def _ordered_columns(self) -> list[Column]: + column_indices = range(len(self.columns)) + column_keys = [ + self._column_locations.get_key(index) for index in column_indices + ] + ordered_columns = [self.columns.get(key) for key in column_keys] + return ordered_columns + def _get_row_renderables(self, row_index: int) -> list[RenderableType]: """Get renderables for the given row. @@ -686,18 +701,18 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ if row_index == -1: - row = [column.label for column in self.columns] + row = [column.label for column in self._ordered_columns] return row row_key = self._row_locations.get_key(row_index) - data = self.data.get(row_key) + row = self.data.get(row_key) empty = Text() - if data is None: + if row is None: return [empty for _ in self.columns] else: return [ Text() if datum is None else default_cell_formatter(datum) or empty - for datum, _ in zip_longest(data, range(len(self.columns))) + for datum, _ in zip_longest(row, range(len(self.columns))) ] def _render_cell( @@ -761,7 +776,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _render_line_in_row( self, - row_index: int, + row_key: RowKey, line_no: int, base_style: Style, cursor_location: Coordinate, @@ -770,7 +785,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Render a row in to lines for each cell. Args: - row_index: Index of the row. + row_key: The identifying key for this row. line_no: Line number (y-coordinate) within row. 0 is the first strip of cells in the row, line_no=1 is the next, and so on... base_style: Base style of row. @@ -784,7 +799,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): show_cursor = self.show_cursor cache_key = ( - row_index, + row_key, line_no, base_style, cursor_location, @@ -821,11 +836,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): else: return False + row_index = self._row_locations.get(row_key, -1) if self.fixed_columns: fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style += Style.from_meta({"fixed": True}) fixed_row = [] - for column in self.columns[: self.fixed_columns]: + for column in self._ordered_columns[: self.fixed_columns]: cell_location = Coordinate(row_index, column.index) fixed_cell_lines = render_cell( row_index, @@ -841,7 +857,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): else: fixed_row = [] - if row_index == -1: + if row_key is None: row_style = self.get_component_styles("datatable--header").rich_style else: if self.zebra_stripes: @@ -853,7 +869,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_style = base_style scrollable_row = [] - for column in self.columns: + for column in self.columns.values(): cell_location = Coordinate(row_index, column.index) cell_lines = render_cell( row_index, @@ -869,27 +885,25 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_render_cache[cache_key] = row_pair return row_pair - def _get_offsets(self, y: int) -> tuple[int, int]: - """Get row number and line offset for a given line. + def _get_offsets(self, y: int) -> tuple[RowKey | None, int]: + """Get row key and line offset for a given line. Args: y: Y coordinate relative to DataTable top. Returns: - Line number and line offset within cell. + Row key and line (y) offset within cell. """ header_height = self.header_height y_offsets = self._y_offsets if self.show_header: if y < header_height: - return -1, y + return None, y y -= header_height if y > len(y_offsets): raise LookupError("Y coord {y!r} is greater than total height") - row_key, y_offset_in_row = y_offsets[y] - row_index = self._row_locations.get(row_key) - return row_index, y_offset_in_row + return y_offsets[y] def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: """Render a line in to a list of segments. @@ -907,7 +921,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): width = self.size.width try: - row_index, line_no = self._get_offsets(y) + row_key, y_offset_in_row = self._get_offsets(y) except LookupError: return Strip.blank(width, base_style) @@ -927,14 +941,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self._line_cache[cache_key] fixed, scrollable = self._render_line_in_row( - row_index, - line_no, + row_key, + y_offset_in_row, base_style, cursor_location=self.cursor_cell, hover_location=self.hover_cell, ) fixed_width = sum( - column.render_width for column in self.columns[: self.fixed_columns] + column.render_width + for column in self._ordered_columns[: self.fixed_columns] ) fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] @@ -982,7 +997,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): for row_index in range(self.fixed_rows) if row_index in self.rows ) - left = sum(column.render_width for column in self.columns[: self.fixed_columns]) + left = sum( + column.render_width + for column in self._ordered_columns[: self.fixed_columns] + ) return Spacing(top, 0, 0, left) def _scroll_cursor_into_view(self, animate: bool = False) -> None: diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 9092c7909..9bac2cf8b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -58,6 +58,7 @@ async def test_datatable_message_emission(): # therefore no highlighted cells), but then a row was added, and # so the cell at (0, 0) became highlighted. expected_messages.append("CellHighlighted") + await pilot.pause(2 / 100) assert messages == expected_messages # Pressing Enter when the cursor is on a cell emits a CellSelected @@ -74,6 +75,7 @@ async def test_datatable_message_emission(): # Switch over to the row cursor... should emit a `RowHighlighted` table.cursor_type = "row" expected_messages.append("RowHighlighted") + await pilot.pause(2 / 100) assert messages == expected_messages # Select the row... @@ -85,6 +87,7 @@ async def test_datatable_message_emission(): # Switching to the column cursor emits a `ColumnHighlighted` table.cursor_type = "column" expected_messages.append("ColumnHighlighted") + await pilot.pause(2 / 100) assert messages == expected_messages # Select the column... @@ -112,6 +115,7 @@ async def test_datatable_message_emission(): # message should be emitted for highlighting the cell. table.show_cursor = True expected_messages.append("CellHighlighted") + await pilot.pause(2 / 100) assert messages == expected_messages # Likewise, if the cursor_type is "none", and we change the @@ -213,26 +217,28 @@ async def test_column_labels() -> None: async with app.run_test(): table = app.query_one(DataTable) table.add_columns("1", "2", "3") - assert [col.label for col in table.columns] == [Text("1"), Text("2"), Text("3")] + actual_labels = [col.label for col in table.columns.values()] + expected_labels = [Text("1"), Text("2"), Text("3")] + assert actual_labels == expected_labels async def test_column_widths() -> None: app = DataTableApp() async with app.run_test() as pilot: table = app.query_one(DataTable) - table.add_columns("foo", "bar") + foo, bar = table.add_columns("foo", "bar") - assert table.columns[0].width == 3 - assert table.columns[1].width == 3 + assert table.columns[foo].width == 3 + assert table.columns[bar].width == 3 table.add_row("Hello", "World!") await pilot.pause() - assert table.columns[0].content_width == 5 - assert table.columns[1].content_width == 6 + assert table.columns[foo].content_width == 5 + assert table.columns[bar].content_width == 6 table.add_row("Hello World!!!", "fo") await pilot.pause() - assert table.columns[0].content_width == 14 - assert table.columns[1].content_width == 6 + assert table.columns[foo].content_width == 14 + assert table.columns[bar].content_width == 6 def test_get_cell_value_returns_value_at_cell(): From 37dc16641ad4306618c33b8971339ac7f91c9963 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 14:51:22 +0000 Subject: [PATCH 035/155] Cache DataTable cells on keys instead of indices --- src/textual/widgets/_data_table.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d17fd6fe9..e28422a60 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -691,7 +691,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return ordered_columns def _get_row_renderables(self, row_index: int) -> list[RenderableType]: - """Get renderables for the given row. + """Get renderables for the row currently at the given row index. Args: row_index: Index of the row. @@ -761,8 +761,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # TODO: We can hoist `row_key` lookup waaay up to do it inside `_get_offsets` # then just pass it through to here instead of the row_index. row_key = self._row_locations.get_key(row_index) - cell_key = (row_key, column_index, style, cursor, hover) - if cell_key not in self._cell_render_cache: + column_key = self._column_locations.get_key(column_index) + cell_cache_key = (row_key, column_key, style, cursor, hover) + if cell_cache_key not in self._cell_render_cache: style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] @@ -771,8 +772,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.app.console.options.update_dimensions(width, height), style=style, ) - self._cell_render_cache[cell_key] = lines - return self._cell_render_cache[cell_key] + self._cell_render_cache[cell_cache_key] = lines + return self._cell_render_cache[cell_cache_key] def _render_line_in_row( self, @@ -813,8 +814,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if cache_key in self._row_render_cache: return self._row_render_cache[cache_key] - render_cell = self._render_cell - def _should_highlight( cursor_location: Coordinate, cell_location: Coordinate, @@ -837,6 +836,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return False row_index = self._row_locations.get(row_key, -1) + render_cell = self._render_cell if self.fixed_columns: fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style += Style.from_meta({"fixed": True}) From de8b59fb01c3a150faec992216ba03b56a32e6bb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 15:35:21 +0000 Subject: [PATCH 036/155] Using keys to index into the data of a DataTable --- src/textual/widgets/_data_table.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e28422a60..414fb1031 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -216,7 +216,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.columns: dict[ColumnKey, Column] = {} self.rows: dict[RowKey, Row] = {} - self.data: dict[RowKey, list[CellType]] = {} + self.data: dict[RowKey, dict[ColumnKey, CellType]] = {} # Keep tracking of key -> index for rows/cols. # For a given key, what is the current location of the corresponding row/col? @@ -289,8 +289,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # We need to clearly distinguish between coordinates and cell keys row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) + column_key = self._column_locations.get_key(column_index) try: - cell_value = self.data[row_key][column_index] + cell_value = self.data[row_key][column_key] except KeyError: raise CellDoesNotExist(f"No cell exists at {coordinate!r}") from None return cell_value @@ -570,7 +571,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Map the key of this row to its current index self._row_locations[row_key] = row_index - self.data[row_key] = list(cells) + self.data[row_key] = { + column.key: cell for column, cell in zip(self._ordered_columns, cells) + } self.rows[row_key] = Row(row_key, row_index, height, self._line_no) for line_no in range(height): @@ -700,19 +703,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): List of renderables """ + ordered_columns = self._ordered_columns if row_index == -1: - row = [column.label for column in self._ordered_columns] + row = [column.label for column in ordered_columns] return row + # Ensure we order the cells in the row based on current column ordering row_key = self._row_locations.get_key(row_index) - row = self.data.get(row_key) + cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key) + ordered_row: list[CellType] = [] + for column in ordered_columns: + cell = cell_mapping[column.key] + ordered_row.append(cell) + empty = Text() - if row is None: + if ordered_row is None: return [empty for _ in self.columns] else: return [ Text() if datum is None else default_cell_formatter(datum) or empty - for datum, _ in zip_longest(row, range(len(self.columns))) + for datum, _ in zip_longest(ordered_row, range(len(self.columns))) ] def _render_cell( From 3b1f8693009cc910faad092546fcf69ac4f2f079 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 16:00:53 +0000 Subject: [PATCH 037/155] Update tests to support keyed rows --- src/textual/widgets/_data_table.py | 7 ++++- tests/test_data_table.py | 41 ++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 414fb1031..f408bbfc0 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -571,8 +571,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Map the key of this row to its current index self._row_locations[row_key] = row_index + # TODO: If there are no columns, do we generate them here? + # If we don't do this, users will be required to call add_column(s) + # Before they call add_row. + self.data[row_key] = { - column.key: cell for column, cell in zip(self._ordered_columns, cells) + column.key: cell + for column, cell in zip_longest(self._ordered_columns, cells) } self.rows[row_key] = Row(row_key, row_index, height, self._line_no) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 9bac2cf8b..1566b2237 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -130,6 +130,7 @@ async def test_add_rows(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) + table.add_columns("A", "B") row_keys = table.add_rows(ROWS) # We're given a key for each row assert len(row_keys) == len(ROWS) @@ -146,6 +147,7 @@ async def test_add_rows_user_defined_keys(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) + key_a, key_b = table.add_columns("A", "B") algernon_key = table.add_row(*ROWS[0], key="algernon") table.add_row(*ROWS[1], key="charlie") auto_key = table.add_row(*ROWS[2]) @@ -154,10 +156,17 @@ async def test_add_rows_user_defined_keys(): # We get a RowKey object back, but we can use our own string *or* this object # to find the row we're looking for, they're considered equivalent for lookups. assert isinstance(algernon_key, RowKey) - assert table.data[algernon_key] == ROWS[0] - assert table.data["algernon"] == ROWS[0] - assert table.data["charlie"] == ROWS[1] - assert table.data[auto_key] == ROWS[2] + + # Ensure the data in the table is mapped as expected + first_row = {key_a: ROWS[0][0], key_b: ROWS[0][1]} + assert table.data[algernon_key] == first_row + assert table.data["algernon"] == first_row + + second_row = {key_a: ROWS[1][0], key_b: ROWS[1][1]} + assert table.data["charlie"] == second_row + + third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} + assert table.data[auto_key] == third_row first_row = Row(algernon_key, index=0, height=1, y=0) assert table.rows[algernon_key] == first_row @@ -241,17 +250,23 @@ async def test_column_widths() -> None: assert table.columns[bar].content_width == 6 -def test_get_cell_value_returns_value_at_cell(): - table = DataTable() - table.add_rows(ROWS) - assert table.get_cell_value(Coordinate(0, 0)) == "0/0" +async def test_get_cell_value_returns_value_at_cell(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("A", "B") + table.add_rows(ROWS) + assert table.get_cell_value(Coordinate(0, 0)) == "0/0" -def test_get_cell_value_exception(): - table = DataTable() - table.add_rows(ROWS) - with pytest.raises(CellDoesNotExist): - table.get_cell_value(Coordinate(9999, 0)) +async def test_get_cell_value_exception(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("A", "B") + table.add_rows(ROWS) + with pytest.raises(CellDoesNotExist): + table.get_cell_value(Coordinate(9999, 0)) def test_key_equals_equivalent_string(): From 26d493910004cde307edb0c7adf6484f25b7c648 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 24 Jan 2023 16:26:02 +0000 Subject: [PATCH 038/155] Methods for updating DataTable cells --- src/textual/widgets/_data_table.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f408bbfc0..35ecb8774 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -273,6 +273,24 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def row_count(self) -> int: return len(self.rows) + def update_cell( + self, row_key: RowKey | str, column_key: ColumnKey | str, value: CellType + ) -> None: + self.data[row_key][column_key] = value + self._update_count += 1 + + row_index = self._row_locations.get(row_key) + column_index = self._column_locations.get(column_key) + self.refresh_cell(row_index, column_index) + + def update_coordinate(self, coordinate: Coordinate, value: CellType) -> None: + row, column = coordinate + row_key = self._row_locations.get_key(row) + column_key = self._column_locations.get_key(column) + self.data[row_key][column_key] = value + self._update_count += 1 + self.refresh_cell(row, column) + def get_cell_value(self, coordinate: Coordinate) -> CellType: """Get the value from the cell at the given coordinate. @@ -777,7 +795,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # then just pass it through to here instead of the row_index. row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) - cell_cache_key = (row_key, column_key, style, cursor, hover) + cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) if cell_cache_key not in self._cell_render_cache: style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height From c2ee4149d7498e9f61d234f7560a2d6b30e3c409 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 25 Jan 2023 13:42:44 +0000 Subject: [PATCH 039/155] Ensure key-based meta is attached to segments for hover styles --- src/textual/widgets/_data_table.py | 44 +++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 35ecb8774..f9c2d26b5 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1,8 +1,9 @@ from __future__ import annotations +import functools from dataclasses import dataclass, field from itertools import chain, zip_longest -from typing import ClassVar, Generic, Iterable, TypeVar, cast, NamedTuple +from typing import ClassVar, Generic, Iterable, TypeVar, cast, NamedTuple, Callable import rich.repr from rich.console import RenderableType @@ -36,6 +37,7 @@ class CellDoesNotExist(Exception): pass +@functools.total_ordering class StringKey: value: str | None @@ -52,6 +54,11 @@ class StringKey: # Otherwise, you'll need to supply the exact same key object. return hash(self) == hash(other) + def __lt__(self, other): + if isinstance(other, str): + return self.value < other + return self.value < other.value + class RowKey(StringKey): pass @@ -364,7 +371,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Refresh the old and the new cell, and emit the appropriate # message to tell users of the newly highlighted row/cell/column. if self.cursor_type == "cell": + print(f"refreshing {old_coordinate}") self.refresh_cell(*old_coordinate) + print(f"highlighting {new_coordinate}") self._highlight_cell(new_coordinate) elif self.cursor_type == "row": self.refresh_row(old_coordinate.row) @@ -797,7 +806,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key = self._column_locations.get_key(column_index) cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) if cell_cache_key not in self._cell_render_cache: - style += Style.from_meta({"row": row_index, "column": column_index}) + if not is_header_row: + style += Style.from_meta( + {"row_key": row_key.value, "column_key": column_key.value} + ) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] lines = self.app.console.render_lines( @@ -1019,7 +1031,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): meta = event.style.meta if meta and self.show_cursor and self.cursor_type != "none": try: - self.hover_cell = Coordinate(meta["row"], meta["column"]) + row_key = meta["row_key"] + row_index = self._row_locations.get(row_key) + column_key = meta["column_key"] + column_index = self._column_locations.get(column_key) + # print(row_key, column_key, row_index, column_index) + self.hover_cell = Coordinate(row_index, column_index) except KeyError: pass @@ -1036,6 +1053,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) return Spacing(top, 0, 0, left) + def sort_columns( + self, key: Callable[[ColumnKey | str], str] = None, reverse: bool = False + ) -> None: + ordered_keys = sorted(self.columns.keys(), key=key, reverse=reverse) + self.cursor_cell = Coordinate(0, 0) + self.hover_cell = Coordinate(0, 0) + self.columns = {key: self.columns.get(key) for key in ordered_keys} + self._column_locations = TwoWayDict( + {key: new_index for new_index, key in enumerate(ordered_keys)} + ) + self._update_count += 1 + self._clear_caches() + self.refresh() + def _scroll_cursor_into_view(self, animate: bool = False) -> None: fixed_offset = self._get_fixed_offset() top, _, _, left = fixed_offset @@ -1076,7 +1107,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._emit_selected_message() meta = self.get_style_at(event.x, event.y).meta if meta: - self.cursor_cell = Coordinate(meta["row"], meta["column"]) + row_key = meta["row_key"] + row_index = self._row_locations.get(row_key) + column_key = meta["column_key"] + column_index = self._column_locations.get(column_key) + self.cursor_cell = Coordinate(row_index, column_index) self._scroll_cursor_into_view(animate=True) event.stop() @@ -1105,6 +1140,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"): self.cursor_cell = self.cursor_cell.right() + print(self.cursor_cell) self._scroll_cursor_into_view(animate=True) else: super().action_scroll_right() From b0b0531ad7d694bbf9360eb6380ae0cc7b2e8d94 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 25 Jan 2023 13:54:16 +0000 Subject: [PATCH 040/155] Update snapshots --- .../__snapshots__/test_snapshots.ambr | 344 +++++++++--------- 1 file changed, 172 insertions(+), 172 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 52b617982..202d933ee 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10015,134 +10015,134 @@ font-weight: 700; } - .terminal-3966238525-matrix { + .terminal-672973116-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3966238525-title { + .terminal-672973116-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3966238525-r1 { fill: #dde6ed;font-weight: bold } - .terminal-3966238525-r2 { fill: #1e1201;font-weight: bold } - .terminal-3966238525-r3 { fill: #e1e1e1 } - .terminal-3966238525-r4 { fill: #c5c8c6 } - .terminal-3966238525-r5 { fill: #211505 } + .terminal-672973116-r1 { fill: #dde6ed;font-weight: bold } + .terminal-672973116-r2 { fill: #1e1201;font-weight: bold } + .terminal-672973116-r3 { fill: #e1e1e1 } + .terminal-672973116-r4 { fill: #c5c8c6 } + .terminal-672973116-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10173,133 +10173,133 @@ font-weight: 700; } - .terminal-121683423-matrix { + .terminal-1254167810-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-121683423-title { + .terminal-1254167810-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-121683423-r1 { fill: #dde6ed;font-weight: bold } - .terminal-121683423-r2 { fill: #e1e1e1 } - .terminal-121683423-r3 { fill: #c5c8c6 } - .terminal-121683423-r4 { fill: #211505 } + .terminal-1254167810-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1254167810-r2 { fill: #e1e1e1 } + .terminal-1254167810-r3 { fill: #c5c8c6 } + .terminal-1254167810-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10330,133 +10330,133 @@ font-weight: 700; } - .terminal-3001793466-matrix { + .terminal-2085601846-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3001793466-title { + .terminal-2085601846-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3001793466-r1 { fill: #dde6ed;font-weight: bold } - .terminal-3001793466-r2 { fill: #e1e1e1 } - .terminal-3001793466-r3 { fill: #c5c8c6 } - .terminal-3001793466-r4 { fill: #211505 } + .terminal-2085601846-r1 { fill: #dde6ed;font-weight: bold } + .terminal-2085601846-r2 { fill: #e1e1e1 } + .terminal-2085601846-r3 { fill: #c5c8c6 } + .terminal-2085601846-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + From 6d0b8d4f44ad22a6d769d0c22b543e074b9d217d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 25 Jan 2023 14:51:36 +0000 Subject: [PATCH 041/155] Fix indexing bug - use key order in DataTable --- src/textual/widgets/_data_table.py | 35 +++++++++++++----------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f9c2d26b5..00dd2d14b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -315,6 +315,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) + print(column_index, column_key) try: cell_value = self.data[row_key][column_key] except KeyError: @@ -387,6 +388,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh_cell(*coordinate) try: cell_value = self.get_cell_value(coordinate) + print(f"got cell_value = {cell_value}") except CellDoesNotExist: # The cell may not exist e.g. when the table is cleared. # In that case, there's nothing for us to do here. @@ -473,6 +475,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.rows[row_key] # The x-coordinate of a cell is the sum of widths of cells to the left. + print(self._ordered_columns) x = sum(column.render_width for column in self._ordered_columns[:column_index]) column_key = self._column_locations.get_key(column_index) width = self.columns[column_key].render_width @@ -681,6 +684,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if row_index < 0 or column_index < 0: return region = self._get_cell_region(row_index, column_index) + print(f"cell_region = {region}") self._refresh_region(region) def refresh_row(self, row_index: int) -> None: @@ -807,9 +811,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) if cell_cache_key not in self._cell_render_cache: if not is_header_row: - style += Style.from_meta( - {"row_key": row_key.value, "column_key": column_key.value} - ) + style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] lines = self.app.console.render_lines( @@ -886,11 +888,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style += Style.from_meta({"fixed": True}) fixed_row = [] - for column in self._ordered_columns[: self.fixed_columns]: - cell_location = Coordinate(row_index, column.index) + for column_index, column in enumerate( + self._ordered_columns[: self.fixed_columns] + ): + cell_location = Coordinate(row_index, column_index) fixed_cell_lines = render_cell( row_index, - column.index, + column_index, fixed_style, column.render_width, cursor=_should_highlight( @@ -914,11 +918,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_style = base_style scrollable_row = [] - for column in self.columns.values(): - cell_location = Coordinate(row_index, column.index) + for column_index, column in enumerate(self._ordered_columns): + cell_location = Coordinate(row_index, column_index) cell_lines = render_cell( row_index, - column.index, + column_index, row_style, column.render_width, cursor=_should_highlight(cursor_location, cell_location, cursor_type), @@ -1031,12 +1035,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): meta = event.style.meta if meta and self.show_cursor and self.cursor_type != "none": try: - row_key = meta["row_key"] - row_index = self._row_locations.get(row_key) - column_key = meta["column_key"] - column_index = self._column_locations.get(column_key) - # print(row_key, column_key, row_index, column_index) - self.hover_cell = Coordinate(row_index, column_index) + self.hover_cell = Coordinate(meta["row"], meta["column"]) except KeyError: pass @@ -1107,11 +1106,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._emit_selected_message() meta = self.get_style_at(event.x, event.y).meta if meta: - row_key = meta["row_key"] - row_index = self._row_locations.get(row_key) - column_key = meta["column_key"] - column_index = self._column_locations.get(column_key) - self.cursor_cell = Coordinate(row_index, column_index) + self.cursor_cell = Coordinate(meta["row"], meta["column"]) self._scroll_cursor_into_view(animate=True) event.stop() From 8b92aa61ff9c7b73283ff806801f1da811e1314d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 25 Jan 2023 16:47:19 +0000 Subject: [PATCH 042/155] Add row sort --- src/textual/widgets/_data_table.py | 31 ++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 00dd2d14b..374876b05 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -315,7 +315,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) - print(column_index, column_key) try: cell_value = self.data[row_key][column_key] except KeyError: @@ -372,9 +371,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Refresh the old and the new cell, and emit the appropriate # message to tell users of the newly highlighted row/cell/column. if self.cursor_type == "cell": - print(f"refreshing {old_coordinate}") self.refresh_cell(*old_coordinate) - print(f"highlighting {new_coordinate}") self._highlight_cell(new_coordinate) elif self.cursor_type == "row": self.refresh_row(old_coordinate.row) @@ -388,7 +385,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh_cell(*coordinate) try: cell_value = self.get_cell_value(coordinate) - print(f"got cell_value = {cell_value}") except CellDoesNotExist: # The cell may not exist e.g. when the table is cleared. # In that case, there's nothing for us to do here. @@ -450,7 +446,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Called to recalculate the virtual (scrollable) size.""" for row_index in new_rows: for column, renderable in zip( - self.columns.values(), self._get_row_renderables(row_index) + self._ordered_columns, self._get_row_renderables(row_index) ): content_width = measure(self.app.console, renderable, 1) column.content_width = max(column.content_width, content_width) @@ -475,12 +471,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.rows[row_key] # The x-coordinate of a cell is the sum of widths of cells to the left. - print(self._ordered_columns) x = sum(column.render_width for column in self._ordered_columns[:column_index]) column_key = self._column_locations.get_key(column_index) width = self.columns[column_key].render_width height = row.height - y = row.y + y = row.y # TODO: This value cannot be trusted since we can sort the rows if self.show_header: y += self.header_height cell_region = Region(x, y, width, height) @@ -496,7 +491,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key = self._row_locations.get_key(row_index) row = rows[row_key] row_width = sum(column.render_width for column in self.columns.values()) - y = row.y + y = row.y # TODO: This value cannot be trusted since we can sort the rows if self.show_header: y += self.header_height row_region = Region(0, y, row_width, row.height) @@ -684,7 +679,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if row_index < 0 or column_index < 0: return region = self._get_cell_region(row_index, column_index) - print(f"cell_region = {region}") self._refresh_region(region) def refresh_row(self, row_index: int) -> None: @@ -739,6 +733,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): List of renderables """ + # TODO: We have quite a few back and forward key/index conversions, could probably reduce them ordered_columns = self._ordered_columns if row_index == -1: row = [column.label for column in ordered_columns] @@ -747,6 +742,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Ensure we order the cells in the row based on current column ordering row_key = self._row_locations.get_key(row_index) cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key) + print(row_index, cell_mapping.values()) + ordered_row: list[CellType] = [] for column in ordered_columns: cell = cell_mapping[column.key] @@ -814,6 +811,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] + print(f"CELL = {cell}") lines = self.app.console.render_lines( Padding(cell, (0, 1)), self.app.console.options.update_dimensions(width, height), @@ -1056,14 +1054,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self, key: Callable[[ColumnKey | str], str] = None, reverse: bool = False ) -> None: ordered_keys = sorted(self.columns.keys(), key=key, reverse=reverse) - self.cursor_cell = Coordinate(0, 0) - self.hover_cell = Coordinate(0, 0) - self.columns = {key: self.columns.get(key) for key in ordered_keys} self._column_locations = TwoWayDict( {key: new_index for new_index, key in enumerate(ordered_keys)} ) self._update_count += 1 - self._clear_caches() + self.refresh() + + def sort_rows( + self, key: Callable[[RowKey | str], str] = None, reverse: bool = False + ): + ordered_keys = sorted(self.rows.keys(), key=key, reverse=reverse) + self._row_locations = TwoWayDict( + {key: new_index for new_index, key in enumerate(ordered_keys)} + ) + self._update_count += 1 self.refresh() def _scroll_cursor_into_view(self, animate: bool = False) -> None: @@ -1135,7 +1139,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"): self.cursor_cell = self.cursor_cell.right() - print(self.cursor_cell) self._scroll_cursor_into_view(animate=True) else: super().action_scroll_right() From 4b2a50c46c917975208d7bae287d16d01029794c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 26 Jan 2023 12:12:55 +0000 Subject: [PATCH 043/155] Dynamic y-offsets --- src/textual/widgets/_data_table.py | 36 +++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 374876b05..2367c6611 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -231,7 +231,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs - self._y_offsets: list[tuple[RowKey, int]] = [] + # self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] ] @@ -256,8 +256,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.header_height = header_height self.show_cursor = show_cursor self._show_hover_cursor = False - - # TODO: Could track "updates above row_index" instead - better cache efficiency self._update_count = 0 @property @@ -280,6 +278,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def row_count(self) -> int: return len(self.rows) + @property + def _y_offsets(self) -> list[tuple[RowKey, int]]: + y_offsets: list[tuple[RowKey, int]] = [] + for row in self._ordered_rows: + row_key = row.key + row_height = row.height + y_offsets += [(row_key, y) for y in range(row_height)] + return y_offsets + def update_cell( self, row_key: RowKey | str, column_key: ColumnKey | str, value: CellType ) -> None: @@ -593,21 +600,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index = self.row_count row_key = RowKey(key) - # Map the key of this row to its current index - self._row_locations[row_key] = row_index - # TODO: If there are no columns, do we generate them here? # If we don't do this, users will be required to call add_column(s) # Before they call add_row. + # Map the key of this row to its current index + self._row_locations[row_key] = row_index self.data[row_key] = { column.key: cell for column, cell in zip_longest(self._ordered_columns, cells) } self.rows[row_key] = Row(row_key, row_index, height, self._line_no) - for line_no in range(height): - self._y_offsets.append((row_key, line_no)) + # for line_no in range(height): + # self._y_offsets.append((row_key, line_no)) self._line_no += height @@ -723,6 +729,16 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ordered_columns = [self.columns.get(key) for key in column_keys] return ordered_columns + @property + def _ordered_rows(self) -> list[Row]: + row_indices = range(self.row_count) + ordered_rows = [] + for row_index in row_indices: + row_key = self._row_locations.get_key(row_index) + row = self.rows.get(row_key) + ordered_rows.append(row) + return ordered_rows + def _get_row_renderables(self, row_index: int) -> list[RenderableType]: """Get renderables for the row currently at the given row index. @@ -941,6 +957,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: Row key and line (y) offset within cell. """ + + # TODO - the row sorting issue is here, since when we sort rows we never update the + # y-offsets - lets make those offsets dynamic. + header_height = self.header_height y_offsets = self._y_offsets if self.show_header: From 3f89511f245007a81a685b2c36f673526e678d6d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 26 Jan 2023 13:46:58 +0000 Subject: [PATCH 044/155] Remove index from Column object --- src/textual/widgets/_data_table.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 2367c6611..1e0673aff 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -97,7 +97,6 @@ class Column: label: Text width: int = 0 visible: bool = False - index: int = 0 content_width: int = 0 auto_width: bool = False @@ -482,7 +481,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key = self._column_locations.get_key(column_index) width = self.columns[column_key].render_width height = row.height - y = row.y # TODO: This value cannot be trusted since we can sort the rows + y = sum(ordered_row.height for ordered_row in self._ordered_rows[:row_index]) if self.show_header: y += self.header_height cell_region = Region(x, y, width, height) @@ -498,7 +497,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key = self._row_locations.get_key(row_index) row = rows[row_key] row_width = sum(column.render_width for column in self.columns.values()) - y = row.y # TODO: This value cannot be trusted since we can sort the rows + y = sum(ordered_row.height for ordered_row in self._ordered_rows[:row_index]) if self.show_header: y += self.header_height row_region = Region(0, y, row_width, row.height) @@ -562,7 +561,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key, text_label, content_width, - index=column_index, content_width=content_width, auto_width=True, ) @@ -572,7 +570,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): text_label, width, content_width=content_width, - index=column_index, ) self.columns[column_key] = column From 9d2ddfa86e34870b4fc818db132220e236c3c1f9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 26 Jan 2023 13:56:40 +0000 Subject: [PATCH 045/155] Remove DataTable Row.index --- src/textual/widgets/_data_table.py | 3 +-- tests/test_data_table.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 1e0673aff..c9adfe4db 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -116,7 +116,6 @@ class Row: """Table row.""" key: RowKey - index: int height: int y: int cell_renderables: list[RenderableType] = field(default_factory=list) @@ -607,7 +606,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column.key: cell for column, cell in zip_longest(self._ordered_columns, cells) } - self.rows[row_key] = Row(row_key, row_index, height, self._line_no) + self.rows[row_key] = Row(row_key, height, self._line_no) # for line_no in range(height): # self._y_offsets.append((row_key, line_no)) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 1566b2237..7af3b1c70 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -138,9 +138,6 @@ async def test_add_rows(): assert table.row_count == len(ROWS) # Each key can be used to fetch a row from the DataTable assert all(key in table.data for key in row_keys) - # Ensure the keys are returned *in order*, and there's one for each row - for key, row in zip(row_keys, range(len(ROWS))): - assert table.rows[key].index == row async def test_add_rows_user_defined_keys(): @@ -168,7 +165,7 @@ async def test_add_rows_user_defined_keys(): third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} assert table.data[auto_key] == third_row - first_row = Row(algernon_key, index=0, height=1, y=0) + first_row = Row(algernon_key, height=1, y=0) assert table.rows[algernon_key] == first_row assert table.rows["algernon"] == first_row From aee100ff10a8f6be166bff9cb843085940248aef Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 26 Jan 2023 14:05:31 +0000 Subject: [PATCH 046/155] Removing redundant data from DataTable.Row --- src/textual/widgets/_data_table.py | 11 +- .../__snapshots__/test_snapshots.ambr | 344 +++++++++--------- tests/test_data_table.py | 2 +- 3 files changed, 174 insertions(+), 183 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c9adfe4db..832ba54c7 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -117,7 +117,6 @@ class Row: key: RowKey height: int - y: int cell_renderables: list[RenderableType] = field(default_factory=list) @@ -606,13 +605,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column.key: cell for column, cell in zip_longest(self._ordered_columns, cells) } - self.rows[row_key] = Row(row_key, height, self._line_no) - - # for line_no in range(height): - # self._y_offsets.append((row_key, line_no)) - - self._line_no += height - + self.rows[row_key] = Row(row_key, height) self._new_rows.add(row_index) self._require_update_dimensions = True self.cursor_cell = self.cursor_cell @@ -754,7 +747,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Ensure we order the cells in the row based on current column ordering row_key = self._row_locations.get_key(row_index) cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key) - print(row_index, cell_mapping.values()) ordered_row: list[CellType] = [] for column in ordered_columns: @@ -823,7 +815,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] - print(f"CELL = {cell}") lines = self.app.console.render_lines( Padding(cell, (0, 1)), self.app.console.options.update_dimensions(width, height), diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 202d933ee..3a48ca0dd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10015,134 +10015,134 @@ font-weight: 700; } - .terminal-672973116-matrix { + .terminal-3633944034-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-672973116-title { + .terminal-3633944034-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-672973116-r1 { fill: #dde6ed;font-weight: bold } - .terminal-672973116-r2 { fill: #1e1201;font-weight: bold } - .terminal-672973116-r3 { fill: #e1e1e1 } - .terminal-672973116-r4 { fill: #c5c8c6 } - .terminal-672973116-r5 { fill: #211505 } + .terminal-3633944034-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3633944034-r2 { fill: #1e1201;font-weight: bold } + .terminal-3633944034-r3 { fill: #e1e1e1 } + .terminal-3633944034-r4 { fill: #c5c8c6 } + .terminal-3633944034-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10173,133 +10173,133 @@ font-weight: 700; } - .terminal-1254167810-matrix { + .terminal-2438906615-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1254167810-title { + .terminal-2438906615-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1254167810-r1 { fill: #dde6ed;font-weight: bold } - .terminal-1254167810-r2 { fill: #e1e1e1 } - .terminal-1254167810-r3 { fill: #c5c8c6 } - .terminal-1254167810-r4 { fill: #211505 } + .terminal-2438906615-r1 { fill: #dde6ed;font-weight: bold } + .terminal-2438906615-r2 { fill: #e1e1e1 } + .terminal-2438906615-r3 { fill: #c5c8c6 } + .terminal-2438906615-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10330,133 +10330,133 @@ font-weight: 700; } - .terminal-2085601846-matrix { + .terminal-512278738-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2085601846-title { + .terminal-512278738-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2085601846-r1 { fill: #dde6ed;font-weight: bold } - .terminal-2085601846-r2 { fill: #e1e1e1 } - .terminal-2085601846-r3 { fill: #c5c8c6 } - .terminal-2085601846-r4 { fill: #211505 } + .terminal-512278738-r1 { fill: #dde6ed;font-weight: bold } + .terminal-512278738-r2 { fill: #e1e1e1 } + .terminal-512278738-r3 { fill: #c5c8c6 } + .terminal-512278738-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 7af3b1c70..10eed9938 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -165,7 +165,7 @@ async def test_add_rows_user_defined_keys(): third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} assert table.data[auto_key] == third_row - first_row = Row(algernon_key, height=1, y=0) + first_row = Row(algernon_key, height=1) assert table.rows[algernon_key] == first_row assert table.rows["algernon"] == first_row From 85ad9f14148198c8544a6dba3f41ce748c6935f4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 26 Jan 2023 16:32:37 +0000 Subject: [PATCH 047/155] Type aliases for datatable cachees --- src/textual/widgets/_data_table.py | 45 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 832ba54c7..c6c332777 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -3,7 +3,16 @@ from __future__ import annotations import functools from dataclasses import dataclass, field from itertools import chain, zip_longest -from typing import ClassVar, Generic, Iterable, TypeVar, cast, NamedTuple, Callable +from typing import ( + ClassVar, + Generic, + Iterable, + TypeVar, + cast, + NamedTuple, + Callable, + TypeAlias, +) import rich.repr from rich.console import RenderableType @@ -28,6 +37,13 @@ from ..render import measure from ..scroll_view import ScrollView from ..strip import Strip +CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]" +LineCacheKey: TypeAlias = ( + "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]" +) +RowCacheKey: TypeAlias = ( + "tuple[RowKey, int, Style, int, int, CursorType, bool, bool, int]" +) CursorType = Literal["cell", "row", "column", "none"] CELL: CursorType = "cell" CellType = TypeVar("CellType") @@ -217,28 +233,23 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): classes: str | None = None, ) -> None: super().__init__(name=name, id=id, classes=classes) - - self.columns: dict[ColumnKey, Column] = {} - self.rows: dict[RowKey, Row] = {} self.data: dict[RowKey, dict[ColumnKey, CellType]] = {} - # Keep tracking of key -> index for rows/cols. - # For a given key, what is the current location of the corresponding row/col? + # Metadata on rows and columns in the table + self.columns: dict[ColumnKey, Column] = {} + self.rows: dict[RowKey, Row] = {} + + # Keep tracking of key -> index for rows/cols. These allow us to retrieve, + # given a row or column key, the index that row or column is currently present at, + # and mean that rows and columns are location independent - they can move around. self._column_locations: TwoWayDict[ColumnKey, int] = TwoWayDict({}) self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) - # Maps y-coordinate (from top of table) to (row_index, y-coord within row) pairs - # self._y_offsets: list[tuple[RowKey, int]] = [] self._row_render_cache: LRUCache[ - tuple[RowKey, int, Style, int, int], tuple[SegmentLines, SegmentLines] - ] - self._row_render_cache = LRUCache(1000) - self._cell_render_cache: LRUCache[ - tuple[int, int, Style, bool, bool], SegmentLines - ] - self._cell_render_cache = LRUCache(10000) - self._line_cache: LRUCache[tuple[int, int, int, int, int, int, Style], Strip] - self._line_cache = LRUCache(1000) + RowCacheKey, tuple[SegmentLines, SegmentLines] + ] = LRUCache(1000) + self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) + self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) self._line_no = 0 self._require_update_dimensions: bool = False From cef1a32f5c519a1445880f0321cf80e86e04110e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 10:08:36 +0000 Subject: [PATCH 048/155] Use textual._typing TypeAlias --- src/textual/widgets/_data_table.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c6c332777..b62909c7c 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -11,7 +11,6 @@ from typing import ( cast, NamedTuple, Callable, - TypeAlias, ) import rich.repr @@ -27,7 +26,7 @@ from .._cache import LRUCache from .._segment_tools import line_crop from .._two_way_dict import TwoWayDict from .._types import SegmentLines -from .._typing import Literal +from .._typing import Literal, TypeAlias from ..binding import Binding from ..coordinate import Coordinate from ..geometry import Region, Size, Spacing, clamp From 803c044f4b2979fae529d67967bc218d0d7e8e18 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 10:13:07 +0000 Subject: [PATCH 049/155] Add a docstring for update_cell --- src/textual/widgets/_data_table.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b62909c7c..76036211e 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -297,6 +297,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def update_cell( self, row_key: RowKey | str, column_key: ColumnKey | str, value: CellType ) -> None: + """Update the content inside the cell with the specified row key + and column key. + + Args: + row_key: The key identifying the row. + column_key: The key identifying the row. + value: The new value to put inside the cell. + """ self.data[row_key][column_key] = value self._update_count += 1 From d6412e14032d5dd90bacecd82805361154ff3a05 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 10:21:58 +0000 Subject: [PATCH 050/155] Header row key in DataTab;e --- src/textual/widgets/_data_table.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 76036211e..fafa7e24f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -264,6 +264,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.show_cursor = show_cursor self._show_hover_cursor = False self._update_count = 0 + self._header_row_key = RowKey() @property def hover_row(self) -> int: @@ -302,7 +303,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: row_key: The key identifying the row. - column_key: The key identifying the row. + column_key: The key identifying the column. value: The new value to put inside the cell. """ self.data[row_key][column_key] = value @@ -349,10 +350,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_cache.clear() self._styles_cache.clear() - def get_row_height(self, row_key: int | RowKey) -> int: - # TODO: Update to generate header key ourselves instead of -1, - # and remember to update type signature - if row_key == -1: + def get_row_height(self, row_key: RowKey) -> int: + if row_key is self._header_row_key: return self.header_height return self.rows[row_key].height @@ -1046,7 +1045,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.get_row_height(row_key) for row_key in fixed_row_keys ) if self.show_header: - fixed_rows_height += self.get_row_height(-1) + fixed_rows_height += self.get_row_height(self._header_row_key) if y >= fixed_rows_height: y += scroll_y From 8d6529daaddebc9f213ef5c89f08a9bc470f94e5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 13:46:56 +0000 Subject: [PATCH 051/155] Recomputing column size on cell update --- src/textual/widgets/_data_table.py | 41 +++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index fafa7e24f..5d9a24c58 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -296,7 +296,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return y_offsets def update_cell( - self, row_key: RowKey | str, column_key: ColumnKey | str, value: CellType + self, + row_key: RowKey | str, + column_key: ColumnKey | str, + value: CellType, + *, + update_width: bool = False, ) -> None: """Update the content inside the cell with the specified row key and column key. @@ -305,13 +310,34 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key: The key identifying the row. column_key: The key identifying the column. value: The new value to put inside the cell. + update_width: Whether to resize the column width to accommodate + for the new cell content. """ + value = Text.from_markup(value) if isinstance(value, str) else value + self.data[row_key][column_key] = value self._update_count += 1 - row_index = self._row_locations.get(row_key) - column_index = self._column_locations.get(column_key) - self.refresh_cell(row_index, column_index) + # Recalculate widths if necessary + column = self.columns.get(column_key) + if update_width: + column.auto_width = True + console = self.app.console + label_width = measure(console, column.label, 1) + content_width = max(column.content_width, label_width) + new_content_width = max(measure(console, value, 1), label_width) + + if new_content_width < content_width: + cells_in_column = self._get_cells_in_column(column_key) + column.content_width = max( + measure(console, cell, 1) for cell in cells_in_column + ) + else: + column.content_width = new_content_width + + self._require_update_dimensions = True + # TODO: Refresh right + self.refresh() def update_coordinate(self, coordinate: Coordinate, value: CellType) -> None: row, column = coordinate @@ -321,6 +347,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._update_count += 1 self.refresh_cell(row, column) + def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: + """For a given column key, return the cells in that column in order""" + for row_metadata in self._ordered_rows: + row_key = row_metadata.key + row = self.data.get(row_key) + yield row.get(column_key) + def get_cell_value(self, coordinate: Coordinate) -> CellType: """Get the value from the cell at the given coordinate. From 5cccce85ef383d3c5d35784838fa10c594db53d8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 14:04:58 +0000 Subject: [PATCH 052/155] Updating column widths accounting for labels --- src/textual/widgets/_data_table.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 5d9a24c58..18dd69f5c 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -324,16 +324,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column.auto_width = True console = self.app.console label_width = measure(console, column.label, 1) - content_width = max(column.content_width, label_width) - new_content_width = max(measure(console, value, 1), label_width) + content_width = column.content_width + new_content_width = measure(console, value, 1) + print(value, type(value), new_content_width) if new_content_width < content_width: cells_in_column = self._get_cells_in_column(column_key) - column.content_width = max( - measure(console, cell, 1) for cell in cells_in_column - ) + cell_widths = [measure(console, cell, 1) for cell in cells_in_column] + print(cell_widths) + column.content_width = max(*[*cell_widths, label_width]) else: - column.content_width = new_content_width + column.content_width = max(new_content_width, label_width) self._require_update_dimensions = True # TODO: Refresh right @@ -343,6 +344,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row, column = coordinate row_key = self._row_locations.get_key(row) column_key = self._column_locations.get_key(column) + value = Text.from_markup(value) if isinstance(value, str) else value self.data[row_key][column_key] = value self._update_count += 1 self.refresh_cell(row, column) @@ -652,7 +654,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Map the key of this row to its current index self._row_locations[row_key] = row_index self.data[row_key] = { - column.key: cell + column.key: Text(cell) if isinstance(cell, str) else cell for column, cell in zip_longest(self._ordered_columns, cells) } self.rows[row_key] = Row(row_key, height) From bd29bd784633ecece8f54eb1051b83c6ad4dfa35 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 14:29:34 +0000 Subject: [PATCH 053/155] Remove an unused print, simplify content width calculation --- src/textual/widgets/_data_table.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 18dd69f5c..9ad52f520 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -326,13 +326,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): label_width = measure(console, column.label, 1) content_width = column.content_width new_content_width = measure(console, value, 1) - print(value, type(value), new_content_width) if new_content_width < content_width: cells_in_column = self._get_cells_in_column(column_key) cell_widths = [measure(console, cell, 1) for cell in cells_in_column] - print(cell_widths) - column.content_width = max(*[*cell_widths, label_width]) + column.content_width = max([*cell_widths, label_width]) else: column.content_width = max(new_content_width, label_width) From c34f4becfedab30bbff4a10506dc7691440cda41 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 14:37:41 +0000 Subject: [PATCH 054/155] Fixing data table tests --- tests/test_data_table.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 10eed9938..1c53f9b6a 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -155,14 +155,14 @@ async def test_add_rows_user_defined_keys(): assert isinstance(algernon_key, RowKey) # Ensure the data in the table is mapped as expected - first_row = {key_a: ROWS[0][0], key_b: ROWS[0][1]} + first_row = {key_a: Text(ROWS[0][0]), key_b: Text(ROWS[0][1])} assert table.data[algernon_key] == first_row assert table.data["algernon"] == first_row - second_row = {key_a: ROWS[1][0], key_b: ROWS[1][1]} + second_row = {key_a: Text(ROWS[1][0]), key_b: Text(ROWS[1][1])} assert table.data["charlie"] == second_row - third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} + third_row = {key_a: Text(ROWS[2][0]), key_b: Text(ROWS[2][1])} assert table.data[auto_key] == third_row first_row = Row(algernon_key, height=1) @@ -253,7 +253,7 @@ async def test_get_cell_value_returns_value_at_cell(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) - assert table.get_cell_value(Coordinate(0, 0)) == "0/0" + assert table.get_cell_value(Coordinate(0, 0)) == Text("0/0") async def test_get_cell_value_exception(): From a215051eb938646c72cc79c53c4423fee0b99845 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 15:03:37 +0000 Subject: [PATCH 055/155] Fix an incorrect type hint --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 9ad52f520..16caf0f53 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -41,7 +41,7 @@ LineCacheKey: TypeAlias = ( "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]" ) RowCacheKey: TypeAlias = ( - "tuple[RowKey, int, Style, int, int, CursorType, bool, bool, int]" + "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int]" ) CursorType = Literal["cell", "row", "column", "none"] CELL: CursorType = "cell" From 73f6876c7559becd84ebb112e8d84bf16fb9236c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 15:45:14 +0000 Subject: [PATCH 056/155] Sort method --- src/textual/widgets/_data_table.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 16caf0f53..58197c0f1 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools from dataclasses import dataclass, field from itertools import chain, zip_longest +from operator import itemgetter from typing import ( ClassVar, Generic, @@ -11,6 +12,7 @@ from typing import ( cast, NamedTuple, Callable, + Sequence, ) import rich.repr @@ -1107,6 +1109,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) return Spacing(top, 0, 0, left) + def sort( + self, + column: str | ColumnKey | Sequence[str] | Sequence[ColumnKey], + reverse: bool = False, + ) -> None: + if isinstance(column, (str, ColumnKey)): + column = (column,) + indices = [self._column_locations.get(key) for key in column] + ordered_keys = sorted(self.rows, key=itemgetter(*indices), reverse=reverse) + self._row_locations = TwoWayDict( + {key: new_index for new_index, key in enumerate(ordered_keys)} + ) + self._update_count += 1 + self.refresh() + def sort_columns( self, key: Callable[[ColumnKey | str], str] = None, reverse: bool = False ) -> None: From 6382692d112de9f20b08b6092eb293db985d5de5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 16:29:31 +0000 Subject: [PATCH 057/155] Some comment changes --- src/textual/widgets/_data_table.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 58197c0f1..728e9f040 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -13,6 +13,8 @@ from typing import ( NamedTuple, Callable, Sequence, + Type, + Optional, ) import rich.repr @@ -255,7 +257,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_no = 0 self._require_update_dimensions: bool = False - # TODO: Check what this is used for and if it needs updated to use keys + # TODO: This should be updated to use row keys, since sorting could + # occur before code runs on idle. self._new_rows: set[int] = set() self.show_header = show_header @@ -661,7 +664,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._new_rows.add(row_index) self._require_update_dimensions = True self.cursor_cell = self.cursor_cell - self.check_idle() # If a position has opened for the cursor to appear, where it previously # could not (e.g. when there's no data in the table), then a highlighted @@ -672,6 +674,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if cell_now_available and visible_cursor: self._highlight_cursor() + self.check_idle() return row_key def add_columns(self, *labels: TextType) -> list[ColumnKey]: @@ -996,10 +999,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: Row key and line (y) offset within cell. """ - - # TODO - the row sorting issue is here, since when we sort rows we never update the - # y-offsets - lets make those offsets dynamic. - header_height = self.header_height y_offsets = self._y_offsets if self.show_header: From c69b60bd415675064cf30fbde3e2a61394aac906 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 16:32:40 +0000 Subject: [PATCH 058/155] Tracking new rows using keys instead of indices --- src/textual/widgets/_data_table.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 728e9f040..cf4d32612 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -259,7 +259,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # TODO: This should be updated to use row keys, since sorting could # occur before code runs on idle. - self._new_rows: set[int] = set() + self._new_rows: set[RowKey] = set() self.show_header = show_header self.fixed_rows = fixed_rows @@ -501,9 +501,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif cursor_type == "column": self._highlight_column(column_index) - def _update_dimensions(self, new_rows: Iterable[int]) -> None: + def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: """Called to recalculate the virtual (scrollable) size.""" - for row_index in new_rows: + for row_key in new_rows: + row_index = self._row_locations.get(row_key) + if row_index is None: + continue for column, renderable in zip( self._ordered_columns, self._get_row_renderables(row_index) ): @@ -661,7 +664,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): for column, cell in zip_longest(self._ordered_columns, cells) } self.rows[row_key] = Row(row_key, height) - self._new_rows.add(row_index) + self._new_rows.add(row_key) self._require_update_dimensions = True self.cursor_cell = self.cursor_cell From bbe71faa212d938449a1a644398e720d4d88fdd2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 30 Jan 2023 16:59:34 +0000 Subject: [PATCH 059/155] Start adding column width updates on idle [no ci] --- src/textual/widgets/_data_table.py | 40 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index cf4d32612..799403c85 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -257,9 +257,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_no = 0 self._require_update_dimensions: bool = False - # TODO: This should be updated to use row keys, since sorting could - # occur before code runs on idle. self._new_rows: set[RowKey] = set() + self._updated_columns: set[ColumnKey] = set() + """Track which cells were updated, so that we can refresh them once on idle""" self.show_header = show_header self.fixed_rows = fixed_rows @@ -324,23 +324,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._update_count += 1 # Recalculate widths if necessary - column = self.columns.get(column_key) if update_width: - column.auto_width = True - console = self.app.console - label_width = measure(console, column.label, 1) - content_width = column.content_width - new_content_width = measure(console, value, 1) - - if new_content_width < content_width: - cells_in_column = self._get_cells_in_column(column_key) - cell_widths = [measure(console, cell, 1) for cell in cells_in_column] - column.content_width = max([*cell_widths, label_width]) - else: - column.content_width = max(new_content_width, label_width) + self._updated_columns.add(column_key) self._require_update_dimensions = True - # TODO: Refresh right self.refresh() def update_coordinate(self, coordinate: Coordinate, value: CellType) -> None: @@ -501,6 +488,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif cursor_type == "column": self._highlight_column(column_index) + def _update_column_widths(self, column_keys: set[ColumnKey]) -> None: + for column_key in column_keys: + column = self.columns.get(column_key) + console = self.app.console + label_width = measure(console, column.label, 1) + content_width = column.content_width + new_content_width = measure(console, value, 1) + + if new_content_width < content_width: + cells_in_column = self._get_cells_in_column(column_key) + cell_widths = [measure(console, cell, 1) for cell in cells_in_column] + column.content_width = max([*cell_widths, label_width]) + else: + column.content_width = max(new_content_width, label_width) + def _update_dimensions(self, new_rows: Iterable[RowKey]) -> None: """Called to recalculate the virtual (scrollable) size.""" for row_key in new_rows: @@ -719,7 +721,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions = False new_rows = self._new_rows.copy() self._new_rows.clear() + # Add the new rows *before* updating the column widths, since + # cells in a new row may influence the final width of a column self._update_dimensions(new_rows) + if self._updated_columns: + updated_columns = self._updated_columns.copy() + self._updated_columns.clear() + self._update_column_widths(updated_columns) self.refresh() def refresh_cell(self, row_index: int, column_index: int) -> None: From 95b52eef0dffcdf6a94d81cd940e259f6bcc2891 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 13:03:36 +0000 Subject: [PATCH 060/155] Refresh column widths on idle --- src/textual/widgets/_data_table.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 799403c85..ae912fbbd 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -258,7 +258,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions: bool = False self._new_rows: set[RowKey] = set() - self._updated_columns: set[ColumnKey] = set() + self._updated_cells: set[CellKey] = set() """Track which cells were updated, so that we can refresh them once on idle""" self.show_header = show_header @@ -325,19 +325,18 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Recalculate widths if necessary if update_width: - self._updated_columns.add(column_key) + self._updated_cells.add(CellKey(row_key, column_key)) + self._require_update_dimensions = True - self._require_update_dimensions = True self.refresh() - def update_coordinate(self, coordinate: Coordinate, value: CellType) -> None: + def update_coordinate( + self, coordinate: Coordinate, value: CellType, *, update_width: bool = False + ) -> None: row, column = coordinate row_key = self._row_locations.get_key(row) column_key = self._column_locations.get_key(column) - value = Text.from_markup(value) if isinstance(value, str) else value - self.data[row_key][column_key] = value - self._update_count += 1 - self.refresh_cell(row, column) + self.update_cell(row_key, column_key, value, update_width=update_width) def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: """For a given column key, return the cells in that column in order""" @@ -488,13 +487,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif cursor_type == "column": self._highlight_column(column_index) - def _update_column_widths(self, column_keys: set[ColumnKey]) -> None: - for column_key in column_keys: + def _update_column_widths(self, updated_cells: set[CellKey]) -> None: + for row_key, column_key in updated_cells: column = self.columns.get(column_key) console = self.app.console label_width = measure(console, column.label, 1) content_width = column.content_width - new_content_width = measure(console, value, 1) + cell_value = self.data[row_key][column_key] + new_content_width = measure(console, cell_value, 1) if new_content_width < content_width: cells_in_column = self._get_cells_in_column(column_key) @@ -724,9 +724,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Add the new rows *before* updating the column widths, since # cells in a new row may influence the final width of a column self._update_dimensions(new_rows) - if self._updated_columns: - updated_columns = self._updated_columns.copy() - self._updated_columns.clear() + if self._updated_cells: + updated_columns = self._updated_cells.copy() + self._updated_cells.clear() self._update_column_widths(updated_columns) self.refresh() From 8f928f4b768b0cfaf429706f93c20b121d13e1c0 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 13:05:02 +0000 Subject: [PATCH 061/155] Import optimising --- src/textual/widgets/_data_table.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 88aeac5e6..2ac8b1b20 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -13,8 +13,6 @@ from typing import ( NamedTuple, Callable, Sequence, - Type, - Optional, ) import rich.repr @@ -30,9 +28,8 @@ from .._cache import LRUCache from .._segment_tools import line_crop from .._two_way_dict import TwoWayDict from .._types import SegmentLines -from .._typing import Literal, TypeAlias -from ..binding import Binding from .._typing import Literal +from .._typing import TypeAlias from ..binding import Binding, BindingType from ..coordinate import Coordinate from ..geometry import Region, Size, Spacing, clamp From 48488e7402b886507ac4c30ea5e19c7cfa0c6e69 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 13:29:18 +0000 Subject: [PATCH 062/155] Add cell_key to CellHighlighted event --- src/textual/widgets/_data_table.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 2ac8b1b20..e23ff887f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -255,13 +255,19 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: value: The value in the highlighted cell. coordinate: The coordinate of the highlighted cell. + cell_key: The key for the highlighted cell. """ def __init__( - self, sender: DataTable, value: CellType, coordinate: Coordinate + self, + sender: DataTable, + value: CellType, + coordinate: Coordinate, + cell_key: CellKey, ) -> None: self.value: CellType = value self.coordinate: Coordinate = coordinate + self.cell_key: CellKey = cell_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -576,7 +582,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # In that case, there's nothing for us to do here. return else: - self.emit_no_wait(DataTable.CellHighlighted(self, cell_value, coordinate)) + cell_key = self.coordinate_to_cell_key(coordinate) + self.emit_no_wait( + DataTable.CellHighlighted( + self, cell_value, coordinate=coordinate, cell_key=cell_key + ) + ) + + def coordinate_to_cell_key(self, coordinate: Coordinate) -> CellKey: + """Return the key for the cell currently occupying this coordinate in the DataTable + + Args: + coordinate: The coordinate to exam the current cell key of. + + Returns: + The key of the cell currently occupying this coordinate. + """ + row_index, column_index = coordinate + row_key = self._row_locations.get_key(row_index) + column_key = self._column_locations.get_key(column_index) + return CellKey(row_key, column_key) def _highlight_row(self, row_index: int) -> None: """Apply highlighting to the row at the given index, and emit event.""" From abd35436fb94982a280381ad27319a6c41f40832 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 13:34:13 +0000 Subject: [PATCH 063/155] Some refactoring, and add cell_key to DataTable.CellSelected --- src/textual/widgets/_data_table.py | 81 ++++++++++++++++-------------- tests/test_data_table.py | 16 +++--- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e23ff887f..9847be8a8 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -239,10 +239,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): show_cursor = Reactive(True) cursor_type = Reactive(CELL) - cursor_cell: Reactive[Coordinate] = Reactive( + cursor_coordinate: Reactive[Coordinate] = Reactive( Coordinate(0, 0), repaint=False, always_update=True ) - hover_cell: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False) + hover_coordinate: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False) class CellHighlighted(Message, bubble=True): """Emitted when the cursor moves to highlight a new cell. @@ -274,6 +274,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "sender", self.sender yield "value", self.value yield "coordinate", self.coordinate + yield "cell_key", self.cell_key class CellSelected(Message, bubble=True): """Emitted by the `DataTable` widget when a cell is selected. @@ -284,19 +285,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: value: The value in the cell that was selected. coordinate: The coordinate of the cell that was selected. + cell_key: The key for the selected cell. """ def __init__( - self, sender: DataTable, value: CellType, coordinate: Coordinate + self, + sender: DataTable, + value: CellType, + coordinate: Coordinate, + cell_key: CellKey, ) -> None: self.value: CellType = value self.coordinate: Coordinate = coordinate + self.cell_key: CellKey = cell_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "value", self.value yield "coordinate", self.coordinate + yield "cell_key", self.cell_key class RowHighlighted(Message, bubble=True): """Emitted when a row is highlighted. This message is only emitted when the @@ -420,19 +428,19 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def hover_row(self) -> int: - return self.hover_cell.row + return self.hover_coordinate.row @property def hover_column(self) -> int: - return self.hover_cell.column + return self.hover_coordinate.column @property def cursor_row(self) -> int: - return self.cursor_cell.row + return self.cursor_coordinate.row @property def cursor_column(self) -> int: - return self.cursor_cell.column + return self.cursor_coordinate.column @property def row_count(self) -> int: @@ -492,8 +500,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.data.get(row_key) yield row.get(column_key) - def get_cell_value(self, coordinate: Coordinate) -> CellType: - """Get the value from the cell at the given coordinate. + def get_value_at(self, coordinate: Coordinate) -> CellType: + """Get the value from the cell occupying the given coordinate. Args: coordinate: The coordinate to retrieve the value from. @@ -504,11 +512,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: CellDoesNotExist: If there is no cell with the given coordinate. """ - # TODO: Rename to get_value_at()? - # We need to clearly distinguish between coordinates and cell keys - row_index, column_index = coordinate - row_key = self._row_locations.get_key(row_index) - column_key = self._column_locations.get_key(column_index) + row_key, column_key = self.coordinate_to_cell_key(coordinate) try: cell_value = self.data[row_key][column_key] except KeyError: @@ -537,7 +541,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # emit the appropriate [Row|Column|Cell]Highlighted event. self._scroll_cursor_into_view(animate=False) if self.cursor_type == "cell": - self._highlight_cell(self.cursor_cell) + self._highlight_cell(self.cursor_coordinate) elif self.cursor_type == "row": self._highlight_row(self.cursor_row) elif self.cursor_type == "column": @@ -576,7 +580,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Apply highlighting to the cell at the coordinate, and emit event.""" self.refresh_cell(*coordinate) try: - cell_value = self.get_cell_value(coordinate) + cell_value = self.get_value_at(coordinate) except CellDoesNotExist: # The cell may not exist e.g. when the table is cleared. # In that case, there's nothing for us to do here. @@ -632,7 +636,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Refresh cells that were previously impacted by the cursor # but may no longer be. - row_index, column_index = self.cursor_cell + row_index, column_index = self.cursor_coordinate if old == "cell": self.refresh_cell(row_index, column_index) elif old == "row": @@ -643,11 +647,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._scroll_cursor_into_view() def _highlight_cursor(self) -> None: - row_index, column_index = self.cursor_cell + row_index, column_index = self.cursor_coordinate cursor_type = self.cursor_type # Apply the highlighting to the newly relevant cells if cursor_type == "cell": - self._highlight_cell(self.cursor_cell) + self._highlight_cell(self.cursor_coordinate) elif cursor_type == "row": self._highlight_row(row_index) elif cursor_type == "column": @@ -756,8 +760,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.columns.clear() self._line_no = 0 self._require_update_dimensions = True - self.cursor_cell = Coordinate(0, 0) - self.hover_cell = Coordinate(0, 0) + self.cursor_coordinate = Coordinate(0, 0) + self.hover_coordinate = Coordinate(0, 0) self.refresh() def add_column( @@ -834,7 +838,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.rows[row_key] = Row(row_key, height) self._new_rows.add(row_key) self._require_update_dimensions = True - self.cursor_cell = self.cursor_cell + self.cursor_coordinate = self.cursor_coordinate # If a position has opened for the cursor to appear, where it previously # could not (e.g. when there's no data in the table), then a highlighted @@ -1212,8 +1216,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): x1, x2, width, - self.cursor_cell, - self.hover_cell, + self.cursor_coordinate, + self.hover_coordinate, base_style, self.cursor_type, self._show_hover_cursor, @@ -1226,8 +1230,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key, y_offset_in_row, base_style, - cursor_location=self.cursor_cell, - hover_location=self.hover_cell, + cursor_location=self.cursor_coordinate, + hover_location=self.hover_coordinate, ) fixed_width = sum( column.render_width @@ -1268,7 +1272,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): meta = event.style.meta if meta and self.show_cursor and self.cursor_type != "none": try: - self.hover_cell = Coordinate(meta["row"], meta["column"]) + self.hover_coordinate = Coordinate(meta["row"], meta["column"]) except KeyError: pass @@ -1351,7 +1355,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif cursor_type == "row": self.refresh_row(self.hover_row) elif cursor_type == "cell": - self.refresh_cell(*self.hover_cell) + self.refresh_cell(*self.hover_coordinate) def on_click(self, event: events.Click) -> None: self._set_hover_cursor(True) @@ -1360,7 +1364,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._emit_selected_message() meta = self.get_style_at(event.x, event.y).meta if meta: - self.cursor_cell = Coordinate(meta["row"], meta["column"]) + self.cursor_coordinate = Coordinate(meta["row"], meta["column"]) self._scroll_cursor_into_view(animate=True) event.stop() @@ -1368,7 +1372,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(False) cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): - self.cursor_cell = self.cursor_cell.up() + self.cursor_coordinate = self.cursor_coordinate.up() self._scroll_cursor_into_view() else: # If the cursor doesn't move up (e.g. column cursor can't go up), @@ -1379,7 +1383,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(False) cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): - self.cursor_cell = self.cursor_cell.down() + self.cursor_coordinate = self.cursor_coordinate.down() self._scroll_cursor_into_view() else: super().action_scroll_down() @@ -1388,7 +1392,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(False) cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"): - self.cursor_cell = self.cursor_cell.right() + self.cursor_coordinate = self.cursor_coordinate.right() self._scroll_cursor_into_view(animate=True) else: super().action_scroll_right() @@ -1397,7 +1401,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(False) cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"): - self.cursor_cell = self.cursor_cell.left() + self.cursor_coordinate = self.cursor_coordinate.left() self._scroll_cursor_into_view(animate=True) else: super().action_scroll_left() @@ -1409,19 +1413,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _emit_selected_message(self): """Emit the appropriate message for a selection based on the `cursor_type`.""" - cursor_cell = self.cursor_cell + cursor_coordinate = self.cursor_coordinate cursor_type = self.cursor_type if cursor_type == "cell": self.emit_no_wait( DataTable.CellSelected( self, - self.get_cell_value(cursor_cell), - cursor_cell, + self.get_value_at(cursor_coordinate), + coordinate=cursor_coordinate, + cell_key=self.coordinate_to_cell_key(cursor_coordinate), ) ) elif cursor_type == "row": - row, _ = cursor_cell + row, _ = cursor_coordinate self.emit_no_wait(DataTable.RowSelected(self, row)) elif cursor_type == "column": - _, column = cursor_cell + _, column = cursor_coordinate self.emit_no_wait(DataTable.ColumnSelected(self, column)) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 1c53f9b6a..28de49c75 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -193,19 +193,19 @@ async def test_clear(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) - assert table.cursor_cell == Coordinate(0, 0) - assert table.hover_cell == Coordinate(0, 0) + assert table.cursor_coordinate == Coordinate(0, 0) + assert table.hover_coordinate == Coordinate(0, 0) # Add some data and update cursor positions table.add_column("Column0") table.add_rows([["Row0"], ["Row1"], ["Row2"]]) - table.cursor_cell = Coordinate(1, 0) - table.hover_cell = Coordinate(2, 0) + table.cursor_coordinate = Coordinate(1, 0) + table.hover_coordinate = Coordinate(2, 0) # Ensure the cursor positions are reset to origin on clear() table.clear() - assert table.cursor_cell == Coordinate(0, 0) - assert table.hover_cell == Coordinate(0, 0) + assert table.cursor_coordinate == Coordinate(0, 0) + assert table.hover_coordinate == Coordinate(0, 0) # Ensure that the table has been cleared assert table.data == {} @@ -253,7 +253,7 @@ async def test_get_cell_value_returns_value_at_cell(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) - assert table.get_cell_value(Coordinate(0, 0)) == Text("0/0") + assert table.get_value_at(Coordinate(0, 0)) == Text("0/0") async def test_get_cell_value_exception(): @@ -263,7 +263,7 @@ async def test_get_cell_value_exception(): table.add_columns("A", "B") table.add_rows(ROWS) with pytest.raises(CellDoesNotExist): - table.get_cell_value(Coordinate(9999, 0)) + table.get_value_at(Coordinate(9999, 0)) def test_key_equals_equivalent_string(): From e02ef1e22ce0ea998728ce3035c916220b3a1517 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 13:42:53 +0000 Subject: [PATCH 064/155] Update watcher/validator names in DataTable --- src/textual/widgets/_data_table.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 9847be8a8..9dbebc892 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -556,11 +556,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def watch_zebra_stripes(self, zebra_stripes: bool) -> None: self._clear_caches() - def watch_hover_cell(self, old: Coordinate, value: Coordinate) -> None: + def watch_hover_coordinate(self, old: Coordinate, value: Coordinate) -> None: self.refresh_cell(*old) self.refresh_cell(*value) - def watch_cursor_cell( + def watch_cursor_coordinate( self, old_coordinate: Coordinate, new_coordinate: Coordinate ) -> None: if old_coordinate != new_coordinate: @@ -620,11 +620,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if column_index < len(self.columns): self.emit_no_wait(DataTable.ColumnHighlighted(self, column_index)) - def validate_cursor_cell(self, value: Coordinate) -> Coordinate: - return self._clamp_cursor_cell(value) + def validate_cursor_coordinate(self, value: Coordinate) -> Coordinate: + return self._clamp_cursor_coordinate(value) - def _clamp_cursor_cell(self, cursor_cell: Coordinate) -> Coordinate: - row, column = cursor_cell + def _clamp_cursor_coordinate(self, coordinate: Coordinate) -> Coordinate: + row, column = coordinate row = clamp(row, 0, self.row_count - 1) column = clamp(column, self.fixed_columns, len(self.columns) - 1) return Coordinate(row, column) From f97cdd679747633732a3805c50992eb2b25fa3cf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 15:18:33 +0000 Subject: [PATCH 065/155] Remove redundant attribute. Add more DataTable docstrings. --- src/textual/widgets/_data_table.py | 64 ++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 9dbebc892..88315f855 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -392,16 +392,24 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) -> None: super().__init__(name=name, id=id, classes=classes) self.data: dict[RowKey, dict[ColumnKey, CellType]] = {} + """Contains the cells of the table, indexed by row key and column key. + The final positioning of a cell on screen cannot be determined solely by this + structure. Instead, we must check _row_locations and _column_locations to find + where each cell currently resides in space.""" - # Metadata on rows and columns in the table self.columns: dict[ColumnKey, Column] = {} + """Metadata about the columns of the table, indexed by their key.""" self.rows: dict[RowKey, Row] = {} + """Metadata about the rows of the table, indexed by their key.""" # Keep tracking of key -> index for rows/cols. These allow us to retrieve, # given a row or column key, the index that row or column is currently present at, - # and mean that rows and columns are location independent - they can move around. - self._column_locations: TwoWayDict[ColumnKey, int] = TwoWayDict({}) + # and mean that rows and columns are location independent - they can move around + # without requiring us to modify the underlying data. self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) + """Maps row keys to row indices which represent row order.""" + self._column_locations: TwoWayDict[ColumnKey, int] = TwoWayDict({}) + """Maps column keys to column indices which represent column order.""" self._row_render_cache: LRUCache[ RowCacheKey, tuple[SegmentLines, SegmentLines] @@ -409,22 +417,31 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) - self._line_no = 0 self._require_update_dimensions: bool = False - + """Set to re-calculate dimensions on idle.""" self._new_rows: set[RowKey] = set() + """Tracking newly added rows to be used in re-calculation of dimensions on idle.""" self._updated_cells: set[CellKey] = set() - """Track which cells were updated, so that we can refresh them once on idle""" + """Track which cells were updated, so that we can refresh them once on idle.""" self.show_header = show_header - self.fixed_rows = fixed_rows - self.fixed_columns = fixed_columns - self.zebra_stripes = zebra_stripes + """Show/hide the header row (the row of column labels).""" self.header_height = header_height + """The height of the header row (the row of column labels).""" + self.fixed_rows = fixed_rows + """The number of rows to fix (prevented from scrolling).""" + self.fixed_columns = fixed_columns + """The number of columns to fix (prevented from scrolling).""" + self.zebra_stripes = zebra_stripes + """Apply zebra-stripe effect on row backgrounds (light, dark, light, dark, ...).""" self.show_cursor = show_cursor + """Show/hide both the keyboard and hover cursor.""" self._show_hover_cursor = False + """Used to hide the mouse hover cursor when the user uses the keyboard.""" self._update_count = 0 + """The number of update operations that have occurred. Used for cache invalidation.""" self._header_row_key = RowKey() + """The header is a special row which is not part of the data. This key is used to retrieve it.""" @property def hover_row(self) -> int: @@ -488,9 +505,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def update_coordinate( self, coordinate: Coordinate, value: CellType, *, update_width: bool = False ) -> None: - row, column = coordinate - row_key = self._row_locations.get_key(row) - column_key = self._column_locations.get_key(column) + """Update the content inside the cell currently occupying the given coordinate. + + Args: + coordinate: The coordinate to update the cell at. + value: The new value to place inside the cell. + update_width: Whether to resize the column width to accommodate + for the new cell content. + """ + row_key, column_key = self.coordinate_to_cell_key(coordinate) self.update_cell(row_key, column_key, value, update_width=update_width) def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: @@ -758,7 +781,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.rows.clear() if columns: self.columns.clear() - self._line_no = 0 self._require_update_dimensions = True self.cursor_coordinate = Coordinate(0, 0) self.hover_coordinate = Coordinate(0, 0) @@ -887,18 +909,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_keys def on_idle(self) -> None: + # Add the new rows *before* updating the column widths, since + # cells in a new row may influence the final width of a column if self._require_update_dimensions: self._require_update_dimensions = False new_rows = self._new_rows.copy() self._new_rows.clear() - # Add the new rows *before* updating the column widths, since - # cells in a new row may influence the final width of a column self._update_dimensions(new_rows) - if self._updated_cells: - updated_columns = self._updated_cells.copy() - self._updated_cells.clear() - self._update_column_widths(updated_columns) - self.refresh() + + if self._updated_cells: + # Cell contents have already been updated at this point. + # Now we only need to worry about measuring column widths. + updated_columns = self._updated_cells.copy() + self._updated_cells.clear() + self._update_column_widths(updated_columns) def refresh_cell(self, row_index: int, column_index: int) -> None: """Refresh a cell. From 25abe4dbdf1e35e18ee4bdfa6cac1056492bb930 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 15:30:23 +0000 Subject: [PATCH 066/155] Expose ordered rows and ordered columns publically --- src/textual/widgets/_data_table.py | 32 ++++++++++++++---------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 88315f855..8b4a91477 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -466,7 +466,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def _y_offsets(self) -> list[tuple[RowKey, int]]: y_offsets: list[tuple[RowKey, int]] = [] - for row in self._ordered_rows: + for row in self.ordered_rows: row_key = row.key row_height = row.height y_offsets += [(row_key, y) for y in range(row_height)] @@ -518,7 +518,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: """For a given column key, return the cells in that column in order""" - for row_metadata in self._ordered_rows: + for row_metadata in self.ordered_rows: row_key = row_metadata.key row = self.data.get(row_key) yield row.get(column_key) @@ -703,7 +703,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if row_index is None: continue for column, renderable in zip( - self._ordered_columns, self._get_row_renderables(row_index) + self.ordered_columns, self._get_row_renderables(row_index) ): content_width = measure(self.app.console, renderable, 1) column.content_width = max(column.content_width, content_width) @@ -728,11 +728,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.rows[row_key] # The x-coordinate of a cell is the sum of widths of cells to the left. - x = sum(column.render_width for column in self._ordered_columns[:column_index]) + x = sum(column.render_width for column in self.ordered_columns[:column_index]) column_key = self._column_locations.get_key(column_index) width = self.columns[column_key].render_width height = row.height - y = sum(ordered_row.height for ordered_row in self._ordered_rows[:row_index]) + y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index]) if self.show_header: y += self.header_height cell_region = Region(x, y, width, height) @@ -748,7 +748,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key = self._row_locations.get_key(row_index) row = rows[row_key] row_width = sum(column.render_width for column in self.columns.values()) - y = sum(ordered_row.height for ordered_row in self._ordered_rows[:row_index]) + y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index]) if self.show_header: y += self.header_height row_region = Region(0, y, row_width, row.height) @@ -761,7 +761,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if not valid_column: return Region(0, 0, 0, 0) - x = sum(column.render_width for column in self._ordered_columns[:column_index]) + x = sum(column.render_width for column in self.ordered_columns[:column_index]) column_key = self._column_locations.get_key(column_index) width = columns[column_key].render_width header_height = self.header_height if self.show_header else 0 @@ -855,7 +855,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_locations[row_key] = row_index self.data[row_key] = { column.key: Text(cell) if isinstance(cell, str) else cell - for column, cell in zip_longest(self._ordered_columns, cells) + for column, cell in zip_longest(self.ordered_columns, cells) } self.rows[row_key] = Row(row_key, height) self._new_rows.add(row_key) @@ -970,7 +970,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh(region) @property - def _ordered_columns(self) -> list[Column]: + def ordered_columns(self) -> list[Column]: column_indices = range(len(self.columns)) column_keys = [ self._column_locations.get_key(index) for index in column_indices @@ -979,7 +979,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return ordered_columns @property - def _ordered_rows(self) -> list[Row]: + def ordered_rows(self) -> list[Row]: row_indices = range(self.row_count) ordered_rows = [] for row_index in row_indices: @@ -999,7 +999,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ # TODO: We have quite a few back and forward key/index conversions, could probably reduce them - ordered_columns = self._ordered_columns + ordered_columns = self.ordered_columns if row_index == -1: row = [column.label for column in ordered_columns] return row @@ -1150,7 +1150,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): fixed_style += Style.from_meta({"fixed": True}) fixed_row = [] for column_index, column in enumerate( - self._ordered_columns[: self.fixed_columns] + self.ordered_columns[: self.fixed_columns] ): cell_location = Coordinate(row_index, column_index) fixed_cell_lines = render_cell( @@ -1179,7 +1179,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_style = base_style scrollable_row = [] - for column_index, column in enumerate(self._ordered_columns): + for column_index, column in enumerate(self.ordered_columns): cell_location = Coordinate(row_index, column_index) cell_lines = render_cell( row_index, @@ -1258,8 +1258,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): hover_location=self.hover_coordinate, ) fixed_width = sum( - column.render_width - for column in self._ordered_columns[: self.fixed_columns] + column.render_width for column in self.ordered_columns[: self.fixed_columns] ) fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] @@ -1308,8 +1307,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if row_index in self.rows ) left = sum( - column.render_width - for column in self._ordered_columns[: self.fixed_columns] + column.render_width for column in self.ordered_columns[: self.fixed_columns] ) return Spacing(top, 0, 0, left) From 0b2b7a964628b58056c46c33a91c116a300ca35e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 31 Jan 2023 16:43:33 +0000 Subject: [PATCH 067/155] Docstring improvements --- src/textual/widgets/_data_table.py | 31 ++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 8b4a91477..e393545ee 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -530,16 +530,30 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): coordinate: The coordinate to retrieve the value from. Returns: - The value of the cell. + The value of the cell at the coordinate. Raises: CellDoesNotExist: If there is no cell with the given coordinate. """ row_key, column_key = self.coordinate_to_cell_key(coordinate) + return self.get_cell_value(row_key, column_key) + + def get_cell_value(self, row_key: RowKey, column_key: ColumnKey) -> CellType: + """Given a row key and column key, return the value of the corresponding cell. + + Args: + row_key: The row key of the cell. + column_key: The column key of the cell. + + Returns: + The value of the cell identified by the row and column keys. + """ try: cell_value = self.data[row_key][column_key] except KeyError: - raise CellDoesNotExist(f"No cell exists at {coordinate!r}") from None + raise CellDoesNotExist( + f"No cell exists for row_key={row_key!r}, column_key={column_key!r}." + ) return cell_value def _clear_caches(self) -> None: @@ -549,6 +563,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._styles_cache.clear() def get_row_height(self, row_key: RowKey) -> int: + """Given a row key, return the height of that row in terminal cells. + + Args: + row_key: The key of the row. + + Returns: + The height of the row, measured in terminal character cells. + """ if row_key is self._header_row_key: return self.header_height return self.rows[row_key].height @@ -1216,7 +1238,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return y_offsets[y] def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: - """Render a line in to a list of segments. + """Render a (possibly cropped) line in to a Strip (a list of segments + representing a horizontal line). Args: y: Y coordinate of line @@ -1225,7 +1248,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): base_style: Style to apply to line. Returns: - List of segments for rendering. + The Strip which represents this cropped line. """ width = self.size.width From 655b2b3ea7d4a932d747f999e4e3d67ae64704c5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 10:57:03 +0000 Subject: [PATCH 068/155] Docstring updates --- src/textual/widgets/_data_table.py | 39 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e393545ee..2bc819ab7 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -445,22 +445,27 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def hover_row(self) -> int: + """The index of the row that the mouse cursor is currently hovering above.""" return self.hover_coordinate.row @property def hover_column(self) -> int: + """The index of the column that the mouse cursor is currently hovering above.""" return self.hover_coordinate.column @property def cursor_row(self) -> int: + """The index of the row that the DataTable cursor is currently on.""" return self.cursor_coordinate.row @property def cursor_column(self) -> int: + """The index of the column that the DataTable cursor is currently on.""" return self.cursor_coordinate.column @property def row_count(self) -> int: + """The number of rows currently present in the DataTable.""" return len(self.rows) @property @@ -586,7 +591,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # emit the appropriate [Row|Column|Cell]Highlighted event. self._scroll_cursor_into_view(animate=False) if self.cursor_type == "cell": - self._highlight_cell(self.cursor_coordinate) + self._highlight_coordinate(self.cursor_coordinate) elif self.cursor_type == "row": self._highlight_row(self.cursor_row) elif self.cursor_type == "column": @@ -613,7 +618,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # message to tell users of the newly highlighted row/cell/column. if self.cursor_type == "cell": self.refresh_cell(*old_coordinate) - self._highlight_cell(new_coordinate) + self._highlight_coordinate(new_coordinate) elif self.cursor_type == "row": self.refresh_row(old_coordinate.row) self._highlight_row(new_coordinate.row) @@ -621,7 +626,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh_column(old_coordinate.column) self._highlight_column(new_coordinate.column) - def _highlight_cell(self, coordinate: Coordinate) -> None: + def _highlight_coordinate(self, coordinate: Coordinate) -> None: """Apply highlighting to the cell at the coordinate, and emit event.""" self.refresh_cell(*coordinate) try: @@ -669,6 +674,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self._clamp_cursor_coordinate(value) def _clamp_cursor_coordinate(self, coordinate: Coordinate) -> Coordinate: + """Clamp a coordinate such that it falls within the boundaries of the table.""" row, column = coordinate row = clamp(row, 0, self.row_count - 1) column = clamp(column, self.fixed_columns, len(self.columns) - 1) @@ -692,11 +698,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._scroll_cursor_into_view() def _highlight_cursor(self) -> None: + """Applies the appropriate highlighting and raises the appropriate + [Row|Column|Cell]Highlighted event for the given cursor coordinate + and cursor type.""" row_index, column_index = self.cursor_coordinate cursor_type = self.cursor_type # Apply the highlighting to the newly relevant cells if cursor_type == "cell": - self._highlight_cell(self.cursor_coordinate) + self._highlight_coordinate(self.cursor_coordinate) elif cursor_type == "row": self._highlight_row(row_index) elif cursor_type == "column": @@ -931,9 +940,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_keys def on_idle(self) -> None: - # Add the new rows *before* updating the column widths, since - # cells in a new row may influence the final width of a column + """Runs when the message pump is empty, and so we use this for + some expensive calculations like re-computing dimensions of the + whole DataTable and re-computing column widths after some cells + have been updated. This is more efficient in the case of high + frequency updates, ensuring we only do expensive computations once.""" if self._require_update_dimensions: + # Add the new rows *before* updating the column widths, since + # cells in a new row may influence the final width of a column self._require_update_dimensions = False new_rows = self._new_rows.copy() self._new_rows.clear() @@ -993,6 +1007,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def ordered_columns(self) -> list[Column]: + """The list of Columns in the DataTable, ordered as they currently appear on screen.""" column_indices = range(len(self.columns)) column_keys = [ self._column_locations.get_key(index) for index in column_indices @@ -1002,6 +1017,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def ordered_rows(self) -> list[Row]: + """The list of Rows in the DataTable, ordered as they currently appear on screen.""" row_indices = range(self.row_count) ordered_rows = [] for row_index in row_indices: @@ -1113,12 +1129,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_location: Coordinate, hover_location: Coordinate, ) -> tuple[SegmentLines, SegmentLines]: - """Render a row in to lines for each cell. + """Render a single line from a row in the DataTable. Args: row_key: The identifying key for this row. line_no: Line number (y-coordinate) within row. 0 is the first strip of - cells in the row, line_no=1 is the next, and so on... + cells in the row, line_no=1 is the next line in the row, and so on... base_style: Base style of row. cursor_location: The location of the cursor in the DataTable. hover_location: The location of the hover cursor in the DataTable. @@ -1314,6 +1330,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self._render_line(y, scroll_x, scroll_x + width, self.rich_style) def on_mouse_move(self, event: events.MouseMove): + """If the hover cursor is visible, display it by extracting the row + and column metadata from the segments present in the cells.""" self._set_hover_cursor(True) meta = event.style.meta if meta and self.show_cursor and self.cursor_type != "none": @@ -1323,6 +1341,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): pass def _get_fixed_offset(self) -> Spacing: + """Calculate the "fixed offset", that is the space to the top and left + that is occupied by fixed rows and columns respectively. Fixed rows and columns + are rows and columns that do not participate in scrolling.""" top = self.header_height if self.show_header else 0 top += sum( self.rows[self._row_locations.get_key(row_index)].height @@ -1370,6 +1391,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh() def _scroll_cursor_into_view(self, animate: bool = False) -> None: + """When the cursor is at a boundary of the DataTable and moves out + of view, this method handles scrolling to ensure it remains visible.""" fixed_offset = self._get_fixed_offset() top, _, _, left = fixed_offset From 07e964d2ba6b67c39660ced64042f75b6758c113 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 11:14:08 +0000 Subject: [PATCH 069/155] More docstrings for the DataTable, new private property refactor for total_row_height --- src/textual/widgets/_data_table.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 2bc819ab7..d2cabe73f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -470,6 +470,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def _y_offsets(self) -> list[tuple[RowKey, int]]: + """Contains a 2-tuple for each line (not row!) of the DataTable. Given a y-coordinate, + we can index into this list to find which row that y-coordinate lands on, and the + y-offset *within* that row. The length of the returned list is therefore the total + height of all rows within the DataTable.""" y_offsets: list[tuple[RowKey, int]] = [] for row in self.ordered_rows: row_key = row.key @@ -477,6 +481,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): y_offsets += [(row_key, y) for y in range(row_height)] return y_offsets + @property + def _total_row_height(self) -> int: + """The total height of all rows within the DataTable""" + return len(self._y_offsets) + def update_cell( self, row_key: RowKey | str, @@ -744,7 +753,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): header_height = self.header_height if self.show_header else 0 self.virtual_size = Size( total_width, - len(self._y_offsets) + header_height, + self._total_row_height + header_height, ) def _get_cell_region(self, row_index: int, column_index: int) -> Region: @@ -796,7 +805,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key = self._column_locations.get_key(column_index) width = columns[column_key].render_width header_height = self.header_height if self.show_header else 0 - height = len(self._y_offsets) + header_height + height = self._total_row_height + header_height full_column_region = Region(x, 0, width, height) return full_column_region From cc3d744168e3098a05500731fae722c666649d6c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 11:15:31 +0000 Subject: [PATCH 070/155] Add row_key to RowHighlighted event in DataTable --- src/textual/widgets/_data_table.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d2cabe73f..8c27cf9ce 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -315,13 +315,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_row: The y-coordinate of the cursor that highlighted the row. """ - def __init__(self, sender: DataTable, cursor_row: int) -> None: + def __init__(self, sender: DataTable, cursor_row: int, row_key: RowKey) -> None: self.cursor_row: int = cursor_row + self.row_key: RowKey = row_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "cursor_row", self.cursor_row + yield "row_key", self.row_key class RowSelected(Message, bubble=True): """Emitted when a row is selected. This message is only emitted when the From bf42ac94f7f03bf89a8a712c1f6eedd4f5e8cc3a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 11:34:39 +0000 Subject: [PATCH 071/155] Ensure row_key is included in RowHighlighted event --- src/textual/widgets/_data_table.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 8c27cf9ce..cf5146787 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -416,8 +416,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_render_cache: LRUCache[ RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) + """For each row (a row can have a height of multiple lines), we maintain a cache + of the fixed and scrollable lines within that row to minimise how often we need to + re-render it.""" self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) + """Cache for individual cells.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) + """Cache for lines within rows.""" self._require_update_dimensions: bool = False """Set to re-calculate dimensions on idle.""" @@ -673,7 +678,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh_row(row_index) is_valid_row = row_index < len(self.data) if is_valid_row: - self.emit_no_wait(DataTable.RowHighlighted(self, row_index)) + row_key = self._row_locations.get_key(row_index) + self.emit_no_wait(DataTable.RowHighlighted(self, row_index, row_key)) def _highlight_column(self, column_index: int) -> None: """Apply highlighting to the column at the given index, and emit event.""" From c9629b1755cf01d11ead0a80faf5cf6814e0e1a7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 13:53:48 +0000 Subject: [PATCH 072/155] Ensure keys are included in emitted events from DataTable --- src/textual/widgets/_data_table.py | 38 +++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index cf5146787..6bec7fd11 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -313,6 +313,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: cursor_row: The y-coordinate of the cursor that highlighted the row. + row_key: The key of the row that was highlighted. """ def __init__(self, sender: DataTable, cursor_row: int, row_key: RowKey) -> None: @@ -333,15 +334,18 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: cursor_row: The y-coordinate of the cursor that made the selection. + row_key: The key of the row that was selected. """ - def __init__(self, sender: DataTable, cursor_row: int) -> None: + def __init__(self, sender: DataTable, cursor_row: int, row_key: RowKey) -> None: self.cursor_row: int = cursor_row + self.row_key: RowKey = row_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "cursor_row", self.cursor_row + yield "row_key", self.row_key class ColumnHighlighted(Message, bubble=True): """Emitted when a column is highlighted. This message is only emitted when the @@ -351,15 +355,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: cursor_column: The x-coordinate of the column that was highlighted. + column_key: The key of the column that was highlighted. """ - def __init__(self, sender: DataTable, cursor_column: int) -> None: + def __init__( + self, sender: DataTable, cursor_column: int, column_key: ColumnKey + ) -> None: self.cursor_column: int = cursor_column + self.column_key = column_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "cursor_column", self.cursor_column + yield "column_key", self.column_key class ColumnSelected(Message, bubble=True): """Emitted when a column is selected. This message is only emitted when the @@ -369,15 +378,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Attributes: cursor_column: The x-coordinate of the column that was selected. + column_key: The key of the column that was selected. """ - def __init__(self, sender: DataTable, cursor_column: int) -> None: + def __init__( + self, sender: DataTable, cursor_column: int, column_key: ColumnKey + ) -> None: self.cursor_column: int = cursor_column + self.column_key = column_key super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "cursor_column", self.cursor_column + yield "column_key", self.column_key def __init__( self, @@ -685,7 +699,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Apply highlighting to the column at the given index, and emit event.""" self.refresh_column(column_index) if column_index < len(self.columns): - self.emit_no_wait(DataTable.ColumnHighlighted(self, column_index)) + column_key = self._column_locations.get_key(column_index) + self.emit_no_wait( + DataTable.ColumnHighlighted(self, column_index, column_key) + ) def validate_cursor_coordinate(self, value: Coordinate) -> Coordinate: return self._clamp_cursor_coordinate(value) @@ -1500,18 +1517,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Emit the appropriate message for a selection based on the `cursor_type`.""" cursor_coordinate = self.cursor_coordinate cursor_type = self.cursor_type + cell_key = self.coordinate_to_cell_key(cursor_coordinate) if cursor_type == "cell": self.emit_no_wait( DataTable.CellSelected( self, self.get_value_at(cursor_coordinate), coordinate=cursor_coordinate, - cell_key=self.coordinate_to_cell_key(cursor_coordinate), + cell_key=cell_key, ) ) elif cursor_type == "row": - row, _ = cursor_coordinate - self.emit_no_wait(DataTable.RowSelected(self, row)) + row_index, _ = cursor_coordinate + row_key, _ = cell_key + self.emit_no_wait(DataTable.RowSelected(self, row_index, row_key)) elif cursor_type == "column": - _, column = cursor_coordinate - self.emit_no_wait(DataTable.ColumnSelected(self, column)) + _, column_index = cursor_coordinate + _, column_key = cell_key + self.emit_no_wait(DataTable.ColumnSelected(self, column_index, column_key)) From 53685ee2b58381f5c35e11b5adc89342e2f67867 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 13:59:44 +0000 Subject: [PATCH 073/155] Docstring update in DataTable --- src/textual/widgets/_data_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 6bec7fd11..aa7ccaac3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -552,7 +552,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.update_cell(row_key, column_key, value, update_width=update_width) def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: - """For a given column key, return the cells in that column in order""" + """For a given column key, return the cells in that column in the + order they currently appear on screen.""" for row_metadata in self.ordered_rows: row_key = row_metadata.key row = self.data.get(row_key) From 67d79e16dad694b27c734d70de507b1bba5a4c86 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 14:10:01 +0000 Subject: [PATCH 074/155] Simplify _get_offsets to return header row key --- src/textual/widgets/_data_table.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index aa7ccaac3..aedd06180 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1138,8 +1138,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if is_fixed_style: style += self.get_component_styles("datatable--cursor-fixed").rich_style - # TODO: We can hoist `row_key` lookup waaay up to do it inside `_get_offsets` - # then just pass it through to here instead of the row_index. row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) @@ -1240,7 +1238,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): else: fixed_row = [] - if row_key is None: + is_header_row = row_key is self._header_row_key + if is_header_row: row_style = self.get_component_styles("datatable--header").rich_style else: if self.zebra_stripes: @@ -1268,7 +1267,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_render_cache[cache_key] = row_pair return row_pair - def _get_offsets(self, y: int) -> tuple[RowKey | None, int]: + def _get_offsets(self, y: int) -> tuple[RowKey, int]: """Get row key and line offset for a given line. Args: @@ -1281,7 +1280,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): y_offsets = self._y_offsets if self.show_header: if y < header_height: - return None, y + return self._header_row_key, y y -= header_height if y > len(y_offsets): raise LookupError("Y coord {y!r} is greater than total height") From a7383e6a83823824decec010d47d51b20ebdb5dd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 14:51:05 +0000 Subject: [PATCH 075/155] Import and export datatable utilities from public module --- src/textual/widgets/_data_table.py | 15 +++++++-------- src/textual/widgets/data_table.py | 22 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index aedd06180..a607b9a67 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -39,15 +39,14 @@ from ..render import measure from ..scroll_view import ScrollView from ..strip import Strip -CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]" -LineCacheKey: TypeAlias = ( +_CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]" +_LineCacheKey: TypeAlias = ( "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]" ) -RowCacheKey: TypeAlias = ( +_RowCacheKey: TypeAlias = ( "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int]" ) CursorType = Literal["cell", "row", "column", "none"] -CELL: CursorType = "cell" CellType = TypeVar("CellType") @@ -237,7 +236,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): zebra_stripes = Reactive(False) header_height = Reactive(1) show_cursor = Reactive(True) - cursor_type = Reactive(CELL) + cursor_type = Reactive("cell") cursor_coordinate: Reactive[Coordinate] = Reactive( Coordinate(0, 0), repaint=False, always_update=True @@ -428,14 +427,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Maps column keys to column indices which represent column order.""" self._row_render_cache: LRUCache[ - RowCacheKey, tuple[SegmentLines, SegmentLines] + _RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) """For each row (a row can have a height of multiple lines), we maintain a cache of the fixed and scrollable lines within that row to minimise how often we need to re-render it.""" - self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) + self._cell_render_cache: LRUCache[_CellCacheKey, SegmentLines] = LRUCache(10000) """Cache for individual cells.""" - self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) + self._line_cache: LRUCache[_LineCacheKey, Strip] = LRUCache(1000) """Cache for lines within rows.""" self._require_update_dimensions: bool = False diff --git a/src/textual/widgets/data_table.py b/src/textual/widgets/data_table.py index d0316f387..429724361 100644 --- a/src/textual/widgets/data_table.py +++ b/src/textual/widgets/data_table.py @@ -1,5 +1,23 @@ """Make non-widget DataTable support classes available.""" -from ._data_table import Column, Row +from ._data_table import ( + Column, + Row, + RowKey, + ColumnKey, + CellKey, + CursorType, + CellType, + CellDoesNotExist, +) -__all__ = ["Column", "Row"] +__all__ = [ + "Column", + "Row", + "RowKey", + "ColumnKey", + "CellKey", + "CursorType", + "CellType", + "CellDoesNotExist", +] From 3f463cb0ef4cae4d31e8f8f2f26f96bac96c141d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 14:54:33 +0000 Subject: [PATCH 076/155] Store strings as strings --- src/textual/widgets/_data_table.py | 2 +- tests/test_data_table.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index a607b9a67..d70fce77d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -919,7 +919,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Map the key of this row to its current index self._row_locations[row_key] = row_index self.data[row_key] = { - column.key: Text(cell) if isinstance(cell, str) else cell + column.key: cell for column, cell in zip_longest(self.ordered_columns, cells) } self.rows[row_key] = Row(row_key, height) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 28de49c75..23004419e 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -155,14 +155,14 @@ async def test_add_rows_user_defined_keys(): assert isinstance(algernon_key, RowKey) # Ensure the data in the table is mapped as expected - first_row = {key_a: Text(ROWS[0][0]), key_b: Text(ROWS[0][1])} + first_row = {key_a: ROWS[0][0], key_b: ROWS[0][1]} assert table.data[algernon_key] == first_row assert table.data["algernon"] == first_row - second_row = {key_a: Text(ROWS[1][0]), key_b: Text(ROWS[1][1])} + second_row = {key_a: ROWS[1][0], key_b: ROWS[1][1]} assert table.data["charlie"] == second_row - third_row = {key_a: Text(ROWS[2][0]), key_b: Text(ROWS[2][1])} + third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} assert table.data[auto_key] == third_row first_row = Row(algernon_key, height=1) @@ -253,7 +253,7 @@ async def test_get_cell_value_returns_value_at_cell(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) - assert table.get_value_at(Coordinate(0, 0)) == Text("0/0") + assert table.get_value_at(Coordinate(0, 0)) == "0/0" async def test_get_cell_value_exception(): From c84ae53395a88609b1efabcf24541aba16882724 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 15:07:38 +0000 Subject: [PATCH 077/155] Fix docstring indentation to fix mkdocs rendering --- src/textual/widgets/_data_table.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d70fce77d..66a59498b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -947,8 +947,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: A list of the keys for the columns that were added. See - the `add_column` method docstring for more information on how - these keys are used. + the `add_column` method docstring for more information on how + these keys are used. """ column_keys = [] for label in labels: @@ -964,8 +964,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: A list of the keys for the rows that were added. See - the `add_row` method docstring for more information on how - these keys are used. + the `add_row` method docstring for more information on how + these keys are used. """ row_keys = [] for row in rows: From 43c2696ccfe54b7345b496d3f63f44dd8386e603 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 15:25:13 +0000 Subject: [PATCH 078/155] Small rename in DataTable utility types --- src/textual/widgets/_data_table.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 66a59498b..d54b96fc6 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -39,11 +39,11 @@ from ..render import measure from ..scroll_view import ScrollView from ..strip import Strip -_CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]" -_LineCacheKey: TypeAlias = ( +CellCacheKey: TypeAlias = "tuple[RowKey, ColumnKey, Style, bool, bool, int]" +LineCacheKey: TypeAlias = ( "tuple[int, int, int, int, Coordinate, Coordinate, Style, CursorType, bool, int]" ) -_RowCacheKey: TypeAlias = ( +RowCacheKey: TypeAlias = ( "tuple[RowKey, int, Style, Coordinate, Coordinate, CursorType, bool, bool, int]" ) CursorType = Literal["cell", "row", "column", "none"] @@ -427,14 +427,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Maps column keys to column indices which represent column order.""" self._row_render_cache: LRUCache[ - _RowCacheKey, tuple[SegmentLines, SegmentLines] + RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) """For each row (a row can have a height of multiple lines), we maintain a cache of the fixed and scrollable lines within that row to minimise how often we need to re-render it.""" - self._cell_render_cache: LRUCache[_CellCacheKey, SegmentLines] = LRUCache(10000) + self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) """Cache for individual cells.""" - self._line_cache: LRUCache[_LineCacheKey, Strip] = LRUCache(1000) + self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) """Cache for lines within rows.""" self._require_update_dimensions: bool = False @@ -974,7 +974,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_keys def on_idle(self) -> None: - """Runs when the message pump is empty, and so we use this for + """Runs when the message pump is empty. We use this for some expensive calculations like re-computing dimensions of the whole DataTable and re-computing column widths after some cells have been updated. This is more efficient in the case of high From fd4e13c988a3649c071c0f36d88665131dfcdfa9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 15:43:36 +0000 Subject: [PATCH 079/155] Add tests for DataTable.get_cell_value --- tests/test_data_table.py | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 23004419e..378de0a17 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -5,8 +5,7 @@ from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable -from textual.widgets._data_table import ( - StringKey, +from textual.widgets.data_table import ( CellDoesNotExist, RowKey, Row, @@ -248,6 +247,35 @@ async def test_column_widths() -> None: async def test_get_cell_value_returns_value_at_cell(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("Column1", key="C1") + table.add_row("TargetValue", key="R1") + assert table.get_cell_value("R1", "C1") == "TargetValue" + + +async def test_get_cell_value_invalid_row_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("Column1", key="C1") + table.add_row("TargetValue", key="R1") + with pytest.raises(CellDoesNotExist): + table.get_cell_value("INVALID_ROW", "C1") + + +async def test_get_cell_value_invalid_column_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("Column1", key="C1") + table.add_row("TargetValue", key="R1") + with pytest.raises(CellDoesNotExist): + table.get_cell_value("R1", "INVALID_COLUMN") + + +async def test_get_value_at_returns_value_at_cell(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) @@ -256,7 +284,7 @@ async def test_get_cell_value_returns_value_at_cell(): assert table.get_value_at(Coordinate(0, 0)) == "0/0" -async def test_get_cell_value_exception(): +async def test_get_value_at_exception(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) @@ -266,15 +294,24 @@ async def test_get_cell_value_exception(): table.get_value_at(Coordinate(9999, 0)) +# async def test_update_cell_cell_exists(): +# app = DataTableApp() +# async with app.run_test(): +# table = app.query_one(DataTable) +# table.add_column("A", key="A") +# table.add_row("1", key="1") +# assert table.get_cell_value() + + def test_key_equals_equivalent_string(): text = "Hello" - key = StringKey(text) + key = RowKey(text) assert key == text assert hash(key) == hash(text) def test_key_doesnt_match_non_equal_string(): - key = StringKey("123") + key = ColumnKey("123") text = "laksjdlaskjd" assert key != text assert hash(key) != hash(text) @@ -293,9 +330,9 @@ def test_key_string_lookup(): # in tests how we intend for the keys to work for cache lookups. dictionary = { "foo": "bar", - StringKey("hello"): "world", + RowKey("hello"): "world", } assert dictionary["foo"] == "bar" - assert dictionary[StringKey("foo")] == "bar" + assert dictionary[RowKey("foo")] == "bar" assert dictionary["hello"] == "world" - assert dictionary[StringKey("hello")] == "world" + assert dictionary[RowKey("hello")] == "world" From 23a34030cd1dcec13b21cd3d451eca42d27d06bf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 17:10:59 +0000 Subject: [PATCH 080/155] Measuring string cells correctly --- src/textual/render.py | 4 ++++ src/textual/widgets/_data_table.py | 11 ++++------- tests/test_data_table.py | 21 ++++++++++++--------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/textual/render.py b/src/textual/render.py index 8911c4263..c1003b062 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -1,5 +1,6 @@ from __future__ import annotations +from rich.cells import cell_len from rich.console import Console, RenderableType from rich.protocol import rich_cast @@ -22,6 +23,9 @@ def measure( Returns: Width in cells """ + if isinstance(renderable, str): + return cell_len(renderable) + width = default renderable = rich_cast(renderable) get_console_width = getattr(renderable, "__rich_measure__", None) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d54b96fc6..a9a00fbc3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -524,8 +524,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): update_width: Whether to resize the column width to accommodate for the new cell content. """ - value = Text.from_markup(value) if isinstance(value, str) else value - self.data[row_key][column_key] = value self._update_count += 1 @@ -752,6 +750,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): label_width = measure(console, column.label, 1) content_width = column.content_width cell_value = self.data[row_key][column_key] + new_content_width = measure(console, cell_value, 1) if new_content_width < content_width: @@ -866,15 +865,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other columns). """ - text_label = Text.from_markup(label) if isinstance(label, str) else label - column_key = ColumnKey(key) column_index = len(self.columns) - content_width = measure(self.app.console, text_label, 1) + content_width = measure(self.app.console, label, 1) if width is None: column = Column( column_key, - text_label, + label, content_width, content_width=content_width, auto_width=True, @@ -882,7 +879,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): else: column = Column( column_key, - text_label, + label, width, content_width=content_width, ) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 378de0a17..23499e3eb 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -178,7 +178,6 @@ async def test_add_columns(): assert len(table.columns) == 3 -# TODO: Ensure we can use the key to retrieve the column. async def test_add_columns_user_defined_keys(): app = DataTableApp() async with app.run_test(): @@ -223,7 +222,7 @@ async def test_column_labels() -> None: table = app.query_one(DataTable) table.add_columns("1", "2", "3") actual_labels = [col.label for col in table.columns.values()] - expected_labels = [Text("1"), Text("2"), Text("3")] + expected_labels = ["1", "2", "3"] assert actual_labels == expected_labels @@ -294,13 +293,17 @@ async def test_get_value_at_exception(): table.get_value_at(Coordinate(9999, 0)) -# async def test_update_cell_cell_exists(): -# app = DataTableApp() -# async with app.run_test(): -# table = app.query_one(DataTable) -# table.add_column("A", key="A") -# table.add_row("1", key="1") -# assert table.get_cell_value() +async def test_update_cell_cell_exists(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("A", key="A") + table.add_row("1", key="1") + table.update_cell("1", "A", "NEW_VALUE") + assert table.get_cell_value("1", "A") == "NEW_VALUE" + + +# TODO: Test update coordinate def test_key_equals_equivalent_string(): From 77b94b005ce3251f741f30423091ab3cf1013fe8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 1 Feb 2023 17:34:03 +0000 Subject: [PATCH 081/155] Testing case where you try to update cells which dont exist --- src/textual/widgets/_data_table.py | 12 +++++++++++- tests/test_data_table.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index a9a00fbc3..0668630c9 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -523,8 +523,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): value: The new value to put inside the cell. update_width: Whether to resize the column width to accommodate for the new cell content. + + Raises: + CellDoesNotExist: When the supplied `row_key` and `column_key` + cannot be found in the table. """ - self.data[row_key][column_key] = value + try: + self.data[row_key][column_key] = value + except KeyError: + raise CellDoesNotExist( + f"No cell exists for row_key={row_key!r}, column_key={column_key!r}." + ) from None self._update_count += 1 # Recalculate widths if necessary @@ -545,6 +554,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): update_width: Whether to resize the column width to accommodate for the new cell content. """ + # TODO: Validate coordinate and raise exception row_key, column_key = self.coordinate_to_cell_key(coordinate) self.update_cell(row_key, column_key, value, update_width=update_width) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 23499e3eb..b4f20f811 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -303,6 +303,16 @@ async def test_update_cell_cell_exists(): assert table.get_cell_value("1", "A") == "NEW_VALUE" +async def test_update_cell_cell_doesnt_exist(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("A", key="A") + table.add_row("1", key="1") + with pytest.raises(CellDoesNotExist): + table.update_cell("INVALID", "CELL", "Value") + + # TODO: Test update coordinate From 990a6311bccbf2539bfcac209a0564bae7d206ba Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 13:09:11 +0000 Subject: [PATCH 082/155] Extract common coordinate validation logic into method in DataTable --- src/textual/widgets/_data_table.py | 96 ++++++++++++++++++++---------- tests/test_data_table.py | 19 +++++- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 0668630c9..e82b76384 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -86,8 +86,8 @@ class ColumnKey(StringKey): class CellKey(NamedTuple): - row_key: RowKey - column_key: ColumnKey + row_key: RowKey | str + column_key: ColumnKey | str def default_cell_formatter(obj: object) -> RenderableType | None: @@ -554,7 +554,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): update_width: Whether to resize the column width to accommodate for the new cell content. """ - # TODO: Validate coordinate and raise exception + if not self.is_valid_coordinate(coordinate): + raise CellDoesNotExist() + row_key, column_key = self.coordinate_to_cell_key(coordinate) self.update_cell(row_key, column_key, value, update_width=update_width) @@ -645,8 +647,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._clear_caches() def watch_hover_coordinate(self, old: Coordinate, value: Coordinate) -> None: - self.refresh_cell(*old) - self.refresh_cell(*value) + self.refresh_coordinate(old) + self.refresh_coordinate(value) def watch_cursor_coordinate( self, old_coordinate: Coordinate, new_coordinate: Coordinate @@ -655,7 +657,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Refresh the old and the new cell, and emit the appropriate # message to tell users of the newly highlighted row/cell/column. if self.cursor_type == "cell": - self.refresh_cell(*old_coordinate) + self.refresh_coordinate(old_coordinate) self._highlight_coordinate(new_coordinate) elif self.cursor_type == "row": self.refresh_row(old_coordinate.row) @@ -666,7 +668,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _highlight_coordinate(self, coordinate: Coordinate) -> None: """Apply highlighting to the cell at the coordinate, and emit event.""" - self.refresh_cell(*coordinate) + self.refresh_coordinate(coordinate) try: cell_value = self.get_value_at(coordinate) except CellDoesNotExist: @@ -690,6 +692,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: The key of the cell currently occupying this coordinate. """ + if not self.is_valid_coordinate(coordinate): + raise CellDoesNotExist() row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) @@ -729,12 +733,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Refresh cells that were previously impacted by the cursor # but may no longer be. - row_index, column_index = self.cursor_coordinate if old == "cell": - self.refresh_cell(row_index, column_index) + self.refresh_coordinate(self.cursor_coordinate) elif old == "row": + row_index, _ = self.cursor_coordinate self.refresh_row(row_index) elif old == "column": + _, column_index = self.cursor_coordinate self.refresh_column(column_index) self._scroll_cursor_into_view() @@ -790,14 +795,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._total_row_height + header_height, ) - def _get_cell_region(self, row_index: int, column_index: int) -> Region: + def _get_cell_region(self, coordinate: Coordinate) -> Region: """Get the region of the cell at the given spatial coordinate.""" - valid_row = 0 <= row_index < len(self.rows) - valid_column = 0 <= column_index < len(self.columns) - valid_cell = valid_row and valid_column - if not valid_cell: + if not self.is_valid_coordinate(coordinate): return Region(0, 0, 0, 0) + row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) row = self.rows[row_key] @@ -814,11 +817,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_row_region(self, row_index: int) -> Region: """Get the region of the row at the given index.""" - rows = self.rows - valid_row = 0 <= row_index < len(rows) - if not valid_row: + if not self.is_valid_row_index(row_index): return Region(0, 0, 0, 0) + rows = self.rows row_key = self._row_locations.get_key(row_index) row = rows[row_key] row_width = sum(column.render_width for column in self.columns.values()) @@ -830,11 +832,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _get_column_region(self, column_index: int) -> Region: """Get the region of the column at the given index.""" - columns = self.columns - valid_column = 0 <= column_index < len(columns) - if not valid_column: + if not self.is_valid_column_index(column_index): return Region(0, 0, 0, 0) + columns = self.columns x = sum(column.render_width for column in self.ordered_columns[:column_index]) column_key = self._column_locations.get_key(column_index) width = columns[column_key].render_width @@ -1001,16 +1002,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._updated_cells.clear() self._update_column_widths(updated_columns) - def refresh_cell(self, row_index: int, column_index: int) -> None: - """Refresh a cell. + def refresh_coordinate(self, coordinate: Coordinate) -> None: + """Refresh the cell at a coordinate. Args: - row_index: Row index. - column_index: Column index. + coordinate: The coordinate to refresh. """ - if row_index < 0 or column_index < 0: + if not self.is_valid_coordinate(coordinate): return - region = self._get_cell_region(row_index, column_index) + region = self._get_cell_region(coordinate) self._refresh_region(region) def refresh_row(self, row_index: int) -> None: @@ -1019,7 +1019,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: row_index: The index of the row to refresh. """ - if row_index < 0 or row_index >= len(self.rows): + if not self.is_valid_row_index(row_index): return region = self._get_row_region(row_index) @@ -1031,7 +1031,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: column_index: The index of the column to refresh. """ - if column_index < 0 or column_index >= len(self.columns): + if not self.is_valid_column_index(column_index): return region = self._get_column_region(column_index) @@ -1046,6 +1046,42 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): region = region.translate(-self.scroll_offset) self.refresh(region) + def is_valid_row_index(self, row_index: int) -> bool: + """Return a boolean indicating whether the row_index is within table bounds. + + Args: + row_index: The row index to check. + + Returns: + True if the row index is within the bounds of the table. + """ + return 0 <= row_index < len(self.rows) + + def is_valid_column_index(self, column_index: int) -> bool: + """Return a boolean indicating whether the column_index is within table bounds. + + Args: + column_index: The column index to check. + + Returns: + True if the column index is within the bounds of the table. + """ + return 0 <= column_index < len(self.columns) + + def is_valid_coordinate(self, coordinate: Coordinate) -> bool: + """Return a boolean indicating whether the given coordinate is within table bounds. + + Args: + coordinate: The coordinate to validate. + + Returns: + True if the coordinate is within the bounds of the table. + """ + row_index, column_index = coordinate + return self.is_valid_row_index(row_index) and self.is_valid_column_index( + column_index + ) + @property def ordered_columns(self) -> list[Column]: """The list of Columns in the DataTable, ordered as they currently appear on screen.""" @@ -1443,7 +1479,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): x, y, width, height = self._get_column_region(self.cursor_column) region = Region(x, int(self.scroll_y) + top, width, height - top) else: - region = self._get_cell_region(self.cursor_row, self.cursor_column) + region = self._get_cell_region(self.cursor_coordinate) self.scroll_to_region(region, animate=animate, spacing=fixed_offset) @@ -1463,7 +1499,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif cursor_type == "row": self.refresh_row(self.hover_row) elif cursor_type == "cell": - self.refresh_cell(*self.hover_coordinate) + self.refresh_coordinate(self.hover_coordinate) def on_click(self, event: events.Click) -> None: self._set_hover_cursor(True) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index b4f20f811..40d399f93 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -313,7 +313,24 @@ async def test_update_cell_cell_doesnt_exist(): table.update_cell("INVALID", "CELL", "Value") -# TODO: Test update coordinate +async def test_update_coordinate_coordinate_exists(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column_0, column_1 = table.add_columns("A", "B") + row_0, *_ = table.add_rows(ROWS) + table.update_coordinate(Coordinate(0, 1), "newvalue") + assert table.get_cell_value(row_0, column_1) == "newvalue" + + +async def test_update_coordinate_coordinate_doesnt_exist(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("A", "B") + table.add_rows(ROWS) + with pytest.raises(CellDoesNotExist): + table.update_coordinate(Coordinate(999, 999), "newvalue") def test_key_equals_equivalent_string(): From 7748b69e954f107767ad2615e6c36dbd0ba9c3f5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 14:12:14 +0000 Subject: [PATCH 083/155] Initial unit tests around column width updates --- tests/test_data_table.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 40d399f93..fccd4e275 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,6 +1,9 @@ +import asyncio + import pytest from rich.text import Text +from textual._wait import wait_for_idle from textual.app import App from textual.coordinate import Coordinate from textual.message import Message @@ -226,21 +229,21 @@ async def test_column_labels() -> None: assert actual_labels == expected_labels -async def test_column_widths() -> None: +async def test_initial_column_widths() -> None: app = DataTableApp() - async with app.run_test() as pilot: + async with app.run_test(): table = app.query_one(DataTable) foo, bar = table.add_columns("foo", "bar") assert table.columns[foo].width == 3 assert table.columns[bar].width == 3 table.add_row("Hello", "World!") - await pilot.pause() + await wait_for_idle() assert table.columns[foo].content_width == 5 assert table.columns[bar].content_width == 6 table.add_row("Hello World!!!", "fo") - await pilot.pause() + await wait_for_idle() assert table.columns[foo].content_width == 14 assert table.columns[bar].content_width == 6 @@ -319,6 +322,10 @@ async def test_update_coordinate_coordinate_exists(): table = app.query_one(DataTable) column_0, column_1 = table.add_columns("A", "B") row_0, *_ = table.add_rows(ROWS) + + columns = table.columns + column = columns.get(column_1) + table.update_coordinate(Coordinate(0, 1), "newvalue") assert table.get_cell_value(row_0, column_1) == "newvalue" @@ -333,6 +340,31 @@ async def test_update_coordinate_coordinate_doesnt_exist(): table.update_coordinate(Coordinate(999, 999), "newvalue") +@pytest.mark.parametrize( + "label,new_value,new_content_width", + [ + # We update the value of a cell to a value longer than the initial value, + # but shorter than the column label. The column label width should be used. + ("1234567", "1234", 7), + # We update the value of a cell to a value larger than the initial value, + # so the width of the column should be increased to accommodate on idle. + ("1234567", "123456789", 9), + ], +) +async def test_update_coordinate_column_width(label, new_value, new_content_width): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + key, _ = table.add_columns(label, "Column2") + table.add_rows(ROWS) + first_column = table.columns.get(key) + + table.update_coordinate(Coordinate(0, 0), new_value, update_width=True) + await wait_for_idle() + assert first_column.content_width == new_content_width + assert first_column.render_width == new_content_width + 2 + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 134ceffd110ea070630c54ed9611dd82bf629622 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 14:20:33 +0000 Subject: [PATCH 084/155] Testing to ensure column size calculated correctly --- tests/test_data_table.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index fccd4e275..80caf04ee 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -343,12 +343,16 @@ async def test_update_coordinate_coordinate_doesnt_exist(): @pytest.mark.parametrize( "label,new_value,new_content_width", [ - # We update the value of a cell to a value longer than the initial value, - # but shorter than the column label. The column label width should be used. + # Initial cell values are length 3. Let's update cell content and ensure + # that the width of the column is calculated given the new cell width. + # Shorter than initial cell value, larger than label => width remains same + ("A", "BB", 3), + # Larger than initial cell value, shorter than label => width remains that of label ("1234567", "1234", 7), - # We update the value of a cell to a value larger than the initial value, - # so the width of the column should be increased to accommodate on idle. - ("1234567", "123456789", 9), + # Shorter than initial cell value, shorter than label => width remains same + ("12345", "123", 5), + # Larger than initial cell value, larger than label => width updates to new cell value + ("12345", "123456789", 9), ], ) async def test_update_coordinate_column_width(label, new_value, new_content_width): From 87808c63b284d59f520d591d0e71355c89920558 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 15:29:26 +0000 Subject: [PATCH 085/155] Tidying some tests --- tests/test_data_table.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 80caf04ee..fed155f8b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -323,9 +323,6 @@ async def test_update_coordinate_coordinate_exists(): column_0, column_1 = table.add_columns("A", "B") row_0, *_ = table.add_rows(ROWS) - columns = table.columns - column = columns.get(column_1) - table.update_coordinate(Coordinate(0, 1), "newvalue") assert table.get_cell_value(row_0, column_1) == "newvalue" @@ -343,8 +340,6 @@ async def test_update_coordinate_coordinate_doesnt_exist(): @pytest.mark.parametrize( "label,new_value,new_content_width", [ - # Initial cell values are length 3. Let's update cell content and ensure - # that the width of the column is calculated given the new cell width. # Shorter than initial cell value, larger than label => width remains same ("A", "BB", 3), # Larger than initial cell value, shorter than label => width remains that of label @@ -356,6 +351,9 @@ async def test_update_coordinate_coordinate_doesnt_exist(): ], ) async def test_update_coordinate_column_width(label, new_value, new_content_width): + # Initial cell values are length 3. Let's update cell content and ensure + # that the width of the column is correct given the new cell content widths + # and the label of the column the cell is in. app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) From 62fb9d58bdf72fba52fa4611d3afce029380078d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 15:40:24 +0000 Subject: [PATCH 086/155] Testing conversion of coordinate to cell_key --- tests/test_data_table.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index fed155f8b..49d490a06 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -8,6 +8,7 @@ from textual.app import App from textual.coordinate import Coordinate from textual.message import Message from textual.widgets import DataTable +from textual.widgets._data_table import CellKey from textual.widgets.data_table import ( CellDoesNotExist, RowKey, @@ -367,6 +368,17 @@ async def test_update_coordinate_column_width(label, new_value, new_content_widt assert first_column.render_width == new_content_width + 2 +async def test_coordinate_to_cell_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column_key, _ = table.add_columns("Column0", "Column1") + row_key = table.add_row("A", "B") + + cell_key = table.coordinate_to_cell_key(Coordinate(0, 0)) + assert cell_key == CellKey(row_key, column_key) + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 18aaeaa2844ae586e14a041a9f134acb52cb0374 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 15:41:24 +0000 Subject: [PATCH 087/155] Add explanatory message to an exception in DataTable --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e82b76384..ae0c30e2d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -693,7 +693,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): The key of the cell currently occupying this coordinate. """ if not self.is_valid_coordinate(coordinate): - raise CellDoesNotExist() + raise CellDoesNotExist(f"No cell exists at {coordinate!r}.") row_index, column_index = coordinate row_key = self._row_locations.get_key(row_index) column_key = self._column_locations.get_key(column_index) From 998ee9b8a217fd6204b39e08defca81cb8ca952a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 2 Feb 2023 15:44:25 +0000 Subject: [PATCH 088/155] Test to ensure correct exception raised when converting to cell key from coordinate in DataTable --- tests/test_data_table.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 49d490a06..1555646de 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -379,6 +379,14 @@ async def test_coordinate_to_cell_key(): assert cell_key == CellKey(row_key, column_key) +async def test_coordinate_to_cell_key_invalid_coordinate(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + with pytest.raises(CellDoesNotExist): + table.coordinate_to_cell_key(Coordinate(9999, 9999)) + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 60ae08594f9666e389993085305f6f6018a83dcd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 6 Feb 2023 12:53:30 +0000 Subject: [PATCH 089/155] Ensure correct indices added in event --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 5ea9f0a59..2b84c8a03 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1504,10 +1504,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._set_hover_cursor(True) if self.show_cursor and self.cursor_type != "none": # Only emit selection events if there is a visible row/col/cell cursor. - self._emit_selected_message() meta = self.get_style_at(event.x, event.y).meta if meta: self.cursor_coordinate = Coordinate(meta["row"], meta["column"]) + self._emit_selected_message() self._scroll_cursor_into_view(animate=True) event.stop() From 76d2ff2999653e9f37c465c9f0250e5250f9e00b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 10:49:01 +0000 Subject: [PATCH 090/155] Testing clicks in DataTable --- tests/test_data_table.py | 89 ++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 1555646de..957be18e4 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,11 +1,11 @@ -import asyncio +from __future__ import annotations import pytest -from rich.text import Text from textual._wait import wait_for_idle from textual.app import App from textual.coordinate import Coordinate +from textual.events import Click from textual.message import Message from textual.widgets import DataTable from textual.widgets._data_table import CellKey @@ -20,7 +20,6 @@ ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] class DataTableApp(App): - messages = [] messages_to_record = { "CellHighlighted", "CellSelected", @@ -30,6 +29,10 @@ class DataTableApp(App): "ColumnSelected", } + def __init__(self): + super().__init__() + self.messages = [] + def compose(self): table = DataTable() table.focus() @@ -38,7 +41,11 @@ class DataTableApp(App): def record_data_table_event(self, message: Message) -> None: name = message.__class__.__name__ if name in self.messages_to_record: - self.messages.append(name) + self.messages.append(message) + + @property + def message_names(self) -> list[str]: + return [message.__class__.__name__ for message in self.messages] async def _on_message(self, message: Message) -> None: await super()._on_message(message) @@ -47,12 +54,11 @@ class DataTableApp(App): async def test_datatable_message_emission(): app = DataTableApp() - messages = app.messages expected_messages = [] async with app.run_test() as pilot: table = app.query_one(DataTable) - assert messages == expected_messages + assert app.message_names == expected_messages table.add_columns("Column0", "Column1") table.add_rows(ROWS) @@ -62,48 +68,48 @@ async def test_datatable_message_emission(): # so the cell at (0, 0) became highlighted. expected_messages.append("CellHighlighted") await pilot.pause(2 / 100) - assert messages == expected_messages + assert app.message_names == expected_messages # Pressing Enter when the cursor is on a cell emits a CellSelected await pilot.press("enter") expected_messages.append("CellSelected") - assert messages == expected_messages + assert app.message_names == expected_messages # Moving the cursor left and up when the cursor is at origin # emits no events, since the cursor doesn't move at all. await pilot.press("left", "up") - assert messages == expected_messages + assert app.message_names == expected_messages # ROW CURSOR # Switch over to the row cursor... should emit a `RowHighlighted` table.cursor_type = "row" expected_messages.append("RowHighlighted") await pilot.pause(2 / 100) - assert messages == expected_messages + assert app.message_names == expected_messages # Select the row... await pilot.press("enter") expected_messages.append("RowSelected") - assert messages == expected_messages + assert app.message_names == expected_messages # COLUMN CURSOR # Switching to the column cursor emits a `ColumnHighlighted` table.cursor_type = "column" expected_messages.append("ColumnHighlighted") await pilot.pause(2 / 100) - assert messages == expected_messages + assert app.message_names == expected_messages # Select the column... await pilot.press("enter") expected_messages.append("ColumnSelected") - assert messages == expected_messages + assert app.message_names == expected_messages # NONE CURSOR # No messages get emitted at all... table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") # No new messages since cursor not visible - assert messages == expected_messages + assert app.message_names == expected_messages # Edge case - if show_cursor is False, and the cursor type # is changed back to a visible type, then no messages should @@ -112,21 +118,37 @@ async def test_datatable_message_emission(): table.cursor_type = "cell" await pilot.press("up", "down", "left", "right", "enter") # No new messages since show_cursor = False - assert messages == expected_messages + assert app.message_names == expected_messages # Now when show_cursor is set back to True, the appropriate # message should be emitted for highlighting the cell. table.show_cursor = True expected_messages.append("CellHighlighted") await pilot.pause(2 / 100) - assert messages == expected_messages + assert app.message_names == expected_messages + + # Similarly for showing the cursor again when row or column + # cursor was active before the cursor was hidden. + table.show_cursor = False + table.cursor_type = "row" + table.show_cursor = True + expected_messages.append("RowHighlighted") + await pilot.pause(2 / 100) + assert app.message_names == expected_messages + + table.show_cursor = False + table.cursor_type = "column" + table.show_cursor = True + expected_messages.append("ColumnHighlighted") + await pilot.pause(2 / 100) + assert app.message_names == expected_messages # Likewise, if the cursor_type is "none", and we change the # show_cursor to True, then no events should be raised since # the cursor is still not visible to the user. table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") - assert messages == expected_messages + assert app.message_names == expected_messages async def test_add_rows(): @@ -173,6 +195,17 @@ async def test_add_rows_user_defined_keys(): assert table.rows["algernon"] == first_row +async def test_add_column_with_width(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column = table.add_column("ABC", width=10, key="ABC") + row = table.add_row("123") + assert table.get_cell_value(row, column) == "123" + assert table.columns[column].width == 10 + assert table.columns[column].render_width == 12 # 10 + (2 padding) + + async def test_add_columns(): app = DataTableApp() async with app.run_test(): @@ -387,6 +420,28 @@ async def test_coordinate_to_cell_key_invalid_coordinate(): table.coordinate_to_cell_key(Coordinate(9999, 9999)) +async def test_datatable_on_click_cell_cursor(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + click = Click( + sender=app, + x=1, + y=1, + delta_x=3, + delta_y=3, + button=0, + shift=False, + meta=False, + ctrl=False, + ) + table.add_column("ABC") + table.add_row("123") + table.on_click(event=click) + await wait_for_idle(0) + assert app.message_names == ["CellHighlighted", "CellSelected"] + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 3e451e84162a6600a05bf614b946641393f0c67e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 11:09:21 +0000 Subject: [PATCH 091/155] Testing that data inside DataTable events correct on click --- src/textual/message.py | 1 - tests/test_data_table.py | 98 +++++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/textual/message.py b/src/textual/message.py index da0c093a7..4cdd6f359 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -9,7 +9,6 @@ from .case import camel_to_snake from ._types import MessageTarget as MessageTarget if TYPE_CHECKING: - from .widget import Widget from .message_pump import MessagePump diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 957be18e4..9e1c9ee9f 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -7,6 +7,7 @@ from textual.app import App from textual.coordinate import Coordinate from textual.events import Click from textual.message import Message +from textual.message_pump import MessagePump from textual.widgets import DataTable from textual.widgets._data_table import CellKey from textual.widgets.data_table import ( @@ -420,26 +421,99 @@ async def test_coordinate_to_cell_key_invalid_coordinate(): table.coordinate_to_cell_key(Coordinate(9999, 9999)) +def click_in_app(sender: MessagePump): + return Click( + sender=sender, + x=1, + y=2, + delta_x=0, + delta_y=0, + button=0, + shift=False, + meta=False, + ctrl=False, + ) + + async def test_datatable_on_click_cell_cursor(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) - click = Click( - sender=app, - x=1, - y=1, - delta_x=3, - delta_y=3, - button=0, - shift=False, - meta=False, - ctrl=False, - ) + click = click_in_app(app) + column_key = table.add_column("ABC") + table.add_row("123") + row_key = table.add_row("456") + table.on_click(event=click) + await wait_for_idle(0) + # There's two CellHighlighted events since a cell is highlighted on initial load, + # then when we click, another cell is highlighted (and selected). + assert app.message_names == [ + "CellHighlighted", + "CellHighlighted", + "CellSelected", + ] + cell_highlighted_event: DataTable.CellHighlighted = app.messages[1] + assert cell_highlighted_event.sender is table + assert cell_highlighted_event.value == "456" + assert cell_highlighted_event.cell_key == CellKey(row_key, column_key) + assert cell_highlighted_event.coordinate == Coordinate(1, 0) + + cell_selected_event: DataTable.CellSelected = app.messages[2] + assert cell_selected_event.sender is table + assert cell_selected_event.value == "456" + assert cell_selected_event.cell_key == CellKey(row_key, column_key) + assert cell_selected_event.coordinate == Coordinate(1, 0) + + +async def test_datatable_on_click_row_cursor(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.cursor_type = "row" + click = click_in_app(app) table.add_column("ABC") table.add_row("123") + row_key = table.add_row("456") + table.on_click(event=click) + await wait_for_idle(0) + assert app.message_names == ["RowHighlighted", "RowHighlighted", "RowSelected"] + + row_highlighted: DataTable.RowHighlighted = app.messages[1] + assert row_highlighted.sender is table + assert row_highlighted.row_key == row_key + assert row_highlighted.cursor_row == 1 + + row_selected: DataTable.RowSelected = app.messages[2] + assert row_selected.sender is table + assert row_selected.row_key == row_key + assert row_highlighted.cursor_row == 1 + + +async def test_datatable_on_click_column_cursor(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.cursor_type = "column" + click = click_in_app(app) + column_key = table.add_column("ABC") + table.add_row("123") + table.add_row("123") table.on_click(event=click) await wait_for_idle(0) - assert app.message_names == ["CellHighlighted", "CellSelected"] + assert app.message_names == [ + "ColumnHighlighted", + "ColumnHighlighted", + "ColumnSelected", + ] + column_highlighted: DataTable.ColumnHighlighted = app.messages[1] + assert column_highlighted.sender is table + assert column_highlighted.column_key == column_key + assert column_highlighted.cursor_column == 0 + + column_selected: DataTable.ColumnSelected = app.messages[2] + assert column_selected.sender is table + assert column_selected.column_key == column_key + assert column_highlighted.cursor_column == 0 def test_key_equals_equivalent_string(): From 0afcc8cd47014596b92eb4a7742354cec7dbee5b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 11:09:51 +0000 Subject: [PATCH 092/155] Add comment to a test --- tests/test_data_table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 9e1c9ee9f..8a179b2aa 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -436,6 +436,7 @@ def click_in_app(sender: MessagePump): async def test_datatable_on_click_cell_cursor(): + # Regression test for https://github.com/Textualize/textual/issues/1723 app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) From 3cf010ebe739bcd1e970a35b38f35b6240e21745 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 11:23:02 +0000 Subject: [PATCH 093/155] Testing to ensure the hover coordinate is updated --- src/textual/widgets/_data_table.py | 11 +++---- tests/test_data_table.py | 48 +++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 2b84c8a03..e2634ec0f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1128,13 +1128,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ordered_row.append(cell) empty = Text() - if ordered_row is None: - return [empty for _ in self.columns] - else: - return [ - Text() if datum is None else default_cell_formatter(datum) or empty - for datum, _ in zip_longest(ordered_row, range(len(self.columns))) - ] + return [ + Text() if datum is None else default_cell_formatter(datum) or empty + for datum, _ in zip_longest(ordered_row, range(len(self.columns))) + ] def _render_cell( self, diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 8a179b2aa..abbbda058 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -1,11 +1,12 @@ from __future__ import annotations import pytest +from rich.style import Style from textual._wait import wait_for_idle from textual.app import App from textual.coordinate import Coordinate -from textual.events import Click +from textual.events import Click, MouseMove from textual.message import Message from textual.message_pump import MessagePump from textual.widgets import DataTable @@ -421,7 +422,7 @@ async def test_coordinate_to_cell_key_invalid_coordinate(): table.coordinate_to_cell_key(Coordinate(9999, 9999)) -def click_in_app(sender: MessagePump): +def make_click_event(sender: MessagePump): return Click( sender=sender, x=1, @@ -436,11 +437,13 @@ def click_in_app(sender: MessagePump): async def test_datatable_on_click_cell_cursor(): - # Regression test for https://github.com/Textualize/textual/issues/1723 + """When the cell cursor is used, and we click, we emit a CellHighlighted + *and* a CellSelected message for the cell that was clicked. + Regression test for https://github.com/Textualize/textual/issues/1723""" app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) - click = click_in_app(app) + click = make_click_event(app) column_key = table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") @@ -467,11 +470,13 @@ async def test_datatable_on_click_cell_cursor(): async def test_datatable_on_click_row_cursor(): + """When the row cursor is used, and we click, we emit a RowHighlighted + *and* a RowSelected message for the row that was clicked.""" app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.cursor_type = "row" - click = click_in_app(app) + click = make_click_event(app) table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") @@ -491,14 +496,16 @@ async def test_datatable_on_click_row_cursor(): async def test_datatable_on_click_column_cursor(): + """When the column cursor is used, and we click, we emit a ColumnHighlighted + *and* a ColumnSelected message for the column that was clicked.""" app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.cursor_type = "column" - click = click_in_app(app) column_key = table.add_column("ABC") table.add_row("123") - table.add_row("123") + table.add_row("456") + click = make_click_event(app) table.on_click(event=click) await wait_for_idle(0) assert app.message_names == [ @@ -517,6 +524,33 @@ async def test_datatable_on_click_column_cursor(): assert column_highlighted.cursor_column == 0 +async def test_datatable_hover_coordinate(): + """Ensure that the hover_coordinate reactive is updated as expected.""" + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("ABC") + table.add_row("123") + table.add_row("456") + assert table.hover_coordinate == Coordinate(0, 0) + + mouse_move = MouseMove( + sender=app, + x=1, + y=2, + delta_x=0, + delta_y=0, + button=0, + shift=False, + meta=False, + ctrl=False, + style=Style(meta={"row": 1, "column": 2}), + ) + table.on_mouse_move(mouse_move) + await wait_for_idle(0) + assert table.hover_coordinate == Coordinate(1, 2) + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From cc9e342b406c3b51682aa53125ee0dbed4b218b6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 13:38:21 +0000 Subject: [PATCH 094/155] Sorting --- .coveragerc | 1 + src/textual/widgets/_data_table.py | 34 +++++++++--------------------- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/.coveragerc b/.coveragerc index d16dd221a..087a1674f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,3 +7,4 @@ exclude_lines = if TYPE_CHECKING: if __name__ == "__main__": @overload + __rich_repr__ diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e2634ec0f..9d7f0dda4 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -13,6 +13,7 @@ from typing import ( NamedTuple, Callable, Sequence, + Any, ) import rich.repr @@ -1429,35 +1430,20 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def sort( self, - column: str | ColumnKey | Sequence[str] | Sequence[ColumnKey], + *columns: ColumnKey | str, reverse: bool = False, ) -> None: - if isinstance(column, (str, ColumnKey)): - column = (column,) - indices = [self._column_locations.get(key) for key in column] - ordered_keys = sorted(self.rows, key=itemgetter(*indices), reverse=reverse) - self._row_locations = TwoWayDict( - {key: new_index for new_index, key in enumerate(ordered_keys)} - ) - self._update_count += 1 - self.refresh() + def sort_by_column_keys( + row: tuple[RowKey, dict[ColumnKey | str, CellType]] + ) -> Any: + _, row_data = row + return itemgetter(*columns)(row_data) - def sort_columns( - self, key: Callable[[ColumnKey | str], str] = None, reverse: bool = False - ) -> None: - ordered_keys = sorted(self.columns.keys(), key=key, reverse=reverse) - self._column_locations = TwoWayDict( - {key: new_index for new_index, key in enumerate(ordered_keys)} + ordered_rows = sorted( + self.data.items(), key=sort_by_column_keys, reverse=reverse ) - self._update_count += 1 - self.refresh() - - def sort_rows( - self, key: Callable[[RowKey | str], str] = None, reverse: bool = False - ): - ordered_keys = sorted(self.rows.keys(), key=key, reverse=reverse) self._row_locations = TwoWayDict( - {key: new_index for new_index, key in enumerate(ordered_keys)} + {key: new_index for new_index, (key, _) in enumerate(ordered_rows)} ) self._update_count += 1 self.refresh() From 78ebdd88189765973157f2e3c3418800c90bd82b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 14:50:29 +0000 Subject: [PATCH 095/155] Import types from typing_extensions --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index a9e198420..e942e7c43 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -21,13 +21,13 @@ from rich.protocol import is_renderable from rich.segment import Segment from rich.style import Style from rich.text import Text, TextType +from typing_extensions import TypeAlias, Literal from .. import events, messages from .._cache import LRUCache from .._segment_tools import line_crop from .._two_way_dict import TwoWayDict from .._types import SegmentLines -from .._typing import Literal, TypeAlias from ..binding import Binding, BindingType from ..coordinate import Coordinate from ..geometry import Region, Size, Spacing, clamp From 802ea63ddb17914de7bea901a76c4e3dd5f82656 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 15:09:21 +0000 Subject: [PATCH 096/155] Default cell formatter fixes --- docs/examples/widgets/data_table.py | 26 +++++++++++++------------- src/textual/widgets/_data_table.py | 8 +++++--- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/examples/widgets/data_table.py b/docs/examples/widgets/data_table.py index 74d9b76e4..9a0550f34 100644 --- a/docs/examples/widgets/data_table.py +++ b/docs/examples/widgets/data_table.py @@ -1,18 +1,18 @@ -import csv -import io - from textual.app import App, ComposeResult from textual.widgets import DataTable -CSV = """lane,swimmer,country,time -4,Joseph Schooling,Singapore,50.39 -2,Michael Phelps,United States,51.14 -5,Chad le Clos,South Africa,51.14 -6,László Cseh,Hungary,51.14 -3,Li Zhuhao,China,51.26 -8,Mehdy Metella,France,51.58 -7,Tom Shields,United States,51.73 -1,Aleksandr Sadovnikov,Russia,51.84""" +ROWS = [ + ("lane", "swimmer", "country", "time"), + (4, "Joseph Schooling", "Singapore", 50.39), + (2, "Michael Phelps", "United States", 51.14), + (5, "Chad le Clos", "South Africa", 51.14), + (6, "László Cseh", "Hungary", 51.14), + (3, "Li Zhuhao", "China", 51.26), + (8, "Mehdy Metella", "France", 51.58), + (7, "Tom Shields", "United States", 51.73), + (1, "Aleksandr Sadovnikov", "Russia", 51.84), + (10, "Darren Burns", "Scotland", 51.84), +] class TableApp(App): @@ -21,7 +21,7 @@ class TableApp(App): def on_mount(self) -> None: table = self.query_one(DataTable) - rows = csv.reader(io.StringIO(CSV)) + rows = iter(ROWS) table.add_columns(*next(rows)) table.add_rows(rows) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e942e7c43..f0b4dea9b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -88,19 +88,21 @@ class CellKey(NamedTuple): column_key: ColumnKey | str -def default_cell_formatter(obj: object) -> RenderableType | None: +def default_cell_formatter(obj: object) -> RenderableType: """Format a cell in to a renderable. Args: obj: Data for a cell. Returns: - A renderable or None if the object could not be rendered. + A renderable to be displayed which represents the data. """ if isinstance(obj, str): return Text.from_markup(obj) + if isinstance(obj, float): + return f"{obj:.2f}" if not is_renderable(obj): - return None + return str(obj) return cast(RenderableType, obj) From ea884681d0b323cb1ea30018d917dc286ccbbc41 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 15:12:30 +0000 Subject: [PATCH 097/155] Default cell formatter DataTable docstring update --- src/textual/widgets/_data_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f0b4dea9b..34cce7a04 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -89,7 +89,8 @@ class CellKey(NamedTuple): def default_cell_formatter(obj: object) -> RenderableType: - """Format a cell in to a renderable. + """Given an object stored in a DataTable cell, return a Rich + renderable type which displays that object. Args: obj: Data for a cell. From 5f163352fa85f2a89aca7b008c6d9018ac92ed6d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 7 Feb 2023 16:40:52 +0000 Subject: [PATCH 098/155] Remove a comment --- src/textual/widgets/_data_table.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 34cce7a04..413365fb6 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1113,8 +1113,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: List of renderables """ - - # TODO: We have quite a few back and forward key/index conversions, could probably reduce them ordered_columns = self.ordered_columns if row_index == -1: row = [column.label for column in ordered_columns] @@ -1438,7 +1436,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row: tuple[RowKey, dict[ColumnKey | str, CellType]] ) -> Any: _, row_data = row - return itemgetter(*columns)(row_data) + result = itemgetter(*columns)(row_data) + return result ordered_rows = sorted( self.data.items(), key=sort_by_column_keys, reverse=reverse From a627d4b3fa984da117fd05c4b6515f214e3e06a6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 09:12:22 +0000 Subject: [PATCH 099/155] Docstring for DataTable sort method --- src/textual/widgets/_data_table.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 413365fb6..e7a02458a 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1432,6 +1432,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): *columns: ColumnKey | str, reverse: bool = False, ) -> None: + """Sort the rows in the DataTable by one or more column keys. + + Args: + columns: One or more columns to sort by the values in. + reverse: If True, the sort order will be reversed. + """ + def sort_by_column_keys( row: tuple[RowKey, dict[ColumnKey | str, CellType]] ) -> Any: From dca164d70fbbb65899063aa10b00d86e64145b95 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 09:54:33 +0000 Subject: [PATCH 100/155] Update snapshots for datatable_render test --- .../__snapshots__/test_snapshots.ambr | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 7bce93a23..262a3fa8a 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10333,133 +10333,133 @@ font-weight: 700; } - .terminal-2438906615-matrix { + .terminal-108526495-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2438906615-title { + .terminal-108526495-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2438906615-r1 { fill: #dde6ed;font-weight: bold } - .terminal-2438906615-r2 { fill: #e1e1e1 } - .terminal-2438906615-r3 { fill: #c5c8c6 } - .terminal-2438906615-r4 { fill: #211505 } + .terminal-108526495-r1 { fill: #dde6ed;font-weight: bold } + .terminal-108526495-r2 { fill: #e1e1e1 } + .terminal-108526495-r3 { fill: #c5c8c6 } + .terminal-108526495-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + From 0721d7fc8777437813de576e2a906c246b73c3ba Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 10:39:04 +0000 Subject: [PATCH 101/155] Snapshot test for sorting --- docs/examples/widgets/data_table.py | 2 +- .../__snapshots__/test_snapshots.ambr | 157 ++++++++++++++++++ .../snapshot_apps/data_table_sort.py | 44 +++++ tests/snapshot_tests/test_snapshots.py | 5 + 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 tests/snapshot_tests/snapshot_apps/data_table_sort.py diff --git a/docs/examples/widgets/data_table.py b/docs/examples/widgets/data_table.py index 9a0550f34..f409bb45c 100644 --- a/docs/examples/widgets/data_table.py +++ b/docs/examples/widgets/data_table.py @@ -26,6 +26,6 @@ class TableApp(App): table.add_rows(rows) +app = TableApp() if __name__ == "__main__": - app = TableApp() app.run() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 262a3fa8a..388723c00 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10624,6 +10624,163 @@ ''' # --- +# name: test_datatable_sort_multikey + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TableApp + + + + + + + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_demo ''' diff --git a/tests/snapshot_tests/snapshot_apps/data_table_sort.py b/tests/snapshot_tests/snapshot_apps/data_table_sort.py new file mode 100644 index 000000000..b4866e0ca --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/data_table_sort.py @@ -0,0 +1,44 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import DataTable + +# Shuffled around a bit to exercise sorting. +ROWS = [ + ("lane", "swimmer", "country", "time"), + (5, "Chad le Clos", "South Africa", 51.14), + (4, "Joseph Schooling", "Singapore", 50.39), + (2, "Michael Phelps", "United States", 51.14), + (6, "László Cseh", "Hungary", 51.14), + (3, "Li Zhuhao", "China", 51.26), + (8, "Mehdy Metella", "France", 51.58), + (7, "Tom Shields", "United States", 51.73), + (10, "Darren Burns", "Scotland", 51.84), + (1, "Aleksandr Sadovnikov", "Russia", 51.84), +] + + +class TableApp(App): + BINDINGS = [ + Binding("s", "sort", "Sort"), + ] + + def compose(self) -> ComposeResult: + yield DataTable() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.focus() + rows = iter(ROWS) + column_labels = next(rows) + for column in column_labels: + table.add_column(column, key=column) + table.add_rows(rows) + + def action_sort(self): + table = self.query_one(DataTable) + table.sort("time", "lane") + + +app = TableApp() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9ce48d171..ef7ab29b6 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -103,6 +103,11 @@ def test_datatable_column_cursor_render(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_column_cursor.py", press=press) +def test_datatable_sort_multikey(snap_compare): + press = ["down", "right", "s"] # Also checks that sort doesn't move cursor. + assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_sort.py", press=press) + + def test_footer_render(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py") From 0949211ab6a2aebf01ab31fdf32890052a5c9b46 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 12:44:02 +0000 Subject: [PATCH 102/155] Unit testing for sorting method --- tests/test_data_table.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index abbbda058..0abeb278e 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -469,7 +469,7 @@ async def test_datatable_on_click_cell_cursor(): assert cell_selected_event.coordinate == Coordinate(1, 0) -async def test_datatable_on_click_row_cursor(): +async def test_on_click_row_cursor(): """When the row cursor is used, and we click, we emit a RowHighlighted *and* a RowSelected message for the row that was clicked.""" app = DataTableApp() @@ -495,7 +495,7 @@ async def test_datatable_on_click_row_cursor(): assert row_highlighted.cursor_row == 1 -async def test_datatable_on_click_column_cursor(): +async def test_on_click_column_cursor(): """When the column cursor is used, and we click, we emit a ColumnHighlighted *and* a ColumnSelected message for the column that was clicked.""" app = DataTableApp() @@ -524,7 +524,7 @@ async def test_datatable_on_click_column_cursor(): assert column_highlighted.cursor_column == 0 -async def test_datatable_hover_coordinate(): +async def test_hover_coordinate(): """Ensure that the hover_coordinate reactive is updated as expected.""" app = DataTableApp() async with app.run_test(): @@ -551,6 +551,35 @@ async def test_datatable_hover_coordinate(): assert table.hover_coordinate == Coordinate(1, 2) +async def test_sort_coordinate_and_key_access(): + """Ensure that, after sorting, that coordinates and cell keys + can still be used to retrieve the correct cell.""" + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column = table.add_column("number") + row_three = table.add_row(3) + row_one = table.add_row(1) + row_two = table.add_row(2) + + # Items inserted in correct initial positions (before sort) + assert table.get_value_at(Coordinate(0, 0)) == 3 + assert table.get_value_at(Coordinate(1, 0)) == 1 + assert table.get_value_at(Coordinate(2, 0)) == 2 + + table.sort(column) + + # The keys still refer to the same cells... + assert table.get_cell_value(row_one, column) == 1 + assert table.get_cell_value(row_two, column) == 2 + assert table.get_cell_value(row_three, column) == 3 + + # ...even though the values under the coordinates have changed... + assert table.get_value_at(Coordinate(0, 0)) == 1 + assert table.get_value_at(Coordinate(1, 0)) == 2 + assert table.get_value_at(Coordinate(2, 0)) == 3 + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 10c3deb9d238a794a4d724d76d5a23d1cc382712 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 12:49:59 +0000 Subject: [PATCH 103/155] Testing reverse sort --- tests/test_data_table.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 0abeb278e..3b8ff25d0 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -580,6 +580,35 @@ async def test_sort_coordinate_and_key_access(): assert table.get_value_at(Coordinate(2, 0)) == 3 +async def test_sort_reverse_coordinate_and_key_access(): + """Ensure that, after sorting, that coordinates and cell keys + can still be used to retrieve the correct cell.""" + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column = table.add_column("number") + row_three = table.add_row(3) + row_one = table.add_row(1) + row_two = table.add_row(2) + + # Items inserted in correct initial positions (before sort) + assert table.get_value_at(Coordinate(0, 0)) == 3 + assert table.get_value_at(Coordinate(1, 0)) == 1 + assert table.get_value_at(Coordinate(2, 0)) == 2 + + table.sort(column, reverse=True) + + # The keys still refer to the same cells... + assert table.get_cell_value(row_one, column) == 1 + assert table.get_cell_value(row_two, column) == 2 + assert table.get_cell_value(row_three, column) == 3 + + # ...even though the values under the coordinates have changed... + assert table.get_value_at(Coordinate(0, 0)) == 3 + assert table.get_value_at(Coordinate(1, 0)) == 2 + assert table.get_value_at(Coordinate(2, 0)) == 1 + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 54a29dd664c9ee0b35726b897cc7a8df68959ae7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 13:20:53 +0000 Subject: [PATCH 104/155] Fix attribute error with emit being remove, check ordered_rows is correct after sort --- src/textual/widgets/_data_table.py | 2 +- tests/test_data_table.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d9ad86181..f82691ce2 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1499,7 +1499,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): meta = self.get_style_at(event.x, event.y).meta if meta: self.cursor_coordinate = Coordinate(meta["row"], meta["column"]) - self._emit_selected_message() + self._post_selected_message() self._scroll_cursor_into_view(animate=True) event.stop() diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 3b8ff25d0..1c5fd35e5 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -579,6 +579,10 @@ async def test_sort_coordinate_and_key_access(): assert table.get_value_at(Coordinate(1, 0)) == 2 assert table.get_value_at(Coordinate(2, 0)) == 3 + assert table.ordered_rows[0].key == row_one + assert table.ordered_rows[1].key == row_two + assert table.ordered_rows[2].key == row_three + async def test_sort_reverse_coordinate_and_key_access(): """Ensure that, after sorting, that coordinates and cell keys @@ -608,6 +612,10 @@ async def test_sort_reverse_coordinate_and_key_access(): assert table.get_value_at(Coordinate(1, 0)) == 2 assert table.get_value_at(Coordinate(2, 0)) == 1 + assert table.ordered_rows[0].key == row_three + assert table.ordered_rows[1].key == row_two + assert table.ordered_rows[2].key == row_one + def test_key_equals_equivalent_string(): text = "Hello" From 64840daa0eded8067a9cdbd630091a02dbb1468c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 13:36:42 +0000 Subject: [PATCH 105/155] PEP8 in tests for data table --- tests/test_data_table.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 1c5fd35e5..19580913f 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -378,11 +378,11 @@ async def test_update_coordinate_coordinate_doesnt_exist(): [ # Shorter than initial cell value, larger than label => width remains same ("A", "BB", 3), - # Larger than initial cell value, shorter than label => width remains that of label + # Larger than cell value, shorter than label => width remains that of label ("1234567", "1234", 7), - # Shorter than initial cell value, shorter than label => width remains same + # Shorter than cell value, shorter than label => width remains same ("12345", "123", 5), - # Larger than initial cell value, larger than label => width updates to new cell value + # Larger than cell value, larger than label => width updates to new cell value ("12345", "123456789", 9), ], ) From 2fe73c0c28f4cc5d5db75afd2b665afa935edaf3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 14:01:42 +0000 Subject: [PATCH 106/155] Testing highlighted events via keyboard cursor movement --- src/textual/widgets/_data_table.py | 5 ++++- tests/test_data_table.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index f82691ce2..62bcef1e5 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -67,7 +67,10 @@ class StringKey: def __eq__(self, other: object) -> bool: # Strings will match Keys containing the same string value. # Otherwise, you'll need to supply the exact same key object. - return hash(self) == hash(other) + try: + return hash(self) == hash(other) + except TypeError: + return False def __lt__(self, other): if isinstance(other, str): diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 19580913f..dd0109447 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -617,6 +617,38 @@ async def test_sort_reverse_coordinate_and_key_access(): assert table.ordered_rows[2].key == row_one +async def test_cell_cursor_highlight_events(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column_one_key, column_two_key = table.add_columns("A", "B") + _ = table.add_row(0, 1) + row_two_key = table.add_row(2, 3) + + # Since initial position is (0, 0), cursor doesn't move so no event posted + table.action_cursor_up() + table.action_cursor_left() + + await wait_for_idle(0) + assert table.app.message_names == ["CellHighlighted"] + + table.action_cursor_down() + await wait_for_idle(0) + assert len(table.app.messages) == 2 + latest_message: DataTable.CellHighlighted = table.app.messages[-1] + assert latest_message.value == 2 + assert latest_message.coordinate == Coordinate(1, 0) + + assert latest_message.cell_key == CellKey(row_two_key, column_one_key) + + table.action_cursor_right() + await wait_for_idle(0) + assert len(table.app.messages) == 3 + latest_message = table.app.messages[-1] + assert latest_message.coordinate == Coordinate(1, 1) + assert latest_message.cell_key == CellKey(row_two_key, column_two_key) + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 6ffeb5cd8a63730c528d5fe159c0d5fdf47e0d56 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 14:13:37 +0000 Subject: [PATCH 107/155] Unit test to ensure event emission from row cursor correct in DataTable --- tests/test_data_table.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index dd0109447..0705bbe43 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -4,6 +4,7 @@ import pytest from rich.style import Style from textual._wait import wait_for_idle +from textual.actions import SkipAction from textual.app import App from textual.coordinate import Coordinate from textual.events import Click, MouseMove @@ -649,6 +650,41 @@ async def test_cell_cursor_highlight_events(): assert latest_message.cell_key == CellKey(row_two_key, column_two_key) +async def test_row_cursor_highlight_events(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.cursor_type = "row" + column_one_key, column_two_key = table.add_columns("A", "B") + row_one_key = table.add_row(0, 1) + row_two_key = table.add_row(2, 3) + + # Since initial position is row_index=0, the following actions do nothing. + with pytest.raises(SkipAction): + table.action_cursor_up() + table.action_cursor_left() + table.action_cursor_right() + + await wait_for_idle(0) + assert table.app.message_names == ["RowHighlighted"] # Initial highlight + + # Move the row cursor from row 0 to row 1, check the highlighted event posted + table.action_cursor_down() + await wait_for_idle(0) + assert len(table.app.messages) == 2 + latest_message: DataTable.RowHighlighted = table.app.messages[-1] + assert latest_message.row_key == row_two_key + assert latest_message.cursor_row == 1 + + # Move the row cursor back up to row 0, check the highlighted event posted + table.action_cursor_up() + await wait_for_idle(0) + assert len(table.app.messages) == 3 + latest_message = table.app.messages[-1] + assert latest_message.row_key == row_one_key + assert latest_message.cursor_row == 0 + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From e4d2cde9cd981d2be1081f6a4adf32d199aa3d98 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 14:20:28 +0000 Subject: [PATCH 108/155] Unit test to ensure column cursor events posted correctly --- tests/test_data_table.py | 49 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 0705bbe43..74c0e7140 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -631,17 +631,21 @@ async def test_cell_cursor_highlight_events(): table.action_cursor_left() await wait_for_idle(0) - assert table.app.message_names == ["CellHighlighted"] + assert table.app.message_names == [ + "CellHighlighted" + ] # Initial highlight on load + # Move the cursor one cell down, and check the highlighted event posted table.action_cursor_down() await wait_for_idle(0) assert len(table.app.messages) == 2 latest_message: DataTable.CellHighlighted = table.app.messages[-1] + assert isinstance(latest_message, DataTable.CellHighlighted) assert latest_message.value == 2 assert latest_message.coordinate == Coordinate(1, 0) - assert latest_message.cell_key == CellKey(row_two_key, column_one_key) + # Now move the cursor to the right, and check highlighted event posted table.action_cursor_right() await wait_for_idle(0) assert len(table.app.messages) == 3 @@ -655,7 +659,7 @@ async def test_row_cursor_highlight_events(): async with app.run_test(): table = app.query_one(DataTable) table.cursor_type = "row" - column_one_key, column_two_key = table.add_columns("A", "B") + table.add_columns("A", "B") row_one_key = table.add_row(0, 1) row_two_key = table.add_row(2, 3) @@ -673,6 +677,7 @@ async def test_row_cursor_highlight_events(): await wait_for_idle(0) assert len(table.app.messages) == 2 latest_message: DataTable.RowHighlighted = table.app.messages[-1] + assert isinstance(latest_message, DataTable.RowHighlighted) assert latest_message.row_key == row_two_key assert latest_message.cursor_row == 1 @@ -685,6 +690,44 @@ async def test_row_cursor_highlight_events(): assert latest_message.cursor_row == 0 +async def test_column_cursor_highlight_events(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.cursor_type = "column" + column_one_key, column_two_key = table.add_columns("A", "B") + table.add_row(0, 1) + table.add_row(2, 3) + + # Since initial position is column_index=0, the following actions do nothing. + with pytest.raises(SkipAction): + table.action_cursor_left() + table.action_cursor_up() + table.action_cursor_down() + + await wait_for_idle(0) + assert table.app.message_names == ["ColumnHighlighted"] # Initial highlight + + # Move the column cursor from column 0 to column 1, + # check the highlighted event posted + table.action_cursor_right() + await wait_for_idle(0) + assert len(table.app.messages) == 2 + latest_message: DataTable.ColumnHighlighted = table.app.messages[-1] + assert isinstance(latest_message, DataTable.ColumnHighlighted) + assert latest_message.column_key == column_two_key + assert latest_message.cursor_column == 1 + + # Move the column cursor left, back to column 0, + # check the highlighted event posted again. + table.action_cursor_left() + await wait_for_idle(0) + assert len(table.app.messages) == 3 + latest_message = table.app.messages[-1] + assert latest_message.column_key == column_one_key + assert latest_message.cursor_column == 0 + + def test_key_equals_equivalent_string(): text = "Hello" key = RowKey(text) From 0adfda83862449d731ee8d28b834605524fec0de Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 15:36:27 +0000 Subject: [PATCH 109/155] Ensure we convert str to keys for mypy in DataTable update_cell --- src/textual/widgets/_data_table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 39bcbcc54..faf93b482 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -532,6 +532,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): CellDoesNotExist: When the supplied `row_key` and `column_key` cannot be found in the table. """ + if isinstance(row_key, str): + row_key = RowKey(row_key) + if isinstance(column_key, str): + column_key = ColumnKey(column_key) + try: self.data[row_key][column_key] = value except KeyError: From 67415bfc1917139d9dc464ec4d4d06387f155af4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 15:39:03 +0000 Subject: [PATCH 110/155] Satisfy mypy by using indexing rather than get method in dict --- src/textual/widgets/_data_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index faf93b482..a02a4e7b6 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -574,8 +574,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): order they currently appear on screen.""" for row_metadata in self.ordered_rows: row_key = row_metadata.key - row = self.data.get(row_key) - yield row.get(column_key) + row = self.data[row_key] + yield row[column_key] def get_value_at(self, coordinate: Coordinate) -> CellType: """Get the value from the cell occupying the given coordinate. From 08fa1b52aa3c145a6a4643f8f4fb7291b7a84a92 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 15:43:32 +0000 Subject: [PATCH 111/155] Include possible exception in docstring --- src/textual/widgets/_data_table.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index a02a4e7b6..704948820 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -700,6 +700,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Returns: The key of the cell currently occupying this coordinate. + + Raises: + CellDoesNotExist: If the coordinate is not valid. """ if not self.is_valid_coordinate(coordinate): raise CellDoesNotExist(f"No cell exists at {coordinate!r}.") From 08233843c3e7701e9ebc8f03762f73d4989bca1f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 15:54:07 +0000 Subject: [PATCH 112/155] Various PEP8 fixes --- src/textual/widgets/_data_table.py | 106 +++++++++++++++-------------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 704948820..6b4502e86 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -248,12 +248,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): hover_coordinate: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False) class CellHighlighted(Message, bubble=True): - """Posted when the cursor moves to highlight a new cell. - It's only relevant when the `cursor_type` is `"cell"`. - It's also posted when the cell cursor is re-enabled (by setting `show_cursor=True`), - and when the cursor type is changed to `"cell"`. Can be handled using - `on_data_table_cell_highlighted` in a subclass of `DataTable` or in a parent - widget in the DOM. + """Posted when the cursor moves to highlight a new cell. It's only relevant + when the `cursor_type` is `"cell"`. It's also posted when the cell cursor is + re-enabled (by setting `show_cursor=True`), and when the cursor type is + changed to `"cell"`. Can be handled using `on_data_table_cell_highlighted` in + a subclass of `DataTable` or in a parent widget in the DOM. Attributes: value: The value in the highlighted cell. @@ -311,8 +310,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): class RowHighlighted(Message, bubble=True): """Posted when a row is highlighted. This message is only posted when the - `cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_highlighted` - in a subclass of `DataTable` or in a parent widget in the DOM. + `cursor_type` is set to `"row"`. Can be handled using + `on_data_table_row_highlighted` in a subclass of `DataTable` or in a parent + widget in the DOM. Attributes: cursor_row: The y-coordinate of the cursor that highlighted the row. @@ -422,9 +422,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Metadata about the rows of the table, indexed by their key.""" # Keep tracking of key -> index for rows/cols. These allow us to retrieve, - # given a row or column key, the index that row or column is currently present at, - # and mean that rows and columns are location independent - they can move around - # without requiring us to modify the underlying data. + # given a row or column key, the index that row or column is currently + # present at, and mean that rows and columns are location independent - they + # can move around without requiring us to modify the underlying data. self._row_locations: TwoWayDict[RowKey, int] = TwoWayDict({}) """Maps row keys to row indices which represent row order.""" self._column_locations: TwoWayDict[ColumnKey, int] = TwoWayDict({}) @@ -433,9 +433,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._row_render_cache: LRUCache[ RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) - """For each row (a row can have a height of multiple lines), we maintain a cache - of the fixed and scrollable lines within that row to minimise how often we need to - re-render it.""" + """For each row (a row can have a height of multiple lines), we maintain a + cache of the fixed and scrollable lines within that row to minimise how often + we need to re-render it. """ self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) """Cache for individual cells.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) @@ -444,7 +444,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions: bool = False """Set to re-calculate dimensions on idle.""" self._new_rows: set[RowKey] = set() - """Tracking newly added rows to be used in re-calculation of dimensions on idle.""" + """Tracking newly added rows to be used in calculation of dimensions on idle.""" self._updated_cells: set[CellKey] = set() """Track which cells were updated, so that we can refresh them once on idle.""" @@ -457,15 +457,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.fixed_columns = fixed_columns """The number of columns to fix (prevented from scrolling).""" self.zebra_stripes = zebra_stripes - """Apply zebra-stripe effect on row backgrounds (light, dark, light, dark, ...).""" + """Apply zebra effect on row backgrounds (light, dark, light, dark, ...).""" self.show_cursor = show_cursor """Show/hide both the keyboard and hover cursor.""" self._show_hover_cursor = False """Used to hide the mouse hover cursor when the user uses the keyboard.""" self._update_count = 0 - """The number of update operations that have occurred. Used for cache invalidation.""" + """Number of update operations so far. Used for cache invalidation.""" self._header_row_key = RowKey() - """The header is a special row which is not part of the data. This key is used to retrieve it.""" + """The header is a special row - not part of the data. Retrieve via this key.""" @property def hover_row(self) -> int: @@ -494,10 +494,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def _y_offsets(self) -> list[tuple[RowKey, int]]: - """Contains a 2-tuple for each line (not row!) of the DataTable. Given a y-coordinate, - we can index into this list to find which row that y-coordinate lands on, and the - y-offset *within* that row. The length of the returned list is therefore the total - height of all rows within the DataTable.""" + """Contains a 2-tuple for each line (not row!) of the DataTable. Given a + y-coordinate, we can index into this list to find which row that y-coordinate + lands on, and the y-offset *within* that row. The length of the returned list + is therefore the total height of all rows within the DataTable.""" y_offsets: list[tuple[RowKey, int]] = [] for row in self.ordered_rows: row_key = row.key @@ -629,7 +629,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self.header_height return self.rows[row_key].height - async def on_styles_updated(self, message: messages.StylesUpdated) -> None: + async def on_styles_updated(self, _: messages.StylesUpdated) -> None: self._clear_caches() self.refresh() @@ -646,13 +646,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif self.cursor_type == "column": self._highlight_column(self.cursor_column) - def watch_show_header(self, show_header: bool) -> None: + def watch_show_header(self) -> None: self._clear_caches() - def watch_fixed_rows(self, fixed_rows: int) -> None: + def watch_fixed_rows(self) -> None: self._clear_caches() - def watch_zebra_stripes(self, zebra_stripes: bool) -> None: + def watch_zebra_stripes(self) -> None: self._clear_caches() def watch_hover_coordinate(self, old: Coordinate, value: Coordinate) -> None: @@ -693,7 +693,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) def coordinate_to_cell_key(self, coordinate: Coordinate) -> CellKey: - """Return the key for the cell currently occupying this coordinate in the DataTable + """Return the key for the cell currently occupying this coordinate in the + DataTable Args: coordinate: The coordinate to exam the current cell key of. @@ -740,7 +741,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column = clamp(column, self.fixed_columns, len(self.columns) - 1) return Coordinate(row, column) - def watch_cursor_type(self, old: str, new: str) -> None: + def watch_cursor_type(self, old: str, _: str) -> None: self._set_hover_cursor(False) if self.show_cursor: self._highlight_cursor() @@ -882,13 +883,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: label: A str or Text object containing the label (shown top of column). - width: Width of the column in cells or None to fit content. Defaults to None. - key: A key which uniquely identifies this column. If None, it will be generated for you. Defaults to None. + width: Width of the column in cells or None to fit content. + key: A key which uniquely identifies this column. + If None, it will be generated for you. Returns: - Uniquely identifies this column. Can be used to retrieve this column regardless - of its current location in the DataTable (it could have moved after being added - due to sorting or insertion/deletion of other columns). + Uniquely identifies this column. Can be used to retrieve this column + regardless of its current location in the DataTable (it could have moved + after being added due to sorting/insertion/deletion of other columns). """ column_key = ColumnKey(key) column_index = len(self.columns) @@ -923,13 +925,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Args: *cells: Positional arguments should contain cell data. - height: The height of a row (in lines). Defaults to 1. - key: A key which uniquely identifies this row. If None, it will be generated for you. Defaults to None. + height: The height of a row (in lines). + key: A key which uniquely identifies this row. If None, it will be generated + for you and returned. Returns: Uniquely identifies this row. Can be used to retrieve this row regardless - of its current location in the DataTable (it could have moved after being added - due to sorting or insertion/deletion of other rows). + of its current location in the DataTable (it could have moved after + being added due to sorting or insertion/deletion of other rows). """ row_index = self.row_count row_key = RowKey(key) @@ -1083,7 +1086,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return 0 <= column_index < len(self.columns) def is_valid_coordinate(self, coordinate: Coordinate) -> bool: - """Return a boolean indicating whether the given coordinate is within table bounds. + """Return a boolean indicating whether the given coordinate is within table + bounds. Args: coordinate: The coordinate to validate. @@ -1098,7 +1102,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def ordered_columns(self) -> list[Column]: - """The list of Columns in the DataTable, ordered as they currently appear on screen.""" + """The list of Columns in the DataTable, ordered as they appear on screen.""" column_indices = range(len(self.columns)) column_keys = [ self._column_locations.get_key(index) for index in column_indices @@ -1108,7 +1112,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def ordered_rows(self) -> list[Row]: - """The list of Rows in the DataTable, ordered as they currently appear on screen.""" + """The list of Rows in the DataTable, ordered as they appear on screen.""" row_indices = range(self.row_count) ordered_rows = [] for row_index in row_indices: @@ -1245,22 +1249,22 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self._row_render_cache[cache_key] def _should_highlight( - cursor_location: Coordinate, - cell_location: Coordinate, - cursor_type: CursorType, + cursor: Coordinate, + target_cell: Coordinate, + type_of_cursor: CursorType, ) -> bool: """Determine whether we should highlight a cell given the location of the cursor, the location of the cell, and the type of cursor that is currently active.""" - if cursor_type == "cell": - return cursor_location == cell_location - elif cursor_type == "row": - cursor_row, _ = cursor_location - cell_row, _ = cell_location + if type_of_cursor == "cell": + return cursor == target_cell + elif type_of_cursor == "row": + cursor_row, _ = cursor + cell_row, _ = target_cell return cursor_row == cell_row - elif cursor_type == "column": - _, cursor_column = cursor_location - _, cell_column = cell_location + elif type_of_cursor == "column": + _, cursor_column = cursor + _, cell_column = target_cell return cursor_column == cell_column else: return False From a6f382660c2413c814697b02b7ecb26f5c8366d8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 8 Feb 2023 16:07:17 +0000 Subject: [PATCH 113/155] Fix imports in data table tests --- tests/test_data_table.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 74c0e7140..5ae669407 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -11,12 +11,12 @@ from textual.events import Click, MouseMove from textual.message import Message from textual.message_pump import MessagePump from textual.widgets import DataTable -from textual.widgets._data_table import CellKey from textual.widgets.data_table import ( - CellDoesNotExist, - RowKey, - Row, ColumnKey, + CellDoesNotExist, + CellKey, + Row, + RowKey, ) ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] From 85f2250f2d2d3f5ddf2f92ddb95153b9efa110ad Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 09:48:22 +0000 Subject: [PATCH 114/155] Ensure we measure cell values *after* passing through cell formatter --- src/textual/_two_way_dict.py | 3 +++ src/textual/widgets/_data_table.py | 22 ++++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/textual/_two_way_dict.py b/src/textual/_two_way_dict.py index 1c5839478..c13a0da9e 100644 --- a/src/textual/_two_way_dict.py +++ b/src/textual/_two_way_dict.py @@ -53,3 +53,6 @@ class TwoWayDict(Generic[Key, Value]): def __len__(self): return len(self._forward) + + def __contains__(self, item: Key) -> bool: + return item in self._forward diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 6b4502e86..3aeb8ccbf 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -87,8 +87,8 @@ class ColumnKey(StringKey): class CellKey(NamedTuple): - row_key: RowKey | str - column_key: ColumnKey | str + row_key: RowKey + column_key: ColumnKey def default_cell_formatter(obj: object) -> RenderableType: @@ -741,7 +741,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column = clamp(column, self.fixed_columns, len(self.columns) - 1) return Coordinate(row, column) - def watch_cursor_type(self, old: str, _: str) -> None: + def watch_cursor_type(self, old: str, new: str) -> None: self._set_hover_cursor(False) if self.show_cursor: self._highlight_cursor() @@ -776,16 +776,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _update_column_widths(self, updated_cells: set[CellKey]) -> None: for row_key, column_key in updated_cells: column = self.columns.get(column_key) + if column is None: + continue console = self.app.console label_width = measure(console, column.label, 1) content_width = column.content_width cell_value = self.data[row_key][column_key] - new_content_width = measure(console, cell_value, 1) + new_content_width = measure(console, default_cell_formatter(cell_value), 1) if new_content_width < content_width: cells_in_column = self._get_cells_in_column(column_key) - cell_widths = [measure(console, cell, 1) for cell in cells_in_column] + cell_widths = [ + measure(console, default_cell_formatter(cell), 1) + for cell in cells_in_column + ] column.content_width = max([*cell_widths, label_width]) else: column.content_width = max(new_content_width, label_width) @@ -894,6 +899,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ column_key = ColumnKey(key) column_index = len(self.columns) + label = Text.from_markup(label) if isinstance(label, str) else label content_width = measure(self.app.console, label, 1) if width is None: column = Column( @@ -1193,7 +1199,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if is_fixed_style: style += self.get_component_styles("datatable--cursor-fixed").rich_style - row_key = self._row_locations.get_key(row_index) + if is_header_row: + row_key = self._header_row_key + else: + row_key = self._row_locations.get_key(row_index) + column_key = self._column_locations.get_key(column_index) cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) if cell_cache_key not in self._cell_render_cache: From c76bd5df2cb817ff11635ddd723fcfdc4fde80e5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 09:58:24 +0000 Subject: [PATCH 115/155] Fix some typing issues --- src/textual/_two_way_dict.py | 21 +++++++++++++++------ src/textual/widgets/_data_table.py | 10 +++++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/textual/_two_way_dict.py b/src/textual/_two_way_dict.py index c13a0da9e..b75543977 100644 --- a/src/textual/_two_way_dict.py +++ b/src/textual/_two_way_dict.py @@ -27,29 +27,38 @@ class TwoWayDict(Generic[Key, Value]): self._forward.__delitem__(key) self._reverse.__delitem__(value) - def get(self, key: Key, default: Value | None = None) -> Value: + def get(self, key: Key) -> Value: """Given a key, efficiently lookup and return the associated value. Args: key: The key - default: The default return value if not found. Defaults to None. Returns: The value """ - return self._forward.get(key, default) + return self._forward.get(key) - def get_key(self, value: Value, default: Key | None = None) -> Key: + def get_key(self, value: Value) -> Key: """Given a value, efficiently lookup and return the associated key. Args: value: The value - default: The default return value if not found. Defaults to None. Returns: The key """ - return self._reverse.get(value, default) + return self._reverse.get(value) + + def contains_value(self, value: Value) -> bool: + """Check if `value` is a value within this TwoWayDict. + + Args: + value: The value to check. + + Returns: + True if the value is within the values of this dict. + """ + return value in self._reverse def __len__(self): return len(self._forward) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 3aeb8ccbf..775f8d3fd 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1113,7 +1113,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_keys = [ self._column_locations.get_key(index) for index in column_indices ] - ordered_columns = [self.columns.get(key) for key in column_keys] + ordered_columns = [self.columns[key] for key in column_keys] return ordered_columns @property @@ -1123,7 +1123,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ordered_rows = [] for row_index in row_indices: row_key = self._row_locations.get_key(row_index) - row = self.rows.get(row_key) + row = self.rows[row_key] ordered_rows.append(row) return ordered_rows @@ -1279,7 +1279,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): else: return False - row_index = self._row_locations.get(row_key, -1) + if row_key in self._row_locations: + row_index = self._row_locations.get(row_key) + else: + row_index = -1 + render_cell = self._render_cell if self.fixed_columns: fixed_style = self.get_component_styles("datatable--fixed").rich_style From 7ebc95fb542c5dcd5c07e926ef4b17cf0ba30371 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 11:16:14 +0000 Subject: [PATCH 116/155] Updating tests for DataTable --- src/textual/widgets/_data_table.py | 5 +-- tests/test_data_table.py | 2 +- tests/test_two_way_dict.py | 51 ++++++++++++------------------ 3 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 775f8d3fd..c6424d92a 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -12,6 +12,7 @@ from typing import ( cast, NamedTuple, Any, + Sequence, ) import rich.repr @@ -1138,12 +1139,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ ordered_columns = self.ordered_columns if row_index == -1: - row = [column.label for column in ordered_columns] + row: list[RenderableType] = [column.label for column in ordered_columns] return row # Ensure we order the cells in the row based on current column ordering row_key = self._row_locations.get_key(row_index) - cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key) + cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key, {}) ordered_row: list[CellType] = [] for column in ordered_columns: diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 5ae669407..53c4aa779 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -261,7 +261,7 @@ async def test_column_labels() -> None: async with app.run_test(): table = app.query_one(DataTable) table.add_columns("1", "2", "3") - actual_labels = [col.label for col in table.columns.values()] + actual_labels = [col.label.plain for col in table.columns.values()] expected_labels = ["1", "2", "3"] assert actual_labels == expected_labels diff --git a/tests/test_two_way_dict.py b/tests/test_two_way_dict.py index 26e1cb58e..9178f6fdb 100644 --- a/tests/test_two_way_dict.py +++ b/tests/test_two_way_dict.py @@ -4,7 +4,7 @@ from textual._two_way_dict import TwoWayDict @pytest.fixture -def map(): +def two_way_dict(): return TwoWayDict( { 1: 10, @@ -14,43 +14,32 @@ def map(): ) -def test_get(map): - assert map.get(1) == 10 +def test_get(two_way_dict): + assert two_way_dict.get(1) == 10 -def test_get_default_none(map): - assert map.get(9999) is None +def test_get_key(two_way_dict): + assert two_way_dict.get_key(30) == 3 -def test_get_default_supplied(map): - assert map.get(9999, -123) == -123 +def test_set_item(two_way_dict): + two_way_dict[40] = 400 + assert two_way_dict.get(40) == 400 + assert two_way_dict.get_key(400) == 40 -def test_get_key(map): - assert map.get_key(30) == 3 +def test_len(two_way_dict): + assert len(two_way_dict) == 3 -def test_get_key_default_none(map): - assert map.get_key(9999) is None +def test_delitem(two_way_dict): + assert two_way_dict.get(3) == 30 + assert two_way_dict.get_key(30) == 3 + del two_way_dict[3] + assert two_way_dict.get(3) is None + assert two_way_dict.get_key(30) is None -def test_get_key_default_supplied(map): - assert map.get_key(9999, -123) == -123 - - -def test_set_item(map): - map[40] = 400 - assert map.get(40) == 400 - assert map.get_key(400) == 40 - - -def test_len(map): - assert len(map) == 3 - - -def test_delitem(map): - assert map.get(3) == 30 - assert map.get_key(30) == 3 - del map[3] - assert map.get(3) is None - assert map.get_key(30) is None +def test_contains(two_way_dict): + assert 1 in two_way_dict + assert 10 not in two_way_dict From 408ca2822d633cf3647424231a7c312b25e934ce Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 11:22:34 +0000 Subject: [PATCH 117/155] Make filter module public --- src/textual/_styles_cache.py | 2 +- src/textual/app.py | 2 +- src/textual/{_filter.py => filter.py} | 0 src/textual/strip.py | 2 +- tests/test_strip.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/textual/{_filter.py => filter.py} (100%) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 77cbfb4de..c760fcfe3 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -8,7 +8,7 @@ from rich.segment import Segment from rich.style import Style from ._border import get_box, render_row -from ._filter import LineFilter +from .filter import LineFilter from ._opacity import _apply_opacity from ._segment_tools import line_pad, line_trim from .color import Color diff --git a/src/textual/app.py b/src/textual/app.py index d6a0d766d..9ada6222f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -48,7 +48,7 @@ from ._asyncio import create_task from ._callback import invoke from ._context import active_app from ._event_broker import NoHandler, extract_handler_actions -from ._filter import LineFilter, Monochrome +from .filter import LineFilter, Monochrome from ._path import _make_path_object_relative from ._wait import wait_for_idle diff --git a/src/textual/_filter.py b/src/textual/filter.py similarity index 100% rename from src/textual/_filter.py rename to src/textual/filter.py diff --git a/src/textual/strip.py b/src/textual/strip.py index c10a649a9..53d8a8e9f 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -9,7 +9,7 @@ from rich.segment import Segment from rich.style import Style, StyleType from ._cache import FIFOCache -from ._filter import LineFilter +from .filter import LineFilter from ._segment_tools import index_to_cell_position diff --git a/tests/test_strip.py b/tests/test_strip.py index 40f3975fe..63da2dbfc 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -4,7 +4,7 @@ from rich.style import Style from textual._segment_tools import NoCellPositionForIndex from textual.strip import Strip -from textual._filter import Monochrome +from textual.filter import Monochrome def test_cell_length() -> None: From ccc2073b18d8fe2285ae5b99a903256fad729f78 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 11:35:37 +0000 Subject: [PATCH 118/155] Add docstrings, remove more unused attributes in the DataTable --- src/textual/widgets/_data_table.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c6424d92a..fae3e8111 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -50,11 +50,18 @@ CellType = TypeVar("CellType") class CellDoesNotExist(Exception): + """Raised when the user supplies coordinates or cell keys which + do not exist in the DataTable.""" + pass @functools.total_ordering class StringKey: + """An object used as a key in a mapping. It can optionally wrap a string, + and lookups into a map using the object behave the same as lookups using + the string itself.""" + value: str | None def __init__(self, value: str | None = None): @@ -80,14 +87,26 @@ class StringKey: class RowKey(StringKey): + """Uniquely identifies a row in the DataTable. Even if the visual location + of the row changes due to sorting or other modifications, a key will always + refer to the same row.""" + pass class ColumnKey(StringKey): + """Uniquely identifies a column in the DataTable. Even if the visual location + of the column changes due to sorting or other modifications, a key will always + refer to the same column.""" + pass class CellKey(NamedTuple): + """A unique identifier for a cell in the DataTable. Even if the cell changes + visual location (i.e. moves to a different coordinate in the table), this key + can still be used to retrieve it, regardless of where it currently is.""" + row_key: RowKey column_key: ColumnKey @@ -113,13 +132,11 @@ def default_cell_formatter(obj: object) -> RenderableType: @dataclass class Column: - """Table column.""" + """Metadata for a column in the DataTable.""" key: ColumnKey label: Text width: int = 0 - visible: bool = False - content_width: int = 0 auto_width: bool = False @@ -135,11 +152,10 @@ class Column: @dataclass class Row: - """Table row.""" + """Metadata for a row in the DataTable.""" key: RowKey height: int - cell_renderables: list[RenderableType] = field(default_factory=list) class DataTable(ScrollView, Generic[CellType], can_focus=True): From 59fc561e9c4d8b5a026cdff5fd62e758d04a0ddf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 13:16:44 +0000 Subject: [PATCH 119/155] Optimising DataTable import --- src/textual/widgets/_data_table.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index fae3e8111..fc78d31fc 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1,19 +1,10 @@ from __future__ import annotations import functools -from dataclasses import dataclass, field +from dataclasses import dataclass from itertools import chain, zip_longest from operator import itemgetter -from typing import ( - ClassVar, - Generic, - Iterable, - TypeVar, - cast, - NamedTuple, - Any, - Sequence, -) +from typing import Any, ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast import rich.repr from rich.console import RenderableType @@ -22,7 +13,7 @@ from rich.protocol import is_renderable from rich.segment import Segment from rich.style import Style from rich.text import Text, TextType -from typing_extensions import TypeAlias, Literal +from typing_extensions import Literal, TypeAlias from .. import events, messages from .._cache import LRUCache @@ -960,7 +951,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index = self.row_count row_key = RowKey(key) - # TODO: If there are no columns, do we generate them here? + # TODO: If there are no columns: do we generate them here? # If we don't do this, users will be required to call add_column(s) # Before they call add_row. From 95f2e491261cd47d3cec9283ef811aeee1f3444a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 15:52:47 +0000 Subject: [PATCH 120/155] Some review updates --- src/textual/widgets/_data_table.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index fc78d31fc..dcc991e43 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -44,8 +44,6 @@ class CellDoesNotExist(Exception): """Raised when the user supplies coordinates or cell keys which do not exist in the DataTable.""" - pass - @functools.total_ordering class StringKey: @@ -66,10 +64,10 @@ class StringKey: def __eq__(self, other: object) -> bool: # Strings will match Keys containing the same string value. # Otherwise, you'll need to supply the exact same key object. - try: + if isinstance(other, (str, StringKey)): return hash(self) == hash(other) - except TypeError: - return False + else: + raise NotImplemented def __lt__(self, other): if isinstance(other, str): @@ -82,16 +80,12 @@ class RowKey(StringKey): of the row changes due to sorting or other modifications, a key will always refer to the same row.""" - pass - class ColumnKey(StringKey): """Uniquely identifies a column in the DataTable. Even if the visual location of the column changes due to sorting or other modifications, a key will always refer to the same column.""" - pass - class CellKey(NamedTuple): """A unique identifier for a cell in the DataTable. Even if the cell changes @@ -637,7 +631,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return self.header_height return self.rows[row_key].height - async def on_styles_updated(self, _: messages.StylesUpdated) -> None: + async def on_styles_updated(self) -> None: self._clear_caches() self.refresh() From 84141630bc68f2aeb5f1c9a46518d732c356cbbb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 16:20:17 +0000 Subject: [PATCH 121/155] Some renaming of API methods --- src/textual/widgets/_data_table.py | 40 +++++++-------- tests/test_data_table.py | 82 ++++++++++++++---------------- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 418640d5b..80f68f1c8 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -465,7 +465,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._show_hover_cursor = False """Used to hide the mouse hover cursor when the user uses the keyboard.""" self._update_count = 0 - """Number of update operations so far. Used for cache invalidation.""" + """Number of update (INCLUDING SORT) operations so far. Used for cache invalidation.""" self._header_row_key = RowKey() """The header is a special row - not part of the data. Retrieve via this key.""" @@ -554,7 +554,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.refresh() - def update_coordinate( + def update_cell_at( self, coordinate: Coordinate, value: CellType, *, update_width: bool = False ) -> None: """Update the content inside the cell currently occupying the given coordinate. @@ -579,22 +579,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.data[row_key] yield row[column_key] - def get_value_at(self, coordinate: Coordinate) -> CellType: - """Get the value from the cell occupying the given coordinate. - - Args: - coordinate: The coordinate to retrieve the value from. - - Returns: - The value of the cell at the coordinate. - - Raises: - CellDoesNotExist: If there is no cell with the given coordinate. - """ - row_key, column_key = self.coordinate_to_cell_key(coordinate) - return self.get_cell_value(row_key, column_key) - - def get_cell_value(self, row_key: RowKey, column_key: ColumnKey) -> CellType: + def get_cell(self, row_key: RowKey, column_key: ColumnKey) -> CellType: """Given a row key and column key, return the value of the corresponding cell. Args: @@ -612,6 +597,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) return cell_value + def get_cell_at(self, coordinate: Coordinate) -> CellType: + """Get the value from the cell occupying the given coordinate. + + Args: + coordinate: The coordinate to retrieve the value from. + + Returns: + The value of the cell at the coordinate. + + Raises: + CellDoesNotExist: If there is no cell with the given coordinate. + """ + row_key, column_key = self.coordinate_to_cell_key(coordinate) + return self.get_cell(row_key, column_key) + def _clear_caches(self) -> None: self._row_render_cache.clear() self._cell_render_cache.clear() @@ -681,7 +681,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Apply highlighting to the cell at the coordinate, and post event.""" self.refresh_coordinate(coordinate) try: - cell_value = self.get_value_at(coordinate) + cell_value = self.get_cell_at(coordinate) except CellDoesNotExist: # The cell may not exist e.g. when the table is cleared. # In that case, there's nothing for us to do here. @@ -1586,7 +1586,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.post_message_no_wait( DataTable.CellSelected( self, - self.get_value_at(cursor_coordinate), + self.get_cell_at(cursor_coordinate), coordinate=cursor_coordinate, cell_key=cell_key, ) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 53c4aa779..3389d7826 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -11,13 +11,7 @@ from textual.events import Click, MouseMove from textual.message import Message from textual.message_pump import MessagePump from textual.widgets import DataTable -from textual.widgets.data_table import ( - ColumnKey, - CellDoesNotExist, - CellKey, - Row, - RowKey, -) +from textual.widgets.data_table import CellDoesNotExist, CellKey, ColumnKey, Row, RowKey ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] @@ -204,7 +198,7 @@ async def test_add_column_with_width(): table = app.query_one(DataTable) column = table.add_column("ABC", width=10, key="ABC") row = table.add_row("123") - assert table.get_cell_value(row, column) == "123" + assert table.get_cell(row, column) == "123" assert table.columns[column].width == 10 assert table.columns[column].render_width == 12 # 10 + (2 padding) @@ -285,52 +279,52 @@ async def test_initial_column_widths() -> None: assert table.columns[bar].content_width == 6 -async def test_get_cell_value_returns_value_at_cell(): +async def test_get_cell_returns_value_at_cell(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_column("Column1", key="C1") table.add_row("TargetValue", key="R1") - assert table.get_cell_value("R1", "C1") == "TargetValue" + assert table.get_cell("R1", "C1") == "TargetValue" -async def test_get_cell_value_invalid_row_key(): +async def test_get_cell_invalid_row_key(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_column("Column1", key="C1") table.add_row("TargetValue", key="R1") with pytest.raises(CellDoesNotExist): - table.get_cell_value("INVALID_ROW", "C1") + table.get_cell("INVALID_ROW", "C1") -async def test_get_cell_value_invalid_column_key(): +async def test_get_cell_invalid_column_key(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_column("Column1", key="C1") table.add_row("TargetValue", key="R1") with pytest.raises(CellDoesNotExist): - table.get_cell_value("R1", "INVALID_COLUMN") + table.get_cell("R1", "INVALID_COLUMN") -async def test_get_value_at_returns_value_at_cell(): +async def test_get_cell_at_returns_value_at_cell(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) - assert table.get_value_at(Coordinate(0, 0)) == "0/0" + assert table.get_cell_at(Coordinate(0, 0)) == "0/0" -async def test_get_value_at_exception(): +async def test_get_cell_at_exception(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) with pytest.raises(CellDoesNotExist): - table.get_value_at(Coordinate(9999, 0)) + table.get_cell_at(Coordinate(9999, 0)) async def test_update_cell_cell_exists(): @@ -340,7 +334,7 @@ async def test_update_cell_cell_exists(): table.add_column("A", key="A") table.add_row("1", key="1") table.update_cell("1", "A", "NEW_VALUE") - assert table.get_cell_value("1", "A") == "NEW_VALUE" + assert table.get_cell("1", "A") == "NEW_VALUE" async def test_update_cell_cell_doesnt_exist(): @@ -353,25 +347,25 @@ async def test_update_cell_cell_doesnt_exist(): table.update_cell("INVALID", "CELL", "Value") -async def test_update_coordinate_coordinate_exists(): +async def test_update_cell_at_coordinate_exists(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) column_0, column_1 = table.add_columns("A", "B") row_0, *_ = table.add_rows(ROWS) - table.update_coordinate(Coordinate(0, 1), "newvalue") - assert table.get_cell_value(row_0, column_1) == "newvalue" + table.update_cell_at(Coordinate(0, 1), "newvalue") + assert table.get_cell(row_0, column_1) == "newvalue" -async def test_update_coordinate_coordinate_doesnt_exist(): +async def test_update_cell_at_coordinate_doesnt_exist(): app = DataTableApp() async with app.run_test(): table = app.query_one(DataTable) table.add_columns("A", "B") table.add_rows(ROWS) with pytest.raises(CellDoesNotExist): - table.update_coordinate(Coordinate(999, 999), "newvalue") + table.update_cell_at(Coordinate(999, 999), "newvalue") @pytest.mark.parametrize( @@ -387,7 +381,7 @@ async def test_update_coordinate_coordinate_doesnt_exist(): ("12345", "123456789", 9), ], ) -async def test_update_coordinate_column_width(label, new_value, new_content_width): +async def test_update_cell_at_column_width(label, new_value, new_content_width): # Initial cell values are length 3. Let's update cell content and ensure # that the width of the column is correct given the new cell content widths # and the label of the column the cell is in. @@ -398,7 +392,7 @@ async def test_update_coordinate_column_width(label, new_value, new_content_widt table.add_rows(ROWS) first_column = table.columns.get(key) - table.update_coordinate(Coordinate(0, 0), new_value, update_width=True) + table.update_cell_at(Coordinate(0, 0), new_value, update_width=True) await wait_for_idle() assert first_column.content_width == new_content_width assert first_column.render_width == new_content_width + 2 @@ -564,21 +558,21 @@ async def test_sort_coordinate_and_key_access(): row_two = table.add_row(2) # Items inserted in correct initial positions (before sort) - assert table.get_value_at(Coordinate(0, 0)) == 3 - assert table.get_value_at(Coordinate(1, 0)) == 1 - assert table.get_value_at(Coordinate(2, 0)) == 2 + assert table.get_cell_at(Coordinate(0, 0)) == 3 + assert table.get_cell_at(Coordinate(1, 0)) == 1 + assert table.get_cell_at(Coordinate(2, 0)) == 2 table.sort(column) # The keys still refer to the same cells... - assert table.get_cell_value(row_one, column) == 1 - assert table.get_cell_value(row_two, column) == 2 - assert table.get_cell_value(row_three, column) == 3 + assert table.get_cell(row_one, column) == 1 + assert table.get_cell(row_two, column) == 2 + assert table.get_cell(row_three, column) == 3 # ...even though the values under the coordinates have changed... - assert table.get_value_at(Coordinate(0, 0)) == 1 - assert table.get_value_at(Coordinate(1, 0)) == 2 - assert table.get_value_at(Coordinate(2, 0)) == 3 + assert table.get_cell_at(Coordinate(0, 0)) == 1 + assert table.get_cell_at(Coordinate(1, 0)) == 2 + assert table.get_cell_at(Coordinate(2, 0)) == 3 assert table.ordered_rows[0].key == row_one assert table.ordered_rows[1].key == row_two @@ -597,21 +591,21 @@ async def test_sort_reverse_coordinate_and_key_access(): row_two = table.add_row(2) # Items inserted in correct initial positions (before sort) - assert table.get_value_at(Coordinate(0, 0)) == 3 - assert table.get_value_at(Coordinate(1, 0)) == 1 - assert table.get_value_at(Coordinate(2, 0)) == 2 + assert table.get_cell_at(Coordinate(0, 0)) == 3 + assert table.get_cell_at(Coordinate(1, 0)) == 1 + assert table.get_cell_at(Coordinate(2, 0)) == 2 table.sort(column, reverse=True) # The keys still refer to the same cells... - assert table.get_cell_value(row_one, column) == 1 - assert table.get_cell_value(row_two, column) == 2 - assert table.get_cell_value(row_three, column) == 3 + assert table.get_cell(row_one, column) == 1 + assert table.get_cell(row_two, column) == 2 + assert table.get_cell(row_three, column) == 3 # ...even though the values under the coordinates have changed... - assert table.get_value_at(Coordinate(0, 0)) == 3 - assert table.get_value_at(Coordinate(1, 0)) == 2 - assert table.get_value_at(Coordinate(2, 0)) == 1 + assert table.get_cell_at(Coordinate(0, 0)) == 3 + assert table.get_cell_at(Coordinate(1, 0)) == 2 + assert table.get_cell_at(Coordinate(2, 0)) == 1 assert table.ordered_rows[0].key == row_three assert table.ordered_rows[1].key == row_two From 22ab4f80af76201ed6812a4d0e49212f6f313e13 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 9 Feb 2023 16:43:05 +0000 Subject: [PATCH 122/155] [no ci] Begin caching of offsets --- src/textual/widgets/_data_table.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 80f68f1c8..0937dbc57 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -442,6 +442,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Cache for individual cells.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) """Cache for lines within rows.""" + self._offset_cache: LRUCache[int, list[[tuple[RowKey, int]]]] = LRUCache(1) + """Cached y_offset - key is update_count - see y_offsets property for more information""" self._require_update_dimensions: bool = False """Set to re-calculate dimensions on idle.""" @@ -501,10 +503,14 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): lands on, and the y-offset *within* that row. The length of the returned list is therefore the total height of all rows within the DataTable.""" y_offsets: list[tuple[RowKey, int]] = [] - for row in self.ordered_rows: - row_key = row.key - row_height = row.height - y_offsets += [(row_key, y) for y in range(row_height)] + if self._update_count in self._offset_cache: + y_offsets = self._offset_cache[self._update_count] + else: + for row in self.ordered_rows: + row_key = row.key + row_height = row.height + y_offsets += [(row_key, y) for y in range(row_height)] + self._offset_cache = y_offsets return y_offsets @property From 03dc86fc66304e755b4fa6e8630fd24ee1d01b54 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 10:08:04 +0000 Subject: [PATCH 123/155] Add rich reprs --- src/textual/widgets/_data_table.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 0937dbc57..b63a07497 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -74,6 +74,9 @@ class StringKey: return self.value < other return self.value < other.value + def __rich_repr__(self): + yield "value", self.value + class RowKey(StringKey): """Uniquely identifies a row in the DataTable. Even if the visual location @@ -95,6 +98,10 @@ class CellKey(NamedTuple): row_key: RowKey column_key: ColumnKey + def __rich_repr__(self): + yield "row_key", self.row_key + yield "column_key", self.column_key + def default_cell_formatter(obj: object) -> RenderableType: """Given an object stored in a DataTable cell, return a Rich @@ -442,7 +449,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Cache for individual cells.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) """Cache for lines within rows.""" - self._offset_cache: LRUCache[int, list[[tuple[RowKey, int]]]] = LRUCache(1) + self._offset_cache: LRUCache[int, list[tuple[RowKey, int]]] = LRUCache(1) """Cached y_offset - key is update_count - see y_offsets property for more information""" self._require_update_dimensions: bool = False @@ -502,14 +509,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): y-coordinate, we can index into this list to find which row that y-coordinate lands on, and the y-offset *within* that row. The length of the returned list is therefore the total height of all rows within the DataTable.""" - y_offsets: list[tuple[RowKey, int]] = [] + y_offsets = [] if self._update_count in self._offset_cache: y_offsets = self._offset_cache[self._update_count] else: for row in self.ordered_rows: - row_key = row.key - row_height = row.height - y_offsets += [(row_key, y) for y in range(row_height)] + y_offsets += [(row.key, y) for y in range(row.height)] self._offset_cache = y_offsets return y_offsets From 196054f6b019a0a6f02e5bf0985e3788fe0d38f9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 12:11:48 +0000 Subject: [PATCH 124/155] Cache row ordering, raise NotImplemented if StringKey cannot be compared to a type --- src/textual/widgets/_data_table.py | 32 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b63a07497..08bcef69d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -72,7 +72,10 @@ class StringKey: def __lt__(self, other): if isinstance(other, str): return self.value < other - return self.value < other.value + elif isinstance(other, StringKey): + return self.value < other.value + else: + raise NotImplemented def __rich_repr__(self): yield "value", self.value @@ -450,7 +453,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000) """Cache for lines within rows.""" self._offset_cache: LRUCache[int, list[tuple[RowKey, int]]] = LRUCache(1) - """Cached y_offset - key is update_count - see y_offsets property for more information""" + """Cached y_offset - key is update_count - see y_offsets property for more + information """ + self._ordered_row_cache: LRUCache[tuple[int, int], list[Row]] = LRUCache(1) + """Caches row ordering - key is update_count.""" self._require_update_dimensions: bool = False """Set to re-calculate dimensions on idle.""" @@ -1132,12 +1138,22 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): @property def ordered_rows(self) -> list[Row]: """The list of Rows in the DataTable, ordered as they appear on screen.""" - row_indices = range(self.row_count) - ordered_rows = [] - for row_index in row_indices: - row_key = self._row_locations.get_key(row_index) - row = self.rows[row_key] - ordered_rows.append(row) + num_rows = self.row_count + update_count = self._update_count + cache_key = (num_rows, update_count) + if cache_key in self._ordered_row_cache: + ordered_rows = self._ordered_row_cache[cache_key] + print("from cache:") + else: + row_indices = range(num_rows) + ordered_rows = [] + for row_index in row_indices: + row_key = self._row_locations.get_key(row_index) + row = self.rows[row_key] + ordered_rows.append(row) + self._ordered_row_cache[cache_key] = ordered_rows + print("computed:") + print(ordered_rows) return ordered_rows def _get_row_renderables(self, row_index: int) -> list[RenderableType]: From 12a58f838ffae349fdf7e2ce4789208ba0ed7eae Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 14:09:33 +0000 Subject: [PATCH 125/155] Exception on duplicate row and column keys --- src/textual/widgets/_data_table.py | 13 +++++++++++-- src/textual/widgets/data_table.py | 12 +++++++----- tests/test_data_table.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 08bcef69d..c48d29103 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -45,6 +45,11 @@ class CellDoesNotExist(Exception): do not exist in the DataTable.""" +class DuplicateKey(Exception): + """Raised when the RowKey or ColumnKey provided already refers to + an existing row or column in the DataTable. Keys must be unique.""" + + @functools.total_ordering class StringKey: """An object used as a key in a mapping. It can optionally wrap a string, @@ -456,7 +461,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """Cached y_offset - key is update_count - see y_offsets property for more information """ self._ordered_row_cache: LRUCache[tuple[int, int], list[Row]] = LRUCache(1) - """Caches row ordering - key is update_count.""" + """Caches row ordering - key is (num_rows, update_count).""" self._require_update_dimensions: bool = False """Set to re-calculate dimensions on idle.""" @@ -917,6 +922,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): after being added due to sorting/insertion/deletion of other columns). """ column_key = ColumnKey(key) + if column_key in self._column_locations: + raise DuplicateKey(f"The column key {key!r} already exists.") column_index = len(self.columns) label = Text.from_markup(label) if isinstance(label, str) else label content_width = measure(self.app.console, label, 1) @@ -959,13 +966,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other rows). """ - row_index = self.row_count row_key = RowKey(key) + if row_key in self._row_locations: + raise DuplicateKey(f"The row key {row_key!r} already exists.") # TODO: If there are no columns: do we generate them here? # If we don't do this, users will be required to call add_column(s) # Before they call add_row. + row_index = self.row_count # Map the key of this row to its current index self._row_locations[row_key] = row_index self.data[row_key] = { diff --git a/src/textual/widgets/data_table.py b/src/textual/widgets/data_table.py index 429724361..a923e6b86 100644 --- a/src/textual/widgets/data_table.py +++ b/src/textual/widgets/data_table.py @@ -1,14 +1,15 @@ """Make non-widget DataTable support classes available.""" from ._data_table import ( + CellDoesNotExist, + CellKey, + CellType, Column, + ColumnKey, + CursorType, + DuplicateKey, Row, RowKey, - ColumnKey, - CellKey, - CursorType, - CellType, - CellDoesNotExist, ) __all__ = [ @@ -20,4 +21,5 @@ __all__ = [ "CursorType", "CellType", "CellDoesNotExist", + "DuplicateKey", ] diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 3389d7826..d4f22db4e 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -11,6 +11,7 @@ from textual.events import Click, MouseMove from textual.message import Message from textual.message_pump import MessagePump from textual.widgets import DataTable +from textual.widgets._data_table import DuplicateKey from textual.widgets.data_table import CellDoesNotExist, CellKey, ColumnKey, Row, RowKey ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] @@ -192,6 +193,25 @@ async def test_add_rows_user_defined_keys(): assert table.rows["algernon"] == first_row +async def test_add_row_duplicate_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("A") + table.add_row("1", key="1") + with pytest.raises(DuplicateKey): + table.add_row("2", key="1") # Duplicate row key + + +async def test_add_column_duplicate_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_column("A", key="A") + with pytest.raises(DuplicateKey): + table.add_column("B", key="A") # Duplicate column key + + async def test_add_column_with_width(): app = DataTableApp() async with app.run_test(): From 743ddf52033775a86b0ad1651546b5127efe4a3c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 14:21:16 +0000 Subject: [PATCH 126/155] Remove prints --- src/textual/widgets/_data_table.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c48d29103..991760f12 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1152,7 +1152,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cache_key = (num_rows, update_count) if cache_key in self._ordered_row_cache: ordered_rows = self._ordered_row_cache[cache_key] - print("from cache:") else: row_indices = range(num_rows) ordered_rows = [] @@ -1161,8 +1160,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row = self.rows[row_key] ordered_rows.append(row) self._ordered_row_cache[cache_key] = ordered_rows - print("computed:") - print(ordered_rows) return ordered_rows def _get_row_renderables(self, row_index: int) -> list[RenderableType]: From 100921d5a6c31f9d263f7a5a10918ddaa87dab8e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 14:28:59 +0000 Subject: [PATCH 127/155] Add message to exception in DataTable --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 991760f12..b2818b8cb 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -588,7 +588,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): for the new cell content. """ if not self.is_valid_coordinate(coordinate): - raise CellDoesNotExist() + raise CellDoesNotExist(f"Coordinate {coordinate!r} is invalid.") row_key, column_key = self.coordinate_to_cell_key(coordinate) self.update_cell(row_key, column_key, value, update_width=update_width) From 3a5461782ec556efb6f845217678d10d453dfebf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 13 Feb 2023 16:00:46 +0000 Subject: [PATCH 128/155] Update CHANGELOG for DataTable refactor --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c26553d34..5270475ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,38 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to Space https://github.com/Textualize/textual/issues/1433 - Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 - Added DOMNode.watch and DOMNode.is_attached methods https://github.com/Textualize/textual/pull/1750 +- Added `DataTable.sort` to sort rows https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.get_cell` to retrieve a cell by column/row keys https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.get_cell_at` to retrieve a cell by coordinate https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.update_cell` to update a cell by column/row keys https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.update_cell_at`to update a cell at a coordinate https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.ordered_rows` property to retrieve `Row`s as they're currently ordered https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.ordered_columns` property to retrieve `Column`s as they're currently ordered https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.coordinate_to_cell_key` to find the key for the cell at a coordinate https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.is_valid_coordinate` https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.is_valid_row_index` https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.is_valid_column_index` https://github.com/Textualize/textual/pull/1638 +- Added attributes to events emitted from `DataTable` indicating row/column/cell keys https://github.com/Textualize/textual/pull/1638 ### Changed - Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637 - `Tree` now shows a (subdued) cursor for a highlighted node when focus has moved elsewhere https://github.com/Textualize/textual/issues/1471 - Breaking change: renamed `Checkbox` to `Switch`. +- `DataTable.add_row` now accepts `key` argument to uniquely identify the row https://github.com/Textualize/textual/pull/1638 +- `DataTable.add_column` now accepts `key` argument to uniquely identify the column https://github.com/Textualize/textual/pull/1638 +- `DataTable.add_row` and `DataTable.add_column` now return lists of keys identifying the added rows/columns https://github.com/Textualize/textual/pull/1638 +- Breaking change: `DataTable.get_cell_value` renamed to `DataTable.get_value_at` https://github.com/Textualize/textual/pull/1638 +- `DataTable.row_count` is now a property https://github.com/Textualize/textual/pull/1638 +- Breaking change: `DataTable.cursor_cell` renamed to `DataTable.cursor_coordinate` https://github.com/Textualize/textual/pull/1638 + - The method `validate_cursor_cell` was renamed to `validate_cursor_coordinate`. + - The method `watch_cursor_cell` was renamed to `watch_cursor_coordinate`. +- Breaking change: `DataTable.hover_cell` renamed to `DataTable.hover_coordinate` https://github.com/Textualize/textual/pull/1638 + - The method `validate_hover_cell` was renamed to `validate_hover_coordinate`. +- Breaking change: `DataTable.data` structure changed, and will be made private in upcoming release https://github.com/Textualize/textual/pull/1638 +- Breaking change: `DataTable.refresh_cell` was renamed to `DataTable.refresh_coordinate` https://github.com/Textualize/textual/pull/1638 +- Breaking change: `DataTable.get_row_height` now takes a `RowKey` argument instead of a row index https://github.com/Textualize/textual/pull/1638 +- The `_filter` module was made public (now called `filter`) https://github.com/Textualize/textual/pull/1638 ### Fixed @@ -40,6 +66,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issue with renderable width calculation https://github.com/Textualize/textual/issues/1685 - Fixed issue with app not processing Paste event https://github.com/Textualize/textual/issues/1666 - Fixed glitch with view position with auto width inputs https://github.com/Textualize/textual/issues/1693 +- Fixed `DataTable` "selected" events containing wrong coordinates when mouse was used https://github.com/Textualize/textual/issues/1723 ### Removed From 8a6e44b0104cc39b003cd174ebeb3d73083f5ff5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:00:43 +0000 Subject: [PATCH 129/155] Make DataTable.data private (it's now _data) --- src/textual/widgets/_data_table.py | 20 ++++++++++---------- tests/test_data_table.py | 14 +++++++------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b2818b8cb..9c7c91eaa 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -427,7 +427,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): classes: str | None = None, ) -> None: super().__init__(name=name, id=id, classes=classes) - self.data: dict[RowKey, dict[ColumnKey, CellType]] = {} + self._data: dict[RowKey, dict[ColumnKey, CellType]] = {} """Contains the cells of the table, indexed by row key and column key. The final positioning of a cell on screen cannot be determined solely by this structure. Instead, we must check _row_locations and _column_locations to find @@ -562,7 +562,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key = ColumnKey(column_key) try: - self.data[row_key][column_key] = value + self._data[row_key][column_key] = value except KeyError: raise CellDoesNotExist( f"No cell exists for row_key={row_key!r}, column_key={column_key!r}." @@ -598,7 +598,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): order they currently appear on screen.""" for row_metadata in self.ordered_rows: row_key = row_metadata.key - row = self.data[row_key] + row = self._data[row_key] yield row[column_key] def get_cell(self, row_key: RowKey, column_key: ColumnKey) -> CellType: @@ -612,7 +612,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): The value of the cell identified by the row and column keys. """ try: - cell_value = self.data[row_key][column_key] + cell_value = self._data[row_key][column_key] except KeyError: raise CellDoesNotExist( f"No cell exists for row_key={row_key!r}, column_key={column_key!r}." @@ -739,7 +739,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def _highlight_row(self, row_index: int) -> None: """Apply highlighting to the row at the given index, and post event.""" self.refresh_row(row_index) - is_valid_row = row_index < len(self.data) + is_valid_row = row_index < len(self._data) if is_valid_row: row_key = self._row_locations.get_key(row_index) self.post_message_no_wait( @@ -805,7 +805,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): console = self.app.console label_width = measure(console, column.label, 1) content_width = column.content_width - cell_value = self.data[row_key][column_key] + cell_value = self._data[row_key][column_key] new_content_width = measure(console, default_cell_formatter(cell_value), 1) @@ -896,7 +896,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): """ self._clear_caches() self._y_offsets.clear() - self.data.clear() + self._data.clear() self.rows.clear() if columns: self.columns.clear() @@ -977,7 +977,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_index = self.row_count # Map the key of this row to its current index self._row_locations[row_key] = row_index - self.data[row_key] = { + self._data[row_key] = { column.key: cell for column, cell in zip_longest(self.ordered_columns, cells) } @@ -1178,7 +1178,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): # Ensure we order the cells in the row based on current column ordering row_key = self._row_locations.get_key(row_index) - cell_mapping: dict[ColumnKey, CellType] = self.data.get(row_key, {}) + cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {}) ordered_row: list[CellType] = [] for column in ordered_columns: @@ -1513,7 +1513,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return result ordered_rows = sorted( - self.data.items(), key=sort_by_column_keys, reverse=reverse + self._data.items(), key=sort_by_column_keys, reverse=reverse ) self._row_locations = TwoWayDict( {key: new_index for new_index, (key, _) in enumerate(ordered_rows)} diff --git a/tests/test_data_table.py b/tests/test_data_table.py index d4f22db4e..d6e02d653 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -157,10 +157,10 @@ async def test_add_rows(): row_keys = table.add_rows(ROWS) # We're given a key for each row assert len(row_keys) == len(ROWS) - assert len(row_keys) == len(table.data) + assert len(row_keys) == len(table._data) assert table.row_count == len(ROWS) # Each key can be used to fetch a row from the DataTable - assert all(key in table.data for key in row_keys) + assert all(key in table._data for key in row_keys) async def test_add_rows_user_defined_keys(): @@ -179,14 +179,14 @@ async def test_add_rows_user_defined_keys(): # Ensure the data in the table is mapped as expected first_row = {key_a: ROWS[0][0], key_b: ROWS[0][1]} - assert table.data[algernon_key] == first_row - assert table.data["algernon"] == first_row + assert table._data[algernon_key] == first_row + assert table._data["algernon"] == first_row second_row = {key_a: ROWS[1][0], key_b: ROWS[1][1]} - assert table.data["charlie"] == second_row + assert table._data["charlie"] == second_row third_row = {key_a: ROWS[2][0], key_b: ROWS[2][1]} - assert table.data[auto_key] == third_row + assert table._data[auto_key] == third_row first_row = Row(algernon_key, height=1) assert table.rows[algernon_key] == first_row @@ -260,7 +260,7 @@ async def test_clear(): assert table.hover_coordinate == Coordinate(0, 0) # Ensure that the table has been cleared - assert table.data == {} + assert table._data == {} assert table.rows == {} assert table.row_count == 0 assert len(table.columns) == 1 From 273a4a8bc1b61965a8af52270ee3739212c6a2ab Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:24:13 +0000 Subject: [PATCH 130/155] Writing method signatures and docstrings for get_row/get_column DataTable methods --- src/textual/widgets/_data_table.py | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 9c7c91eaa..51dcab805 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -45,6 +45,16 @@ class CellDoesNotExist(Exception): do not exist in the DataTable.""" +class RowDoesNotExist(Exception): + """Raised when the user supplies a row index or row key which does + not exist in the DataTable (e.g. out of bounds index, invalid key)""" + + +class ColumnDoesNotExist(Exception): + """Raised when the user supplies a column index or column key which does + not exist in the DataTable (e.g. out of bounds index, invalid key)""" + + class DuplicateKey(Exception): """Raised when the RowKey or ColumnKey provided already refers to an existing row or column in the DataTable. Keys must be unique.""" @@ -634,6 +644,60 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key, column_key = self.coordinate_to_cell_key(coordinate) return self.get_cell(row_key, column_key) + def get_row(self, row_key: RowKey) -> list[CellType]: + """Get the values from the row identified by the given row key. + + Args: + row_key: The key of the row. + + Returns: + A list of the values contained within the row. + + Raises: + RowDoesNotExist: When there is no row corresponding to the key. + """ + + def get_row_at(self, row_index: int) -> list[CellType]: + """Get the values from the cells in a row at a given index. This will + return the values from a row based on the rows _current position_ in + the table. + + Args: + row_index: The index of the row. + + Returns: + A list of the values contained in the row. + + Raises: + RowDoesNotExist: If there is no row with the given index. + """ + + def get_column(self, column_key: ColumnKey) -> list[CellType]: + """Get the values from the column identified by the given column key. + + Args: + column_key: The key of the column. + + Returns: + A list of values in the column + + Raises: + ColumnDoesNotExist: If there is no column corresponding to the key. + """ + + def get_column_at(self, column_index: int) -> list[CellType]: + """Get the values from the column at a given index. + + Args: + column_index: The index of the column. + + Returns: + A list containing the values in the column. + + Raises: + ColumnDoesNotExist: If there is no column with the given index. + """ + def _clear_caches(self) -> None: self._row_render_cache.clear() self._cell_render_cache.clear() From a35f872bf9a4e5481807da3e6fa7aab9121cf41d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:33:58 +0000 Subject: [PATCH 131/155] Implement methods for getting row values in DataTable --- src/textual/widgets/_data_table.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 51dcab805..7ce7df10d 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -656,6 +656,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: RowDoesNotExist: When there is no row corresponding to the key. """ + cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {}) + ordered_row: list[CellType] = [] + for column in self.ordered_columns: + cell = cell_mapping[column.key] + ordered_row.append(cell) + return ordered_row def get_row_at(self, row_index: int) -> list[CellType]: """Get the values from the cells in a row at a given index. This will @@ -671,6 +677,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: RowDoesNotExist: If there is no row with the given index. """ + row_key = self._row_locations.get_key(row_index) + return self.get_row(row_key) def get_column(self, column_key: ColumnKey) -> list[CellType]: """Get the values from the column identified by the given column key. @@ -1240,15 +1248,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row: list[RenderableType] = [column.label for column in ordered_columns] return row - # Ensure we order the cells in the row based on current column ordering - row_key = self._row_locations.get_key(row_index) - cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {}) - - ordered_row: list[CellType] = [] - for column in ordered_columns: - cell = cell_mapping[column.key] - ordered_row.append(cell) - + ordered_row = self.get_row_at(row_index) empty = Text() return [ Text() if datum is None else default_cell_formatter(datum) or empty From 091adc9d8e759a9e17f4c0800ffad5184258d4e3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:44:35 +0000 Subject: [PATCH 132/155] Testing get_row (by key) in DataTable --- tests/test_data_table.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index d6e02d653..b7c885a92 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -347,6 +347,22 @@ async def test_get_cell_at_exception(): table.get_cell_at(Coordinate(9999, 0)) +async def test_get_row(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + first_row = table.add_row(2, 4, 1) + second_row = table.add_row(3, 2, 1) + assert table.get_row(first_row) == [2, 4, 1] + assert table.get_row(second_row) == [3, 2, 1] + + table.sort(b) + + assert table.get_row(first_row) == [2, 4, 1] + assert table.get_row(second_row) == [3, 2, 1] + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From c37061cf1877ee595864f43ed42eed26a0b0c7e5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:45:27 +0000 Subject: [PATCH 133/155] Adding an explanatory comment to a test --- tests/test_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index b7c885a92..cba0a436c 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -357,8 +357,8 @@ async def test_get_row(): assert table.get_row(first_row) == [2, 4, 1] assert table.get_row(second_row) == [3, 2, 1] + # Even if row positions change, keys should always refer to same rows. table.sort(b) - assert table.get_row(first_row) == [2, 4, 1] assert table.get_row(second_row) == [3, 2, 1] From 8d22ad6ff9ff951a666b0160b3b82e6e2d863178 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 11:52:03 +0000 Subject: [PATCH 134/155] Adding a test for DataTable.get_row_at --- tests/test_data_table.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index cba0a436c..37264a455 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -363,6 +363,24 @@ async def test_get_row(): assert table.get_row(second_row) == [3, 2, 1] +async def test_get_row_at(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b, c = table.add_columns("A", "B", "C") + table.add_row(2, 4, 1) + table.add_row(3, 2, 1) + assert table.get_row_at(0) == [2, 4, 1] + assert table.get_row_at(1) == [3, 2, 1] + + # If we sort, then the rows present at the indices *do* change! + table.sort(b) + + # Since we sorted on column "B", the rows at indices 0 and 1 are swapped. + assert table.get_row_at(0) == [3, 2, 1] + assert table.get_row_at(1) == [2, 4, 1] + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From fcdff48f0a7198dd269cc7834aedf1fb6c78fc9e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 12:47:49 +0000 Subject: [PATCH 135/155] Testing invalid index and keys in DataTable.get_row* --- src/textual/widgets/_data_table.py | 8 ++++++-- src/textual/widgets/data_table.py | 18 ++++++++++------- tests/test_data_table.py | 32 ++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 7ce7df10d..086f15eb7 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -644,7 +644,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key, column_key = self.coordinate_to_cell_key(coordinate) return self.get_cell(row_key, column_key) - def get_row(self, row_key: RowKey) -> list[CellType]: + def get_row(self, row_key: RowKey | str) -> list[CellType]: """Get the values from the row identified by the given row key. Args: @@ -656,6 +656,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: RowDoesNotExist: When there is no row corresponding to the key. """ + if row_key not in self._row_locations: + raise RowDoesNotExist(f"Row key {row_key!r} is not valid.") cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {}) ordered_row: list[CellType] = [] for column in self.ordered_columns: @@ -677,10 +679,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): Raises: RowDoesNotExist: If there is no row with the given index. """ + if not self.is_valid_row_index(row_index): + raise RowDoesNotExist(f"Row index {row_index!r} is not valid.") row_key = self._row_locations.get_key(row_index) return self.get_row(row_key) - def get_column(self, column_key: ColumnKey) -> list[CellType]: + def get_column(self, column_key: ColumnKey | str) -> list[CellType]: """Get the values from the column identified by the given column key. Args: diff --git a/src/textual/widgets/data_table.py b/src/textual/widgets/data_table.py index a923e6b86..0bb18f87f 100644 --- a/src/textual/widgets/data_table.py +++ b/src/textual/widgets/data_table.py @@ -5,21 +5,25 @@ from ._data_table import ( CellKey, CellType, Column, + ColumnDoesNotExist, ColumnKey, CursorType, DuplicateKey, Row, + RowDoesNotExist, RowKey, ) __all__ = [ - "Column", - "Row", - "RowKey", - "ColumnKey", - "CellKey", - "CursorType", - "CellType", "CellDoesNotExist", + "CellKey", + "CellType", + "Column", + "ColumnDoesNotExist", + "ColumnKey", + "CursorType", "DuplicateKey", + "Row", + "RowDoesNotExist", + "RowKey", ] diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 37264a455..f83c27b1b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -11,8 +11,16 @@ from textual.events import Click, MouseMove from textual.message import Message from textual.message_pump import MessagePump from textual.widgets import DataTable -from textual.widgets._data_table import DuplicateKey -from textual.widgets.data_table import CellDoesNotExist, CellKey, ColumnKey, Row, RowKey +from textual.widgets.data_table import ( + CellDoesNotExist, + CellKey, + ColumnDoesNotExist, + ColumnKey, + DuplicateKey, + Row, + RowDoesNotExist, + RowKey, +) ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]] @@ -363,6 +371,14 @@ async def test_get_row(): assert table.get_row(second_row) == [3, 2, 1] +async def test_get_row_invalid_row_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + with pytest.raises(RowDoesNotExist): + table.get_row("abc") + + async def test_get_row_at(): app = DataTableApp() async with app.run_test(): @@ -381,6 +397,18 @@ async def test_get_row_at(): assert table.get_row_at(1) == [2, 4, 1] +@pytest.mark.parametrize("index", (-1, 2)) +async def test_get_row_at_invalid_index(index): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("A", "B", "C") + table.add_row(2, 4, 1) + table.add_row(3, 2, 1) + with pytest.raises(RowDoesNotExist): + table.get_row_at(index) + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From 16c9f15ab5d9c2eb5db436b04d398d83326302fe Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:06:48 +0000 Subject: [PATCH 136/155] Add methods for retrieving column values to DataTable --- src/textual/widgets/_data_table.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 086f15eb7..ee80c27ed 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -603,14 +603,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key, column_key = self.coordinate_to_cell_key(coordinate) self.update_cell(row_key, column_key, value, update_width=update_width) - def _get_cells_in_column(self, column_key: ColumnKey) -> Iterable[CellType]: - """For a given column key, return the cells in that column in the - order they currently appear on screen.""" - for row_metadata in self.ordered_rows: - row_key = row_metadata.key - row = self._data[row_key] - yield row[column_key] - def get_cell(self, row_key: RowKey, column_key: ColumnKey) -> CellType: """Given a row key and column key, return the value of the corresponding cell. @@ -684,31 +676,43 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): row_key = self._row_locations.get_key(row_index) return self.get_row(row_key) - def get_column(self, column_key: ColumnKey | str) -> list[CellType]: + def get_column(self, column_key: ColumnKey | str) -> Iterable[CellType]: """Get the values from the column identified by the given column key. Args: column_key: The key of the column. Returns: - A list of values in the column + A generator which yields the cells in the column. Raises: ColumnDoesNotExist: If there is no column corresponding to the key. """ + if column_key not in self._column_locations: + raise ColumnDoesNotExist(f"Column key {column_key!r} is not valid.") - def get_column_at(self, column_index: int) -> list[CellType]: + for row_metadata in self.ordered_rows: + row_key = row_metadata.key + row = self._data[row_key] + yield row[column_key] + + def get_column_at(self, column_index: int) -> Iterable[CellType]: """Get the values from the column at a given index. Args: column_index: The index of the column. Returns: - A list containing the values in the column. + A generator which yields the cells in the column. Raises: ColumnDoesNotExist: If there is no column with the given index. """ + if not self.is_valid_column_index(column_index): + raise ColumnDoesNotExist(f"Column index {column_index!r} is not valid.") + + column_key = self._column_locations.get_key(column_index) + yield from self.get_column(column_key) def _clear_caches(self) -> None: self._row_render_cache.clear() @@ -886,7 +890,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): new_content_width = measure(console, default_cell_formatter(cell_value), 1) if new_content_width < content_width: - cells_in_column = self._get_cells_in_column(column_key) + cells_in_column = self.get_column(column_key) cell_widths = [ measure(console, default_cell_formatter(cell), 1) for cell in cells_in_column From af694ecb970fb7f89e8132e59154fc4576d27e47 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:18:19 +0000 Subject: [PATCH 137/155] Testing DataTable.get_column --- tests/test_data_table.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index f83c27b1b..123e7f75d 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -376,7 +376,7 @@ async def test_get_row_invalid_row_key(): async with app.run_test(): table = app.query_one(DataTable) with pytest.raises(RowDoesNotExist): - table.get_row("abc") + table.get_row("INVALID") async def test_get_row_at(): @@ -409,6 +409,20 @@ async def test_get_row_at_invalid_index(index): table.get_row_at(index) +async def test_get_column(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + a, b = table.add_columns("A", "B") + table.add_rows(ROWS) + cells = table.get_column(a) + assert next(cells) == ROWS[0][0] + assert next(cells) == ROWS[1][0] + assert next(cells) == ROWS[2][0] + with pytest.raises(StopIteration): + next(cells) + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From 6d888af723f6ac53e25f66d6a8f4439096c49034 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:22:07 +0000 Subject: [PATCH 138/155] Testing DataTable.get_column with invalid column key --- tests/test_data_table.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 123e7f75d..bf072bd0b 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -423,6 +423,14 @@ async def test_get_column(): next(cells) +async def test_get_column_invalid_key(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + with pytest.raises(ColumnDoesNotExist): + list(table.get_column("INVALID")) + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From 3b7d24f9c041c0f226ef44945b0ad34ab911a064 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:25:01 +0000 Subject: [PATCH 139/155] Testing DataTable.get_column_at --- tests/test_data_table.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index bf072bd0b..2ebbd8380 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -431,6 +431,17 @@ async def test_get_column_invalid_key(): list(table.get_column("INVALID")) +async def test_get_column_at(): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("A", "B") + table.add_rows(ROWS) + + first_column = list(table.get_column_at(0)) + assert first_column == [ROWS[0][0], ROWS[1][0], ROWS[2][0]] + + async def test_update_cell_cell_exists(): app = DataTableApp() async with app.run_test(): From 92087784dfdfb15fbbae24aa5eb0d47f40a7ec84 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:28:48 +0000 Subject: [PATCH 140/155] Testing DataTable.get_column_at with invalid index --- tests/test_data_table.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 2ebbd8380..5dc6abaf2 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -441,6 +441,18 @@ async def test_get_column_at(): first_column = list(table.get_column_at(0)) assert first_column == [ROWS[0][0], ROWS[1][0], ROWS[2][0]] + second_column = list(table.get_column_at(1)) + assert second_column == [ROWS[0][1], ROWS[1][1], ROWS[2][1]] + + +@pytest.mark.parametrize("index", [-1, 5]) +async def test_get_column_at_invalid_index(index): + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + with pytest.raises(ColumnDoesNotExist): + list(table.get_column_at(index)) + async def test_update_cell_cell_exists(): app = DataTableApp() From 25d5132b72588a075bfb106bbd299c46cad67da8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:30:47 +0000 Subject: [PATCH 141/155] Update CHANGELOG.md regarding DataTable.get_row* and DataTable.get_column* methods --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5c13e79..557b83e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DataTable.get_cell` to retrieve a cell by column/row keys https://github.com/Textualize/textual/pull/1638 - Added `DataTable.get_cell_at` to retrieve a cell by coordinate https://github.com/Textualize/textual/pull/1638 - Added `DataTable.update_cell` to update a cell by column/row keys https://github.com/Textualize/textual/pull/1638 -- Added `DataTable.update_cell_at`to update a cell at a coordinate https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.update_cell_at` to update a cell at a coordinate https://github.com/Textualize/textual/pull/1638 - Added `DataTable.ordered_rows` property to retrieve `Row`s as they're currently ordered https://github.com/Textualize/textual/pull/1638 - Added `DataTable.ordered_columns` property to retrieve `Column`s as they're currently ordered https://github.com/Textualize/textual/pull/1638 - Added `DataTable.coordinate_to_cell_key` to find the key for the cell at a coordinate https://github.com/Textualize/textual/pull/1638 @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DataTable.is_valid_row_index` https://github.com/Textualize/textual/pull/1638 - Added `DataTable.is_valid_column_index` https://github.com/Textualize/textual/pull/1638 - Added attributes to events emitted from `DataTable` indicating row/column/cell keys https://github.com/Textualize/textual/pull/1638 +- Added `DataTable.get_row` to retrieve the values from a row by key https://github.com/Textualize/textual/pull/1786 +- Added `DataTable.get_row_at` to retrieve the values from a row by index https://github.com/Textualize/textual/pull/1786 +- Added `DataTable.get_column` to retrieve the values from a column by key https://github.com/Textualize/textual/pull/1786 +- Added `DataTable.get_column_at` to retrieve the values from a column by index https://github.com/Textualize/textual/pull/1786 - Added `DOMNode.watch` and `DOMNode.is_attached` methods https://github.com/Textualize/textual/pull/1750 - Added `DOMNode.css_tree` which is a renderable that shows the DOM and CSS https://github.com/Textualize/textual/pull/1778 - Added `DOMNode.children_view` which is a view on to a nodes children list, use for querying https://github.com/Textualize/textual/pull/1778 From 100aab4015b4a7cc0eeaef83d4ef13abf08eb2c8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:32:02 +0000 Subject: [PATCH 142/155] Update CHANGELOG.md regarding making DataTable.data private --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557b83e4b..650cc0371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: `DataTable.data` structure changed, and will be made private in upcoming release https://github.com/Textualize/textual/pull/1638 - Breaking change: `DataTable.refresh_cell` was renamed to `DataTable.refresh_coordinate` https://github.com/Textualize/textual/pull/1638 - Breaking change: `DataTable.get_row_height` now takes a `RowKey` argument instead of a row index https://github.com/Textualize/textual/pull/1638 +- Breaking change: `DataTable.data` renamed to `DataTable._data` (it's now private) https://github.com/Textualize/textual/pull/1786 - The `_filter` module was made public (now called `filter`) https://github.com/Textualize/textual/pull/1638 - Breaking change: renamed `Checkbox` to `Switch` https://github.com/Textualize/textual/issues/1746 - `App.install_screen` name is no longer optional https://github.com/Textualize/textual/pull/1778 From 467615dabd7cc1e4bd5b1d43c1e7820478397f98 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:51:04 +0000 Subject: [PATCH 143/155] Fix some docstrings --- src/textual/widgets/_data_table.py | 46 +++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b2818b8cb..b195b1ac4 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -52,7 +52,9 @@ class DuplicateKey(Exception): @functools.total_ordering class StringKey: - """An object used as a key in a mapping. It can optionally wrap a string, + """An object used as a key in a mapping. + + It can optionally wrap a string, and lookups into a map using the object behave the same as lookups using the string itself.""" @@ -87,19 +89,25 @@ class StringKey: class RowKey(StringKey): - """Uniquely identifies a row in the DataTable. Even if the visual location + """Uniquely identifies a row in the DataTable. + + Even if the visual location of the row changes due to sorting or other modifications, a key will always refer to the same row.""" class ColumnKey(StringKey): - """Uniquely identifies a column in the DataTable. Even if the visual location + """Uniquely identifies a column in the DataTable. + + Even if the visual location of the column changes due to sorting or other modifications, a key will always refer to the same column.""" class CellKey(NamedTuple): - """A unique identifier for a cell in the DataTable. Even if the cell changes + """A unique identifier for a cell in the DataTable. + + Even if the cell changes visual location (i.e. moves to a different coordinate in the table), this key can still be used to retrieve it, regardless of where it currently is.""" @@ -265,8 +273,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): hover_coordinate: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False) class CellHighlighted(Message, bubble=True): - """Posted when the cursor moves to highlight a new cell. It's only relevant - when the `cursor_type` is `"cell"`. It's also posted when the cell cursor is + """Posted when the cursor moves to highlight a new cell. + + This is only relevant when the `cursor_type` is `"cell"`. + It's also posted when the cell cursor is re-enabled (by setting `show_cursor=True`), and when the cursor type is changed to `"cell"`. Can be handled using `on_data_table_cell_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -297,7 +307,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): class CellSelected(Message, bubble=True): """Posted by the `DataTable` widget when a cell is selected. - It's only relevant when the `cursor_type` is `"cell"`. Can be handled using + + This is only relevant when the `cursor_type` is `"cell"`. Can be handled using `on_data_table_cell_selected` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -326,7 +337,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "cell_key", self.cell_key class RowHighlighted(Message, bubble=True): - """Posted when a row is highlighted. This message is only posted when the + """Posted when a row is highlighted. + + This message is only posted when the `cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -347,7 +360,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "row_key", self.row_key class RowSelected(Message, bubble=True): - """Posted when a row is selected. This message is only posted when the + """Posted when a row is selected. + + This message is only posted when the `cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_selected` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -368,7 +383,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "row_key", self.row_key class ColumnHighlighted(Message, bubble=True): - """Posted when a column is highlighted. This message is only posted when the + """Posted when a column is highlighted. + + This message is only posted when the `cursor_type` is set to `"column"`. Can be handled using `on_data_table_column_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -391,7 +408,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "column_key", self.column_key class ColumnSelected(Message, bubble=True): - """Posted when a column is selected. This message is only posted when the + """Posted when a column is selected. + + This message is only posted when the `cursor_type` is set to `"column"`. Can be handled using `on_data_table_column_selected` in a subclass of `DataTable` or in a parent widget in the DOM. @@ -1033,8 +1052,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return row_keys def on_idle(self) -> None: - """Runs when the message pump is empty. We use this for - some expensive calculations like re-computing dimensions of the + """Runs when the message pump is empty. + + We use this for some expensive calculations like re-computing dimensions of the whole DataTable and re-computing column widths after some cells have been updated. This is more efficient in the case of high frequency updates, ensuring we only do expensive computations once.""" From b6441d04173ba8f57843cd3051d053c2d77e944f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 13:57:08 +0000 Subject: [PATCH 144/155] Formatting docstrings to match style guide --- src/textual/_two_way_dict.py | 4 +++- src/textual/widgets/_data_table.py | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/textual/_two_way_dict.py b/src/textual/_two_way_dict.py index b75543977..d733edcdc 100644 --- a/src/textual/_two_way_dict.py +++ b/src/textual/_two_way_dict.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TypeVar, Generic +from typing import Generic, TypeVar Key = TypeVar("Key") Value = TypeVar("Value") @@ -8,6 +8,8 @@ Value = TypeVar("Value") class TwoWayDict(Generic[Key, Value]): """ + A two-way mapping offering O(1) access in both directions. + Wraps two dictionaries and uses them to provide efficient access to both values (given keys) and keys (given values). """ diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b195b1ac4..c5381de5b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -41,12 +41,16 @@ CellType = TypeVar("CellType") class CellDoesNotExist(Exception): - """Raised when the user supplies coordinates or cell keys which + """The cell key/index was invalid. + + Raised when the user supplies coordinates or cell keys which do not exist in the DataTable.""" class DuplicateKey(Exception): - """Raised when the RowKey or ColumnKey provided already refers to + """The key supplied already exists. + + Raised when the RowKey or ColumnKey provided already refers to an existing row or column in the DataTable. Keys must be unique.""" @@ -120,8 +124,7 @@ class CellKey(NamedTuple): def default_cell_formatter(obj: object) -> RenderableType: - """Given an object stored in a DataTable cell, return a Rich - renderable type which displays that object. + """Convert a cell into a Rich renderable for display. Args: obj: Data for a cell. @@ -561,8 +564,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): *, update_width: bool = False, ) -> None: - """Update the content inside the cell with the specified row key - and column key. + """Update the cell identified by the specified row key and column key. Args: row_key: The key identifying the row. @@ -736,8 +738,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) def coordinate_to_cell_key(self, coordinate: Coordinate) -> CellKey: - """Return the key for the cell currently occupying this coordinate in the - DataTable + """Return the key for the cell currently occupying this coordinate. Args: coordinate: The coordinate to exam the current cell key of. @@ -1140,8 +1141,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): return 0 <= column_index < len(self.columns) def is_valid_coordinate(self, coordinate: Coordinate) -> bool: - """Return a boolean indicating whether the given coordinate is within table - bounds. + """Return a boolean indicating whether the given coordinate is valid. Args: coordinate: The coordinate to validate. From 1445275220aa9f149e18cdd7de73305c204a1c45 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 14:05:49 +0000 Subject: [PATCH 145/155] Add HeaderSelected message to DataTable (but don't emit yet). --- src/textual/message.py | 1 - src/textual/widgets/_data_table.py | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/textual/message.py b/src/textual/message.py index 5f46cc97a..7ed72789b 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -10,7 +10,6 @@ from .case import camel_to_snake if TYPE_CHECKING: from .message_pump import MessagePump - from .widget import Widget @rich.repr.auto diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 69288744d..4530eba63 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -445,6 +445,32 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "cursor_column", self.cursor_column yield "column_key", self.column_key + class HeaderSelected(Message, bubble=True): + """Posted when a column header/label is clicked. + + Attributes: + column_key: The key for the column. + column_index: The index for the column. + label: The text of the label. + """ + + def __init__( + self, + sender: DataTable, + column_key: ColumnKey, + column_index: int, + label: str, + ): + self.column_key = column_key + self.column_index = column_index + self.label = label + super().__init__(sender) + + def __rich_repr__(self) -> rich.repr.Result: + yield "sender", self.sender + yield "column_key", self.column_key + yield "label", self.label + def __init__( self, *, From df1116d7760ebc41a85f78e0e914abc574d4be89 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:01:02 +0000 Subject: [PATCH 146/155] Post an event when DataTable column header clicked --- src/textual/widgets/_data_table.py | 33 +- .../__snapshots__/test_snapshots.ambr | 458 +++++++++--------- 2 files changed, 251 insertions(+), 240 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 4530eba63..bbe7659de 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -459,7 +459,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): sender: DataTable, column_key: ColumnKey, column_index: int, - label: str, + label: Text, ): self.column_key = column_key self.column_index = column_index @@ -469,7 +469,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def __rich_repr__(self) -> rich.repr.Result: yield "sender", self.sender yield "column_key", self.column_key - yield "label", self.label + yield "label", self.label.plain def __init__( self, @@ -1360,8 +1360,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_key = self._column_locations.get_key(column_index) cell_cache_key = (row_key, column_key, style, cursor, hover, self._update_count) if cell_cache_key not in self._cell_render_cache: - if not is_header_row: - style += Style.from_meta({"row": row_index, "column": column_index}) + style += Style.from_meta({"row": row_index, "column": column_index}) height = self.header_height if is_header_row else self.rows[row_key].height cell = self._get_row_renderables(row_index)[column_index] lines = self.app.console.render_lines( @@ -1676,14 +1675,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def on_click(self, event: events.Click) -> None: self._set_hover_cursor(True) - if self.show_cursor and self.cursor_type != "none": + meta = self.get_style_at(event.x, event.y).meta + if not meta: + return + + row_index = meta["row"] + column_index = meta["column"] + is_header_click = self.show_header and row_index == -1 + if is_header_click: + # Header clicks work even if cursor is off, and don't move the cursor. + column = self.ordered_columns[column_index] + message = DataTable.HeaderSelected( + self, column.key, column_index, label=column.label + ) + self.post_message_no_wait(message) + elif self.show_cursor and self.cursor_type != "none": # Only post selection events if there is a visible row/col/cell cursor. - meta = self.get_style_at(event.x, event.y).meta - if meta: - self.cursor_coordinate = Coordinate(meta["row"], meta["column"]) - self._post_selected_message() - self._scroll_cursor_into_view(animate=True) - event.stop() + self.cursor_coordinate = Coordinate(row_index, column_index) + self._post_selected_message() + self._scroll_cursor_into_view(animate=True) + event.stop() def action_cursor_up(self) -> None: self._set_hover_cursor(False) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 075a214a7..8fc2f8904 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10015,134 +10015,134 @@ font-weight: 700; } - .terminal-3633944034-matrix { + .terminal-3966238525-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3633944034-title { + .terminal-3966238525-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3633944034-r1 { fill: #dde6ed;font-weight: bold } - .terminal-3633944034-r2 { fill: #1e1201;font-weight: bold } - .terminal-3633944034-r3 { fill: #e1e1e1 } - .terminal-3633944034-r4 { fill: #c5c8c6 } - .terminal-3633944034-r5 { fill: #211505 } + .terminal-3966238525-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3966238525-r2 { fill: #1e1201;font-weight: bold } + .terminal-3966238525-r3 { fill: #e1e1e1 } + .terminal-3966238525-r4 { fill: #c5c8c6 } + .terminal-3966238525-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10173,133 +10173,133 @@ font-weight: 700; } - .terminal-108526495-matrix { + .terminal-1288566407-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-108526495-title { + .terminal-1288566407-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-108526495-r1 { fill: #dde6ed;font-weight: bold } - .terminal-108526495-r2 { fill: #e1e1e1 } - .terminal-108526495-r3 { fill: #c5c8c6 } - .terminal-108526495-r4 { fill: #211505 } + .terminal-1288566407-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1288566407-r2 { fill: #e1e1e1 } + .terminal-1288566407-r3 { fill: #c5c8c6 } + .terminal-1288566407-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  -  10    Darren Burns          Scotland       51.84  - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + @@ -10330,133 +10330,133 @@ font-weight: 700; } - .terminal-512278738-matrix { + .terminal-3001793466-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-512278738-title { + .terminal-3001793466-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-512278738-r1 { fill: #dde6ed;font-weight: bold } - .terminal-512278738-r2 { fill: #e1e1e1 } - .terminal-512278738-r3 { fill: #c5c8c6 } - .terminal-512278738-r4 { fill: #211505 } + .terminal-3001793466-r1 { fill: #dde6ed;font-weight: bold } + .terminal-3001793466-r2 { fill: #e1e1e1 } + .terminal-3001793466-r3 { fill: #c5c8c6 } + .terminal-3001793466-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + @@ -10487,133 +10487,133 @@ font-weight: 700; } - .terminal-480181151-matrix { + .terminal-1660221063-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-480181151-title { + .terminal-1660221063-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-480181151-r1 { fill: #dde6ed;font-weight: bold } - .terminal-480181151-r2 { fill: #e1e1e1 } - .terminal-480181151-r3 { fill: #c5c8c6 } - .terminal-480181151-r4 { fill: #211505 } + .terminal-1660221063-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1660221063-r2 { fill: #e1e1e1 } + .terminal-1660221063-r3 { fill: #c5c8c6 } + .terminal-1660221063-r4 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  -  10    Darren Burns          Scotland       51.84  - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  +  10    Darren Burns          Scotland       51.84  + + + + + + + + + + + + + From 5cf1be1cbcb9c620154534a6a3643fe056041854 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:16:22 +0000 Subject: [PATCH 147/155] Adding test for HeaderSelected event in DataTable --- tests/test_data_table.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 5dc6abaf2..44b7e5d55 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -2,6 +2,7 @@ from __future__ import annotations import pytest from rich.style import Style +from rich.text import Text from textual._wait import wait_for_idle from textual.actions import SkipAction @@ -33,6 +34,7 @@ class DataTableApp(App): "RowSelected", "ColumnHighlighted", "ColumnSelected", + "HeaderSelected", } def __init__(self): @@ -673,6 +675,40 @@ async def test_hover_coordinate(): assert table.hover_coordinate == Coordinate(1, 2) +async def test_header_selected(): + """Ensure that a HeaderSelected event gets posted when we click + on the header in the DataTable.""" + app = DataTableApp() + async with app.run_test(): + table = app.query_one(DataTable) + column = table.add_column("number") + table.add_row(3) + click_event = Click( + sender=table, + x=3, + y=0, + delta_x=0, + delta_y=0, + button=0, + shift=False, + meta=False, + ctrl=False, + ) + table.on_click(click_event) + await wait_for_idle(0) + message: DataTable.HeaderSelected = app.messages[-1] + assert message.sender is table + assert message.label == Text("number") + assert message.column_index == 0 + assert message.column_key == column + + # Now hide the header and click in the exact same place - no additional message emitted. + table.show_header = False + table.on_click(click_event) + await wait_for_idle(0) + assert app.message_names.count("HeaderSelected") == 1 + + async def test_sort_coordinate_and_key_access(): """Ensure that, after sorting, that coordinates and cell keys can still be used to retrieve the correct cell.""" From 50898c2d07311cabe5f1be219d4cf06b20c63018 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:17:49 +0000 Subject: [PATCH 148/155] Added note to changelog about HeaderSelected event in DataTable --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 650cc0371..e08383776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DataTable.get_row_at` to retrieve the values from a row by index https://github.com/Textualize/textual/pull/1786 - Added `DataTable.get_column` to retrieve the values from a column by key https://github.com/Textualize/textual/pull/1786 - Added `DataTable.get_column_at` to retrieve the values from a column by index https://github.com/Textualize/textual/pull/1786 +- Added `DataTable.HeaderSelected` which is posted when header label clicked https://github.com/Textualize/textual/pull/1788 - Added `DOMNode.watch` and `DOMNode.is_attached` methods https://github.com/Textualize/textual/pull/1750 - Added `DOMNode.css_tree` which is a renderable that shows the DOM and CSS https://github.com/Textualize/textual/pull/1778 - Added `DOMNode.children_view` which is a view on to a nodes children list, use for querying https://github.com/Textualize/textual/pull/1778 From 49b78daa04a160449d32b6782747359f3d57e4eb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:18:32 +0000 Subject: [PATCH 149/155] Update docs to include `DataTable.HeaderSelected` --- docs/widgets/data_table.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index 9dd918d9d..c4d94fab0 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -48,6 +48,8 @@ The example below populates a table with CSV data. ### ::: textual.widgets.DataTable.ColumnSelected +### ::: textual.widgets.DataTable.HeaderSelected + ## Bindings The data table widget defines directly the following bindings: From 7824942f820698c049da0515b699de1a5c10da4e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:19:30 +0000 Subject: [PATCH 150/155] Typo fix in comment --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index bbe7659de..366c18816 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1683,7 +1683,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): column_index = meta["column"] is_header_click = self.show_header and row_index == -1 if is_header_click: - # Header clicks work even if cursor is off, and don't move the cursor. + # Header clicks work even if cursor is off, and doesn't move the cursor. column = self.ordered_columns[column_index] message = DataTable.HeaderSelected( self, column.key, column_index, label=column.label From 4ec5d3f9db31bf38c972e3f0790f3ffe0cbbb9a1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:25:33 +0000 Subject: [PATCH 151/155] Migrate some DataTable tests from pilot.pause to wait_for_idle --- tests/test_data_table.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 44b7e5d55..2a000332d 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -75,11 +75,12 @@ async def test_datatable_message_emission(): # therefore no highlighted cells), but then a row was added, and # so the cell at (0, 0) became highlighted. expected_messages.append("CellHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages # Pressing Enter when the cursor is on a cell emits a CellSelected await pilot.press("enter") + await wait_for_idle(0) expected_messages.append("CellSelected") assert app.message_names == expected_messages @@ -92,11 +93,12 @@ async def test_datatable_message_emission(): # Switch over to the row cursor... should emit a `RowHighlighted` table.cursor_type = "row" expected_messages.append("RowHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages # Select the row... await pilot.press("enter") + await wait_for_idle(0) expected_messages.append("RowSelected") assert app.message_names == expected_messages @@ -104,18 +106,20 @@ async def test_datatable_message_emission(): # Switching to the column cursor emits a `ColumnHighlighted` table.cursor_type = "column" expected_messages.append("ColumnHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages # Select the column... await pilot.press("enter") expected_messages.append("ColumnSelected") + await wait_for_idle(0) assert app.message_names == expected_messages # NONE CURSOR # No messages get emitted at all... table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") + await wait_for_idle(0) # No new messages since cursor not visible assert app.message_names == expected_messages @@ -125,6 +129,7 @@ async def test_datatable_message_emission(): table.show_cursor = False table.cursor_type = "cell" await pilot.press("up", "down", "left", "right", "enter") + await wait_for_idle(0) # No new messages since show_cursor = False assert app.message_names == expected_messages @@ -132,7 +137,7 @@ async def test_datatable_message_emission(): # message should be emitted for highlighting the cell. table.show_cursor = True expected_messages.append("CellHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages # Similarly for showing the cursor again when row or column @@ -141,14 +146,14 @@ async def test_datatable_message_emission(): table.cursor_type = "row" table.show_cursor = True expected_messages.append("RowHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages table.show_cursor = False table.cursor_type = "column" table.show_cursor = True expected_messages.append("ColumnHighlighted") - await pilot.pause(2 / 100) + await wait_for_idle(0) assert app.message_names == expected_messages # Likewise, if the cursor_type is "none", and we change the @@ -156,6 +161,7 @@ async def test_datatable_message_emission(): # the cursor is still not visible to the user. table.cursor_type = "none" await pilot.press("up", "down", "left", "right", "enter") + await wait_for_idle(0) assert app.message_names == expected_messages From 36a9214d7f66d4e7b70d867f56e9038b1a983ac4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 15:29:05 +0000 Subject: [PATCH 152/155] Update reactive names in DataTable reference docs --- docs/widgets/data_table.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md index 9dd918d9d..289636afc 100644 --- a/docs/widgets/data_table.md +++ b/docs/widgets/data_table.md @@ -22,17 +22,17 @@ The example below populates a table with CSV data. ## Reactive Attributes -| Name | Type | Default | Description | -|-----------------|---------------------------------------------|--------------------|---------------------------------------------------------| -| `show_header` | `bool` | `True` | Show the table header | -| `fixed_rows` | `int` | `0` | Number of fixed rows (rows which do not scroll) | -| `fixed_columns` | `int` | `0` | Number of fixed columns (columns which do not scroll) | -| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows | -| `header_height` | `int` | `1` | Height of header row | -| `show_cursor` | `bool` | `True` | Show the cursor | -| `cursor_type` | `str` | `"cell"` | One of `"cell"`, `"row"`, `"column"`, or `"none"` | -| `cursor_cell` | [Coordinate][textual.coordinate.Coordinate] | `Coordinate(0, 0)` | The coordinates of the cell the cursor is currently on | -| `hover_cell` | [Coordinate][textual.coordinate.Coordinate] | `Coordinate(0, 0)` | The coordinates of the cell the _mouse_ cursor is above | +| Name | Type | Default | Description | +|---------------------|---------------------------------------------|--------------------|-------------------------------------------------------| +| `show_header` | `bool` | `True` | Show the table header | +| `fixed_rows` | `int` | `0` | Number of fixed rows (rows which do not scroll) | +| `fixed_columns` | `int` | `0` | Number of fixed columns (columns which do not scroll) | +| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows | +| `header_height` | `int` | `1` | Height of header row | +| `show_cursor` | `bool` | `True` | Show the cursor | +| `cursor_type` | `str` | `"cell"` | One of `"cell"`, `"row"`, `"column"`, or `"none"` | +| `cursor_coordinate` | [Coordinate][textual.coordinate.Coordinate] | `Coordinate(0, 0)` | The current coordinate of the cursor | +| `hover_coordinate` | [Coordinate][textual.coordinate.Coordinate] | `Coordinate(0, 0)` | The coordinate the _mouse_ cursor is above | ## Messages From f28f9c4caee73e324ceeba810dc0cbc76e5e8c90 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 16:24:05 +0000 Subject: [PATCH 153/155] Docstring changes --- src/textual/widgets/_data_table.py | 50 +++++++++++++----------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index c5381de5b..7221f1aaa 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -75,8 +75,13 @@ class StringKey: def __eq__(self, other: object) -> bool: # Strings will match Keys containing the same string value. # Otherwise, you'll need to supply the exact same key object. - if isinstance(other, (str, StringKey)): - return hash(self) == hash(other) + if isinstance(other, str): + return self.value == other + elif isinstance(other, StringKey): + if self.value is not None and other.value is not None: + return self.value == other.value + else: + return hash(self) == hash(other) else: raise NotImplemented @@ -283,11 +288,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): re-enabled (by setting `show_cursor=True`), and when the cursor type is changed to `"cell"`. Can be handled using `on_data_table_cell_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - value: The value in the highlighted cell. - coordinate: The coordinate of the highlighted cell. - cell_key: The key for the highlighted cell. """ def __init__( @@ -298,8 +298,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cell_key: CellKey, ) -> None: self.value: CellType = value + """The value in the highlighted cell.""" self.coordinate: Coordinate = coordinate + """The coordinate of the highlighted cell.""" self.cell_key: CellKey = cell_key + """The key for the highlighted cell.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -314,11 +317,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): This is only relevant when the `cursor_type` is `"cell"`. Can be handled using `on_data_table_cell_selected` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - value: The value in the cell that was selected. - coordinate: The coordinate of the cell that was selected. - cell_key: The key for the selected cell. """ def __init__( @@ -329,8 +327,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cell_key: CellKey, ) -> None: self.value: CellType = value + """The value in the cell that was selected.""" self.coordinate: Coordinate = coordinate + """The coordinate of the cell that was selected.""" self.cell_key: CellKey = cell_key + """The key for the selected cell.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -346,15 +347,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): `cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - cursor_row: The y-coordinate of the cursor that highlighted the row. - row_key: The key of the row that was highlighted. """ def __init__(self, sender: DataTable, cursor_row: int, row_key: RowKey) -> None: self.cursor_row: int = cursor_row + """The y-coordinate of the cursor that highlighted the row.""" self.row_key: RowKey = row_key + """The key of the row that was highlighted.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -369,15 +368,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): `cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_selected` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - cursor_row: The y-coordinate of the cursor that made the selection. - row_key: The key of the row that was selected. """ def __init__(self, sender: DataTable, cursor_row: int, row_key: RowKey) -> None: self.cursor_row: int = cursor_row + """The y-coordinate of the cursor that made the selection.""" self.row_key: RowKey = row_key + """The key of the row that was selected.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -392,17 +389,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): `cursor_type` is set to `"column"`. Can be handled using `on_data_table_column_highlighted` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - cursor_column: The x-coordinate of the column that was highlighted. - column_key: The key of the column that was highlighted. """ def __init__( self, sender: DataTable, cursor_column: int, column_key: ColumnKey ) -> None: self.cursor_column: int = cursor_column + """The x-coordinate of the column that was highlighted.""" self.column_key = column_key + """The key of the column that was highlighted.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -417,17 +412,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): `cursor_type` is set to `"column"`. Can be handled using `on_data_table_column_selected` in a subclass of `DataTable` or in a parent widget in the DOM. - - Attributes: - cursor_column: The x-coordinate of the column that was selected. - column_key: The key of the column that was selected. """ def __init__( self, sender: DataTable, cursor_column: int, column_key: ColumnKey ) -> None: self.cursor_column: int = cursor_column + """The x-coordinate of the column that was selected.""" self.column_key = column_key + """The key of the column that was selected.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: @@ -818,6 +811,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._highlight_column(column_index) def _update_column_widths(self, updated_cells: set[CellKey]) -> None: + """Update the widths of the columns based on the newly updated cell widths.""" for row_key, column_key in updated_cells: column = self.columns.get(column_key) if column is None: From 18debed888eb3a19cdb5506ac987a3359bd6e09a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 16:30:46 +0000 Subject: [PATCH 154/155] Small optimisations --- src/textual/widgets/_data_table.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 69288744d..a9a4d9519 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -672,10 +672,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if row_key not in self._row_locations: raise RowDoesNotExist(f"Row key {row_key!r} is not valid.") cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {}) - ordered_row: list[CellType] = [] - for column in self.ordered_columns: - cell = cell_mapping[column.key] - ordered_row.append(cell) + ordered_row: list[CellType] = [ + cell_mapping[column.key] for column in self.ordered_columns + ] return ordered_row def get_row_at(self, row_index: int) -> list[CellType]: @@ -712,10 +711,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if column_key not in self._column_locations: raise ColumnDoesNotExist(f"Column key {column_key!r} is not valid.") + data = self._data for row_metadata in self.ordered_rows: row_key = row_metadata.key - row = self._data[row_key] - yield row[column_key] + yield data[row_key][column_key] def get_column_at(self, column_index: int) -> Iterable[CellType]: """Get the values from the column at a given index. From c80299ea0908112a9e80fbf2a22345f78686e5c4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 14 Feb 2023 16:35:35 +0000 Subject: [PATCH 155/155] Use new docstring format --- src/textual/widgets/_data_table.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 366c18816..0c8839c6b 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -446,13 +446,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): yield "column_key", self.column_key class HeaderSelected(Message, bubble=True): - """Posted when a column header/label is clicked. - - Attributes: - column_key: The key for the column. - column_index: The index for the column. - label: The text of the label. - """ + """Posted when a column header/label is clicked.""" def __init__( self, @@ -462,8 +456,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): label: Text, ): self.column_key = column_key + """The key for the column.""" self.column_index = column_index + """The index for the column.""" self.label = label + """The text of the label.""" super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: