Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DataTable.move_cursor #2479

Merged
merged 4 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459


### Added

- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472

## [0.23.0] - 2023-05-03

### Fixed
Expand Down
75 changes: 64 additions & 11 deletions src/textual/widgets/_data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
cursor_coordinate: Reactive[Coordinate] = Reactive(
Coordinate(0, 0), repaint=False, always_update=True
)
"""Current cursor [`Coordinate`][textual.coordinate.Coordinate].

This can be set programmatically or changed via the method
[`move_cursor`][textual.widgets.DataTable.move_cursor].
"""
hover_coordinate: Reactive[Coordinate] = Reactive(
Coordinate(0, 0), repaint=False, always_update=True
)
Expand Down Expand Up @@ -950,7 +955,63 @@ def watch_cursor_coordinate(
elif self.cursor_type == "column":
self.refresh_column(old_coordinate.column)
self._highlight_column(new_coordinate.column)
self._scroll_cursor_into_view()
# If the coordinate was changed via `move_cursor`, give priority to its
# scrolling because it may be animated.
self.call_next(self._scroll_cursor_into_view)

def move_cursor(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about the API here personally, because the parameters are kind of dependent on each other. (e.g. if you pass coordinate and row, things get weird). Do we need the coordinate parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you pass a coordinate and a row, you get an error.

I opted for this signature because if the cursor is a coordinate (DataTable.cursor_coordinate) it makes sense to accept the same type; then I added row and column as convenience parameters, which work similarly to the parameters after and before in methods like Widget.mount and Widget.move_child

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I concur with Darren here. The signature can be ambiguous. Given there can be row and column cursors, where only a single value makes sense, I think we should drop the coordinate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even when the cursor type is row/column, the reactive attribute cursor_coordinate is a full coordinate with X and Y coordinates, so this is aligned with that.
But if you all think it is a bad idea 🤷

self,
coordinate: Coordinate | None = None,
*,
row: int | None = None,
column: int | None = None,
animate: bool = False,
) -> None:
"""Move the cursor to the given position.

The new cursor position can be specified as a coordinate or you can specify
only the new row/column for the cursor.
Specifying a coordinate is mutually exclusive with specifying either a new
row or new column.

Example:
```py
datatable = app.query_one(DataTable)
datatable.move_cursor(Coordinate(5, 7))
# datatable.cursor_coordinate == Coordinate(5, 7)
datatable.move_cursor(row=3)
# datatable.cursor_coordinate == Coordinate(3, 7)
datatable.move_cursor(row=4, column=6)
# datatable.cursor_coordinate == Coordinate(4, 6)
datatable.move_cursor(Coordinate(0, 0), row=3) # RuntimeError
```

Args:
coordinate: The new coordinate to move the cursor to.
row: The new row to move the cursor to.
column: The new column to move the cursor to.
animate: Whether to animate the change of coordinates.

Raises:
RuntimeError: If the parameter `coordinate` is specified together with any
of `row` or `column`, or if no new position is specified.
"""
if coordinate is not None and (row is not None or column is not None):
raise RuntimeError("Can't specify `coordinate` and `row`/`column`.")
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
if coordinate is None and row is None and column is None:
raise RuntimeError("You must specify a new position to move the cursor to.")

if coordinate is not None:
destination = coordinate
else:
cursor_row, cursor_column = self.cursor_coordinate
if row is not None:
cursor_row = row
if column is not None:
cursor_column = column
destination = Coordinate(cursor_row, cursor_column)
self.cursor_coordinate = destination
self._scroll_cursor_into_view(animate=animate)

def _highlight_coordinate(self, coordinate: Coordinate) -> None:
"""Apply highlighting to the cell at the coordinate, and post event."""
Expand Down Expand Up @@ -2052,7 +2113,6 @@ def action_page_down(self) -> None:
self.cursor_coordinate = Coordinate(
row_index + rows_to_scroll - 1, column_index
)
self._scroll_cursor_into_view()
else:
super().action_page_down()

Expand All @@ -2076,7 +2136,6 @@ def action_page_up(self) -> None:
self.cursor_coordinate = Coordinate(
row_index - rows_to_scroll + 1, column_index
)
self._scroll_cursor_into_view()
else:
super().action_page_up()

Expand All @@ -2087,7 +2146,6 @@ def action_scroll_home(self) -> None:
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
row_index, column_index = self.cursor_coordinate
self.cursor_coordinate = Coordinate(0, column_index)
self._scroll_cursor_into_view()
else:
super().action_scroll_home()

Expand All @@ -2098,7 +2156,6 @@ def action_scroll_end(self) -> None:
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
row_index, column_index = self.cursor_coordinate
self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
self._scroll_cursor_into_view()
else:
super().action_scroll_end()

Expand All @@ -2107,7 +2164,6 @@ def action_cursor_up(self) -> None:
cursor_type = self.cursor_type
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
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),
# then ensure that we instead scroll the DataTable.
Expand All @@ -2118,25 +2174,22 @@ def action_cursor_down(self) -> None:
cursor_type = self.cursor_type
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
self.cursor_coordinate = self.cursor_coordinate.down()
self._scroll_cursor_into_view()
else:
super().action_scroll_down()

