mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
1
docs/api/list_item.md
Normal file
1
docs/api/list_item.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.ListItem
|
||||
1
docs/api/list_view.md
Normal file
1
docs/api/list_view.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.ListView
|
||||
13
docs/examples/widgets/list_view.css
Normal file
13
docs/examples/widgets/list_view.css
Normal file
@@ -0,0 +1,13 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
ListView {
|
||||
width: 30;
|
||||
height: auto;
|
||||
margin: 2 2;
|
||||
}
|
||||
|
||||
Label {
|
||||
padding: 1 2;
|
||||
}
|
||||
17
docs/examples/widgets/list_view.py
Normal file
17
docs/examples/widgets/list_view.py
Normal 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
46
docs/widgets/list_item.md
Normal 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
78
docs/widgets/list_view.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
39
src/textual/widgets/_list_item.py
Normal file
39
src/textual/widgets/_list_item.py
Normal 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
|
||||
157
src/textual/widgets/_list_view.py
Normal file
157
src/textual/widgets/_list_view.py
Normal 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
@@ -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", "_"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user