Merge pull request #1143 from Textualize/list-view

List view
This commit is contained in:
Will McGugan
2022-12-09 10:28:47 +00:00
committed by GitHub
15 changed files with 525 additions and 20 deletions

1
docs/api/list_item.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.ListItem

1
docs/api/list_view.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.ListView

View File

@@ -0,0 +1,13 @@
Screen {
align: center middle;
}
ListView {
width: 30;
height: auto;
margin: 2 2;
}
Label {
padding: 1 2;
}

View File

@@ -0,0 +1,17 @@
from textual.app import App, ComposeResult
from textual.widgets import ListView, ListItem, Label, Footer
class ListViewExample(App):
def compose(self) -> ComposeResult:
yield ListView(
ListItem(Label("One")),
ListItem(Label("Two")),
ListItem(Label("Three")),
)
yield Footer()
app = ListViewExample(css_path="list_view.css")
if __name__ == "__main__":
app.run()

46
docs/widgets/list_item.md Normal file
View File

@@ -0,0 +1,46 @@
# List Item
`ListItem` is the type of the elements in a `ListView`.
- [] Focusable
- [] Container
## Example
The example below shows an app with a simple `ListView`, consisting
of multiple `ListItem`s. The arrow keys can be used to navigate the list.
=== "Output"
```{.textual path="docs/examples/widgets/list_view.py"}
```
=== "list_view.py"
```python
--8<-- "docs/examples/widgets/list_view.py"
```
## Reactive Attributes
| Name | Type | Default | Description |
|---------------|--------|---------|--------------------------------------|
| `highlighted` | `bool` | `False` | True if this ListItem is highlighted |
## Messages
### Selected
The `ListItem.Selected` message is sent when the item is selected.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
|-----------|------------|-----------------------------|
| `item` | `ListItem` | The item that was selected. |
## See Also
* [ListItem](../api/list_item.md) code reference

78
docs/widgets/list_view.md Normal file
View File

@@ -0,0 +1,78 @@
# List View
Displays a vertical list of `ListItem`s which can be highlighted and selected.
Supports keyboard navigation.
- [x] Focusable
- [x] Container
## Example
The example below shows an app with a simple `ListView`.
=== "Output"
```{.textual path="docs/examples/widgets/list_view.py"}
```
=== "list_view.py"
```python
--8<-- "docs/examples/widgets/list_view.py"
```
## Reactive Attributes
| Name | Type | Default | Description |
|---------|-------|---------|---------------------------------|
| `index` | `int` | `0` | The currently highlighted index |
## Messages
### Highlighted
The `ListView.Highlighted` message is emitted when the highlight changes.
This happens when you use the arrow keys on your keyboard and when you
click on a list item.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
|-----------|------------|--------------------------------|
| `item` | `ListItem` | The item that was highlighted. |
### Selected
The `ListView.Selected` message is emitted when a list item is selected.
You can select a list item by pressing ++enter++ while it is highlighted,
or by clicking on it.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
|-----------|------------|-----------------------------|
| `item` | `ListItem` | The item that was selected. |
### ChildrenUpdated
The `ListView.ChildrenUpdated` message is emitted when the elements in the `ListView`
are changed (e.g. a child is added, or the list is cleared).
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
|------------|------------------|---------------------------|
| `children` | `list[ListItem]` | The new ListView children |
## See Also
* [ListView](../api/list_view.md) code reference

View File

@@ -99,6 +99,8 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/list_view.md"
- "widgets/list_item.md"
- "widgets/placeholder.md"
- "widgets/static.md"
- "widgets/tree.md"
@@ -119,6 +121,8 @@ nav:
- "api/header.md"
- "api/input.md"
- "api/label.md"
- "api/list_view.md"
- "api/list_item.md"
- "api/message_pump.md"
- "api/message.md"
- "api/pilot.md"

View File

