mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into promote-disabled
This commit is contained in:
@@ -7,3 +7,4 @@ exclude_lines =
|
||||
if TYPE_CHECKING:
|
||||
if __name__ == "__main__":
|
||||
@overload
|
||||
__rich_repr__
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -19,6 +19,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally
|
||||
- Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to <kbd>Space</kbd> https://github.com/Textualize/textual/issues/1433
|
||||
- Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437
|
||||
- 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
|
||||
- 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 `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
|
||||
@@ -27,6 +44,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
- 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
|
||||
- `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
|
||||
- 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
|
||||
- `App.query` now only includes the current screen https://github.com/Textualize/textual/pull/1778
|
||||
@@ -47,6 +79,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
|
||||
|
||||
|
||||
@@ -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,11 +21,11 @@ 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)
|
||||
|
||||
|
||||
app = TableApp()
|
||||
if __name__ == "__main__":
|
||||
app = TableApp()
|
||||
app.run()
|
||||
|
||||
@@ -23,7 +23,7 @@ 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) |
|
||||
@@ -31,8 +31,8 @@ The example below populates a table with CSV data.
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
69
src/textual/_two_way_dict.py
Normal file
69
src/textual/_two_way_dict.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
Key = TypeVar("Key")
|
||||
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).
|
||||
"""
|
||||
|
||||
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.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)
|
||||
|
||||
def __delitem__(self, key: Key) -> None:
|
||||
value = self._forward[key]
|
||||
self._forward.__delitem__(key)
|
||||
self._reverse.__delitem__(value)
|
||||
|
||||
def get(self, key: Key) -> Value:
|
||||
"""Given a key, efficiently lookup and return the associated value.
|
||||
|
||||
Args:
|
||||
key: The key
|
||||
|
||||
Returns:
|
||||
The value
|
||||
"""
|
||||
return self._forward.get(key)
|
||||
|
||||
def get_key(self, value: Value) -> Key:
|
||||
"""Given a value, efficiently lookup and return the associated key.
|
||||
|
||||
Args:
|
||||
value: The value
|
||||
|
||||
Returns:
|
||||
The key
|
||||
"""
|
||||
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)
|
||||
|
||||
def __contains__(self, item: Key) -> bool:
|
||||
return item in self._forward
|
||||
@@ -48,7 +48,6 @@ 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 ._path import _make_path_object_relative
|
||||
from ._wait import wait_for_idle
|
||||
from .actions import SkipAction
|
||||
@@ -62,6 +61,7 @@ from .driver import Driver
|
||||
from .drivers.headless_driver import HeadlessDriver
|
||||
from .features import FeatureFlag, parse_features
|
||||
from .file_monitor import FileMonitor
|
||||
from .filter import LineFilter, Monochrome
|
||||
from .geometry import Offset, Region, Size
|
||||
from .keys import REPLACED_KEYS, _get_key_display
|
||||
from .messages import CallbackType
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,29 @@
|
||||
"""Make non-widget DataTable support classes available."""
|
||||
|
||||
from ._data_table import Column, Row
|
||||
from ._data_table import (
|
||||
CellDoesNotExist,
|
||||
CellKey,
|
||||
CellType,
|
||||
Column,
|
||||
ColumnDoesNotExist,
|
||||
ColumnKey,
|
||||
CursorType,
|
||||
DuplicateKey,
|
||||
Row,
|
||||
RowDoesNotExist,
|
||||
RowKey,
|
||||
)
|
||||
|
||||
__all__ = ["Column", "Row"]
|
||||
__all__ = [
|
||||
"CellDoesNotExist",
|
||||
"CellKey",
|
||||
"CellType",
|
||||
"Column",
|
||||
"ColumnDoesNotExist",
|
||||
"ColumnKey",
|
||||
"CursorType",
|
||||
"DuplicateKey",
|
||||
"Row",
|
||||
"RowDoesNotExist",
|
||||
"RowKey",
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
44
tests/snapshot_tests/snapshot_apps/data_table_sort.py
Normal file
44
tests/snapshot_tests/snapshot_apps/data_table_sort.py
Normal file
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
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
|
||||
from textual.app import App
|
||||
from textual.coordinate import Coordinate
|
||||
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 (
|
||||
CellDoesNotExist,
|
||||
CellKey,
|
||||
ColumnDoesNotExist,
|
||||
ColumnKey,
|
||||
DuplicateKey,
|
||||
Row,
|
||||
RowDoesNotExist,
|
||||
RowKey,
|
||||
)
|
||||
|
||||
ROWS = [["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]]
|
||||
|
||||
|
||||
class DataTableApp(App):
|
||||
messages = []
|
||||
messages_to_record = {
|
||||
"CellHighlighted",
|
||||
"CellSelected",
|
||||
@@ -13,8 +34,13 @@ class DataTableApp(App):
|
||||
"RowSelected",
|
||||
"ColumnHighlighted",
|
||||
"ColumnSelected",
|
||||
"HeaderSelected",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
|
||||
def compose(self):
|
||||
table = DataTable()
|
||||
table.focus()
|
||||
@@ -23,7 +49,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)
|
||||
@@ -32,68 +62,66 @@ 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([["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
|
||||
# so the cell at (0, 0) became highlighted.
|
||||
expected_messages.append("CellHighlighted")
|
||||
await pilot.pause(2 / 100)
|
||||
assert messages == expected_messages
|
||||
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")
|
||||
await pilot.pause(2 / 100)
|
||||
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")
|
||||
await pilot.pause(2 / 100)
|
||||
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
|
||||
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")
|
||||
await pilot.pause(2 / 100)
|
||||
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
|
||||
await wait_for_idle(0)
|
||||
assert app.message_names == expected_messages
|
||||
|
||||
# Select the column...
|
||||
await pilot.press("enter")
|
||||
expected_messages.append("ColumnSelected")
|
||||
await pilot.pause(2 / 100)
|
||||
assert messages == expected_messages
|
||||
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 pilot.pause(2 / 100)
|
||||
await wait_for_idle(0)
|
||||
# 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
|
||||
@@ -101,49 +129,798 @@ 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)
|
||||
await wait_for_idle(0)
|
||||
# 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
|
||||
await wait_for_idle(0)
|
||||
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 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 wait_for_idle(0)
|
||||
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")
|
||||
await pilot.pause(2 / 100)
|
||||
assert messages == expected_messages
|
||||
await wait_for_idle(0)
|
||||
assert app.message_names == expected_messages
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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])
|
||||
|
||||
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)
|
||||
|
||||
# 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, height=1)
|
||||
assert table.rows[algernon_key] == first_row
|
||||
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():
|
||||
table = app.query_one(DataTable)
|
||||
column = table.add_column("ABC", width=10, key="ABC")
|
||||
row = table.add_row("123")
|
||||
assert table.get_cell(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():
|
||||
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_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():
|
||||
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 == {}
|
||||
assert table._data == {}
|
||||
assert table.rows == {}
|
||||
assert table.row_count == 0
|
||||
assert len(table.columns) == 1
|
||||
|
||||
# Clearing the columns too
|
||||
table.clear(columns=True)
|
||||
assert len(table.columns) == 0
|
||||
|
||||
|
||||
async def test_column_labels() -> None:
|
||||
app = DataTableApp()
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.add_columns("1", "2", "3")
|
||||
actual_labels = [col.label.plain for col in table.columns.values()]
|
||||
expected_labels = ["1", "2", "3"]
|
||||
assert actual_labels == expected_labels
|
||||
|
||||
|
||||
async def test_initial_column_widths() -> None:
|
||||
app = DataTableApp()
|
||||
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 wait_for_idle()
|
||||
assert table.columns[foo].content_width == 5
|
||||
assert table.columns[bar].content_width == 6
|
||||
|
||||
table.add_row("Hello World!!!", "fo")
|
||||
await wait_for_idle()
|
||||
assert table.columns[foo].content_width == 14
|
||||
assert table.columns[bar].content_width == 6
|
||||
|
||||
|
||||
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("R1", "C1") == "TargetValue"
|
||||
|
||||
|
||||
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("INVALID_ROW", "C1")
|
||||
|
||||
|
||||
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("R1", "INVALID_COLUMN")
|
||||
|
||||
|
||||
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_cell_at(Coordinate(0, 0)) == "0/0"
|
||||
|
||||
|
||||
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_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]
|
||||
|
||||
# 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]
|
||||
|
||||
|
||||
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("INVALID")
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
@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_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_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_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]]
|
||||
|
||||
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()
|
||||
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("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")
|
||||
|
||||
|
||||
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_cell_at(Coordinate(0, 1), "newvalue")
|
||||
assert table.get_cell(row_0, column_1) == "newvalue"
|
||||
|
||||
|
||||
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_cell_at(Coordinate(999, 999), "newvalue")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label,new_value,new_content_width",
|
||||
[
|
||||
# Shorter than initial cell value, larger than label => width remains same
|
||||
("A", "BB", 3),
|
||||
# Larger than cell value, shorter than label => width remains that of label
|
||||
("1234567", "1234", 7),
|
||||
# Shorter than cell value, shorter than label => width remains same
|
||||
("12345", "123", 5),
|
||||
# Larger than cell value, larger than label => width updates to new cell value
|
||||
("12345", "123456789", 9),
|
||||
],
|
||||
)
|
||||
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.
|
||||
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_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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 make_click_event(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():
|
||||
"""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 = make_click_event(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_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 = make_click_event(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_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"
|
||||
column_key = table.add_column("ABC")
|
||||
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 == [
|
||||
"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
|
||||
|
||||
|
||||
async def test_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)
|
||||
|
||||
|
||||
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."""
|
||||
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_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(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_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
|
||||
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
|
||||
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_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(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_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
|
||||
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"
|
||||
] # 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
|
||||
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)
|
||||
|
||||
|
||||
async def test_row_cursor_highlight_events():
|
||||
app = DataTableApp()
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.cursor_type = "row"
|
||||
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 isinstance(latest_message, DataTable.RowHighlighted)
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
assert key == text
|
||||
assert hash(key) == hash(text)
|
||||
|
||||
|
||||
def test_key_doesnt_match_non_equal_string():
|
||||
key = ColumnKey("123")
|
||||
text = "laksjdlaskjd"
|
||||
assert key != text
|
||||
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.
|
||||
dictionary = {
|
||||
"foo": "bar",
|
||||
RowKey("hello"): "world",
|
||||
}
|
||||
assert dictionary["foo"] == "bar"
|
||||
assert dictionary[RowKey("foo")] == "bar"
|
||||
assert dictionary["hello"] == "world"
|
||||
assert dictionary[RowKey("hello")] == "world"
|
||||
|
||||
@@ -2,8 +2,8 @@ import pytest
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from textual._filter import Monochrome
|
||||
from textual._segment_tools import NoCellPositionForIndex
|
||||
from textual.filter import Monochrome
|
||||
from textual.strip import Strip
|
||||
|
||||
|
||||
|
||||
@@ -1,66 +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
|
||||
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.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
|
||||
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.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
|
||||
45
tests/test_two_way_dict.py
Normal file
45
tests/test_two_way_dict.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
|
||||
from textual._two_way_dict import TwoWayDict
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_way_dict():
|
||||
return TwoWayDict(
|
||||
{
|
||||
1: 10,
|
||||
2: 20,
|
||||
3: 30,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get(two_way_dict):
|
||||
assert two_way_dict.get(1) == 10
|
||||
|
||||
|
||||
def test_get_key(two_way_dict):
|
||||
assert two_way_dict.get_key(30) == 3
|
||||
|
||||
|
||||
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_len(two_way_dict):
|
||||
assert len(two_way_dict) == 3
|
||||
|
||||
|
||||
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_contains(two_way_dict):
|
||||
assert 1 in two_way_dict
|
||||
assert 10 not in two_way_dict
|
||||
Reference in New Issue
Block a user