Skip to content

Commit 8c8057b

Browse files
TomJGoodingwillmcgugandarrenburns
authored
fix(listview): update index after items removed (#5135)
* fix(listview): update index after pop * tests(listview): import future for type hints * ensure pop error is original index rather than normalized * fix(listview): update index after remove_items * update changelog * reinstate always_update to index reactive * Revert "reinstate always_update to index reactive" This reverts commit 68e205e. * handle unchanged index without always_update * update changelog * update changelog * add docstrings --------- Co-authored-by: Will McGugan <[email protected]> Co-authored-by: Darren Burns <[email protected]>
1 parent 79df474 commit 8c8057b

File tree

3 files changed

+143
-12
lines changed

3 files changed

+143
-12
lines changed

CHANGELOG.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8-
### Unreleased
8+
## Unreleased
99

1010
### Fixed
1111

1212
- Fixed infinite loop in `Widget.anchor` https://github.com/Textualize/textual/pull/5290
1313
- Restores the ability to supply console markup to command list https://github.com/Textualize/textual/pull/5294
1414
- Fixed delayed App Resize event https://github.com/Textualize/textual/pull/5296
15+
- Fixed `ListView` not updating its index or highlighting after removing items https://github.com/Textualize/textual/issues/5114
16+
17+
### Changed
18+
19+
- `ListView.pop` now returns `AwaitComplete` rather than `AwaitRemove` https://github.com/Textualize/textual/pull/5135
20+
- `ListView.remove_items` now returns `AwaitComplete` rather than `AwaitRemove` https://github.com/Textualize/textual/pull/5135
1521
- Fixed ListView focus styling rule being too broad https://github.com/Textualize/textual/pull/5304
1622
- Fixed issue with auto-generated tab IDs https://github.com/Textualize/textual/pull/5298
1723

@@ -46,6 +52,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
4652

4753
- Fixed a glitch with the scrollbar that occurs when you hold `a` to add stopwatches in the tutorial app https://github.com/Textualize/textual/pull/5257
4854

55+
4956
## [0.86.2] - 2024-11-18
5057

5158
### Fixed

src/textual/widgets/_list_view.py

+50-10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing_extensions import TypeGuard
66

77
from textual._loop import loop_from_index
8+
from textual.await_complete import AwaitComplete
89
from textual.await_remove import AwaitRemove
910
from textual.binding import Binding, BindingType
1011
from textual.containers import VerticalScroll
@@ -280,7 +281,7 @@ def insert(self, index: int, items: Iterable[ListItem]) -> AwaitMount:
280281
await_mount = self.mount(*items, before=index)
281282
return await_mount
282283

283-
def pop(self, index: Optional[int] = None) -> AwaitRemove:
284+
def pop(self, index: Optional[int] = None) -> AwaitComplete:
284285
"""Remove last ListItem from ListView or
285286
Remove ListItem from ListView by index
286287
@@ -291,13 +292,31 @@ def pop(self, index: Optional[int] = None) -> AwaitRemove:
291292
An awaitable that yields control to the event loop until
292293
the DOM has been updated to reflect item being removed.
293294
"""
294-
if index is None:
295-
await_remove = self.query("ListItem").last().remove()
296-
else:
297-
await_remove = self.query("ListItem")[index].remove()
298-
return await_remove
299-
300-
def remove_items(self, indices: Iterable[int]) -> AwaitRemove:
295+
if len(self) == 0:
296+
raise IndexError("pop from empty list")
297+
298+
index = index if index is not None else -1
299+
item_to_remove = self.query("ListItem")[index]
300+
normalized_index = index if index >= 0 else index + len(self)
301+
302+
async def do_pop() -> None:
303+
"""Remove the item and update the highlighted index."""
304+
await item_to_remove.remove()
305+
if self.index is not None:
306+
if normalized_index < self.index:
307+
self.index -= 1
308+
elif normalized_index == self.index:
309+
old_index = self.index
310+
# Force a re-validation of the index
311+
self.index = self.index
312+
# If the index hasn't changed, the watcher won't be called
313+
# but we need to update the highlighted item
314+
if old_index == self.index:
315+
self.watch_index(old_index, self.index)
316+
317+
return AwaitComplete(do_pop())
318+
319+
def remove_items(self, indices: Iterable[int]) -> AwaitComplete:
301320
"""Remove ListItems from ListView by indices
302321
303322
Args:
@@ -308,8 +327,29 @@ def remove_items(self, indices: Iterable[int]) -> AwaitRemove:
308327
"""
309328
items = self.query("ListItem")
310329
items_to_remove = [items[index] for index in indices]
311-
await_remove = self.remove_children(items_to_remove)
312-
return await_remove
330+
normalized_indices = set(
331+
index if index >= 0 else index + len(self) for index in indices
332+
)
333+
334+
async def do_remove_items() -> None:
335+
"""Remove the items and update the highlighted index."""
336+
await self.remove_children(items_to_remove)
337+
if self.index is not None:
338+
removed_before_highlighted = sum(
339+
1 for index in normalized_indices if index < self.index
340+
)
341+
if removed_before_highlighted:
342+
self.index -= removed_before_highlighted
343+
elif self.index in normalized_indices:
344+
old_index = self.index
345+
# Force a re-validation of the index
346+
self.index = self.index
347+
# If the index hasn't changed, the watcher won't be called
348+
# but we need to update the highlighted item
349+
if old_index == self.index:
350+
self.watch_index(old_index, self.index)
351+
352+
return AwaitComplete(do_remove_items())
313353

314354
def action_select_cursor(self) -> None:
315355
"""Select the current item in the list."""

tests/listview/test_listview_remove_items.py

+85-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
15
from textual.app import App, ComposeResult
2-
from textual.widgets import ListView, ListItem, Label
6+
from textual.widgets import Label, ListItem, ListView
7+
8+
9+
class EmptyListViewApp(App[None]):
10+
def compose(self) -> ComposeResult:
11+
yield ListView()
12+
13+
14+
async def test_listview_pop_empty_raises_index_error():
15+
app = EmptyListViewApp()
16+
async with app.run_test() as pilot:
17+
listview = pilot.app.query_one(ListView)
18+
with pytest.raises(IndexError) as excinfo:
19+
listview.pop()
20+
assert "pop from empty list" in str(excinfo.value)
321

422

523
class ListViewApp(App[None]):
24+
def __init__(self, initial_index: int | None = None):
25+
super().__init__()
26+
self.initial_index = initial_index
27+
self.highlighted = []
28+
629
def compose(self) -> ComposeResult:
730
yield ListView(
831
ListItem(Label("0")),
@@ -14,8 +37,15 @@ def compose(self) -> ComposeResult:
1437
ListItem(Label("6")),
1538
ListItem(Label("7")),
1639
ListItem(Label("8")),
40+
initial_index=self.initial_index,
1741
)
1842

43+
def _on_list_view_highlighted(self, message: ListView.Highlighted) -> None:
44+
if message.item is None:
45+
self.highlighted.append(None)
46+
else:
47+
self.highlighted.append(str(message.item.children[0].renderable))
48+
1949

2050
async def test_listview_remove_items() -> None:
2151
"""Regression test for https://github.com/Textualize/textual/issues/4735"""
@@ -27,6 +57,60 @@ async def test_listview_remove_items() -> None:
2757
assert len(listview) == 4
2858

2959

60+
@pytest.mark.parametrize(
61+
"initial_index, pop_index, expected_new_index, expected_highlighted",
62+
[
63+
(2, 2, 2, ["2", "3"]), # Remove highlighted item
64+
(0, 0, 0, ["0", "1"]), # Remove first item when highlighted
65+
(8, None, 7, ["8", "7"]), # Remove last item when highlighted
66+
(4, 2, 3, ["4", "4"]), # Remove item before the highlighted index
67+
(4, -2, 4, ["4"]), # Remove item after the highlighted index
68+
],
69+
)
70+
async def test_listview_pop_updates_index_and_highlighting(
71+
initial_index, pop_index, expected_new_index, expected_highlighted
72+
) -> None:
73+
"""Regression test for https://github.com/Textualize/textual/issues/5114"""
74+
app = ListViewApp(initial_index)
75+
async with app.run_test() as pilot:
76+
listview = pilot.app.query_one(ListView)
77+
78+
await listview.pop(pop_index)
79+
await pilot.pause()
80+
81+
assert listview.index == expected_new_index
82+
assert listview._nodes[expected_new_index].highlighted is True
83+
assert app.highlighted == expected_highlighted
84+
85+
86+
@pytest.mark.parametrize(
87+
"initial_index, remove_indices, expected_new_index, expected_highlighted",
88+
[
89+
(2, [2], 2, ["2", "3"]), # Remove highlighted item
90+
(0, [0], 0, ["0", "1"]), # Remove first item when highlighted
91+
(8, [-1], 7, ["8", "7"]), # Remove last item when highlighted
92+
(4, [2, 1], 2, ["4", "4"]), # Remove items before the highlighted index
93+
(4, [-2, 5], 4, ["4"]), # Remove items after the highlighted index
94+
(4, range(0, 9), None, ["4", None]), # Remove all items
95+
],
96+
)
97+
async def test_listview_remove_items_updates_index_and_highlighting(
98+
initial_index, remove_indices, expected_new_index, expected_highlighted
99+
) -> None:
100+
"""Regression test for https://github.com/Textualize/textual/issues/5114"""
101+
app = ListViewApp(initial_index)
102+
async with app.run_test() as pilot:
103+
listview = pilot.app.query_one(ListView)
104+
105+
await listview.remove_items(remove_indices)
106+
await pilot.pause()
107+
108+
assert listview.index == expected_new_index
109+
if expected_new_index is not None:
110+
assert listview._nodes[expected_new_index].highlighted is True
111+
assert app.highlighted == expected_highlighted
112+
113+
30114
if __name__ == "__main__":
31115
app = ListViewApp()
32116
app.run()

0 commit comments

Comments
 (0)