@@ -1955,24 +1955,6 @@ class App(Generic[ReturnType], DOMNode):
# prune event.
return pruned_remove
async def _on_prune(self, event: events.Prune) -> None:
"""Handle a prune event.
Args:
event (events.Prune): The prune event.
"""
try:
# Prune all the widgets.
for widget in event.widgets:
await self._prune_node(widget)
finally:
# Finally, flag that we're done.
event.finished_flag.set()
# Flag that the layout needs refreshing.
self.refresh(layout=True)
def _walk_children(self, root: Widget) -> Iterable[list[Widget]]:
"""Walk children depth first, generating widgets and a list of their siblings.

View File

@@ -289,7 +289,7 @@ class DOMNode(MessagePump):
from .screen import Screen
node = self
while node and not isinstance(node, Screen):
while node is not None and not isinstance(node, Screen):
node = node._parent
if not isinstance(node, Screen):
raise NoScreen("node has no screen")

View File

@@ -8,7 +8,6 @@ from ..case import camel_to_snake
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
# be able to "see" them.
if typing.TYPE_CHECKING:
from ._button import Button
from ._checkbox import Checkbox
from ._data_table import DataTable
@@ -17,6 +16,8 @@ if typing.TYPE_CHECKING:
from ._header import Header
from ._input import Input
from ._label import Label
from ._list_item import ListItem
from ._list_view import ListView
from ._placeholder import Placeholder
from ._pretty import Pretty
from ._static import Static
@@ -26,6 +27,7 @@ if typing.TYPE_CHECKING:
from ._welcome import Welcome
from ..widget import Widget
__all__ = [
"Button",
"Checkbox",
@@ -35,6 +37,8 @@ __all__ = [
"Header",
"Input",
"Label",
"ListItem",
"ListView",
"Placeholder",
"Pretty",
"Static",

View File

@@ -6,6 +6,8 @@ from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
from ._header import Header as Header
from ._label import Label as Label
from ._list_view import ListView as ListView
from ._list_item import ListItem as ListItem
from ._placeholder import Placeholder as Placeholder
from ._pretty import Pretty as Pretty
from ._static import Static as Static

View File

@@ -0,0 +1,39 @@
from textual import events
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
class ListItem(Widget, can_focus=False):
DEFAULT_CSS = """
ListItem {
color: $text;
height: auto;
background: $panel-lighten-1;
overflow: hidden hidden;
}
ListItem > Widget :hover {
background: $boost;
}
ListView > ListItem.--highlight {
background: $accent 50%;
}
ListView:focus > ListItem.--highlight {
background: $accent;
}
ListItem > Widget {
height: auto;
}
"""
highlighted = reactive(False)
def on_click(self, event: events.Click) -> None:
self.emit_no_wait(self._ChildClicked(self))
def watch_highlighted(self, value: bool) -> None:
self.set_class(value, "--highlight")
class _ChildClicked(Message):
"""For informing with the parent ListView that we were clicked"""
pass

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
from textual import events
from textual.await_remove import AwaitRemove
from textual.binding import Binding
from textual.containers import Vertical
from textual.geometry import clamp
from textual.message import Message
from textual.reactive import reactive
from textual.widget import AwaitMount
from textual.widgets._list_item import ListItem
class ListView(Vertical, can_focus=True, can_focus_children=False):
"""Displays a vertical list of `ListItem`s which can be highlighted
and selected using the mouse or keyboard.
Attributes:
index: The index in the list that's currently highlighted.
"""
DEFAULT_CSS = """
ListView {
scrollbar-size-vertical: 2;
}
"""
BINDINGS = [
Binding("down", "cursor_down", "Down", show=False),
Binding("up", "cursor_up", "Up", show=False),
Binding("enter", "select_cursor", "Select", show=False),
]
index = reactive(0, always_update=True)
def __init__(
self,
*children: ListItem,
initial_index: int | None = 0,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
"""
Args:
*children: The ListItems to display in the list.
initial_index: The index that should be highlighted when the list is first mounted.
name: The name of the widget.
id: The unique ID of the widget used in CSS/query selection.
classes: The CSS classes of the widget.
"""
super().__init__(*children, name=name, id=id, classes=classes)
self.index = initial_index
@property
def highlighted_child(self) -> ListItem | None:
"""ListItem | None: The currently highlighted ListItem,
or None if nothing is highlighted.
"""
if self.index is None:
return None
elif 0 <= self.index < len(self.children):
return self.children[self.index]
def validate_index(self, index: int | None) -> int | None:
"""Clamp the index to the valid range, or set to None if there's nothing to highlight."""
if not self.children or index is None:
return None
return self._clamp_index(index)
def _clamp_index(self, index: int) -> int:
"""Clamp the index to a valid value given the current list of children"""
last_index = max(len(self.children) - 1, 0)
return clamp(index, 0, last_index)
def _is_valid_index(self, index: int | None) -> bool:
"""Return True if the current index is valid given the current list of children"""
if index is None:
return False
return 0 <= index < len(self.children)
def watch_index(self, old_index: int, new_index: int) -> None:
"""Updates the highlighting when the index changes."""
if self._is_valid_index(old_index):
old_child = self.children[old_index]
old_child.highlighted = False
if self._is_valid_index(new_index):
new_child = self.children[new_index]
new_child.highlighted = True
else:
new_child = None
self._scroll_highlighted_region()
self.emit_no_wait(self.Highlighted(self, new_child))
def append(self, item: ListItem) -> AwaitMount:
"""Append a new ListItem to the end of the ListView.
Args:
item (ListItem): The ListItem to append.
Returns:
AwaitMount: An awaitable that yields control to the event loop
until the DOM has been updated with the new child item.
"""
await_mount = self.mount(item)
if len(self) == 1:
self.index = 0
return await_mount
def clear(self) -> AwaitRemove:
"""Clear all items from the ListView.
Returns:
AwaitRemove: An awaitable that yields control to the event loop until
the DOM has been updated to reflect all children being removed.
"""
await_remove = self.query("ListView > ListItem").remove()
self.index = None
return await_remove
def action_select_cursor(self) -> None:
selected_child = self.highlighted_child
self.emit_no_wait(self.Selected(self, selected_child))
def action_cursor_down(self) -> None:
self.index += 1
def action_cursor_up(self) -> None:
self.index -= 1
def on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
self.focus()
self.index = self.children.index(event.sender)
self.emit_no_wait(self.Selected(self, event.sender))
def _scroll_highlighted_region(self) -> None:
"""Used to keep the highlighted index within vision"""
if self.highlighted_child is not None:
self.scroll_to_widget(self.highlighted_child, animate=False)
def __len__(self):
return len(self.children)
class Highlighted(Message, bubble=True):
"""Emitted when the highlighted item changes. Highlighted item is controlled using up/down keys"""
def __init__(self, sender: ListView, item: ListItem | None) -> None:
super().__init__(sender)
self.item = item
class Selected(Message, bubble=True):
"""Emitted when a list item is selected, e.g. when you press the enter key on it"""
def __init__(self, sender: ListView, item: ListItem) -> None:
super().__init__(sender)
self.item = item

File diff suppressed because one or more lines are too long

View File

@@ -100,6 +100,10 @@ def test_header_render(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "header.py")
def test_list_view(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"])
def test_textlog_max_lines(snap_compare):
assert snap_compare("snapshot_apps/textlog_max_lines.py", press=[*"abcde", "_"])