def action_cursor_right(self) -> None:
self._set_hover_cursor(False)
cursor_type = self.cursor_type
if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"):
self.cursor_coordinate = self.cursor_coordinate.right()
self._scroll_cursor_into_view(animate=True)
self.move_cursor(self.cursor_coordinate.right(), animate=True)
else:
super().action_scroll_right()

def action_cursor_left(self) -> None:
self._set_hover_cursor(False)
cursor_type = self.cursor_type
if self.show_cursor and (cursor_type == "cell" or cursor_type == "column"):
self.cursor_coordinate = self.cursor_coordinate.left()
self._scroll_cursor_into_view(animate=True)
self.move_cursor(self.cursor_coordinate.left(), animate=True)
else:
super().action_scroll_left()

Expand Down
54 changes: 42 additions & 12 deletions tests/test_data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -996,23 +996,53 @@ def test_key_string_lookup():
async def test_scrolling_cursor_into_view():
"""Regression test for https://github.com/Textualize/textual/issues/2459"""

class TableApp(App):
class ScrollingApp(DataTableApp):
CSS = "DataTable { height: 100%; }"

def compose(self):
yield DataTable()

def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_column("n")
table.add_rows([(n,) for n in range(300)])

def key_c(self):
self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)

app = TableApp()
app = ScrollingApp()

async with app.run_test() as pilot:
table = app.query_one(DataTable)
table.add_column("n")
table.add_rows([(n,) for n in range(300)])

await pilot.press("c")
await pilot.pause()
assert app.query_one(DataTable).scroll_y > 100
assert table.scroll_y > 100


async def test_move_cursor():
app = DataTableApp()

async with app.run_test():
table = app.query_one(DataTable)
table.add_columns(*"These are some columns in your nice table".split())
table.add_rows(["These are some columns in your nice table".split()] * 10)

table.move_cursor(Coordinate(5, 7))
assert table.cursor_coordinate == Coordinate(5, 7)
table.move_cursor(row=3)
assert table.cursor_coordinate == Coordinate(3, 7)
table.move_cursor(row=4, column=6)
assert table.cursor_coordinate == Coordinate(4, 6)
table.move_cursor(column=3)
assert table.cursor_coordinate == Coordinate(4, 3)


async def test_move_cursor_raises_error():
app = DataTableApp()

async with app.run_test():
table = app.query_one(DataTable)
table.add_columns(*"These are some columns in your nice table".split())
table.add_rows(["These are some columns in your nice table".split()] * 10)

coordinate = Coordinate(0, 0)
with pytest.raises(RuntimeError):
table.move_cursor(coordinate, row=1)
with pytest.raises(RuntimeError):
table.move_cursor(coordinate, column=1)
with pytest.raises(RuntimeError):
table.move_cursor(coordinate, row=1, column=1)