From 60423467798b17d927c3e40003468602b24a360d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Nov 2022 17:15:24 +0000 Subject: [PATCH] Add list view and docs --- docs/api/list_view.md | 1 + docs/widgets/list_view.md | 62 ++++++++++++++++++++++ mkdocs.yml | 5 +- src/textual/widgets/_list_item.py | 30 +++++++++-- src/textual/widgets/_list_view.py | 85 ++++++++++++++++++++++++------- 5 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 docs/api/list_view.md create mode 100644 docs/widgets/list_view.md diff --git a/docs/api/list_view.md b/docs/api/list_view.md new file mode 100644 index 000000000..457ddfc52 --- /dev/null +++ b/docs/api/list_view.md @@ -0,0 +1 @@ +::: textual.widgets.ListView diff --git a/docs/widgets/list_view.md b/docs/widgets/list_view.md new file mode 100644 index 000000000..8ed64bddb --- /dev/null +++ b/docs/widgets/list_view.md @@ -0,0 +1,62 @@ +# List View + +Displays a vertical list of widgets 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. | + +## See Also + +* [ListView](../api/list_view.md) code reference diff --git a/mkdocs.yml b/mkdocs.yml index 46f51a2d1..7204e8d40 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - "api/footer.md" - "api/geometry.md" - "api/header.md" + - "api/list_view.md" - "api/message_pump.md" - "api/message.md" - "api/pilot.md" @@ -184,12 +185,12 @@ plugins: - blog: - rss: - match_path: blog/posts/.* + match_path: blog/posts/.* date_from_meta: as_creation: date categories: - categories - - tags + - tags - search: - autorefs: - mkdocstrings: diff --git a/src/textual/widgets/_list_item.py b/src/textual/widgets/_list_item.py index 33d885939..8ebf60900 100644 --- a/src/textual/widgets/_list_item.py +++ b/src/textual/widgets/_list_item.py @@ -1,11 +1,31 @@ +from textual import events +from textual.message import Message from textual.reactive import reactive from textual.widget import Widget -class ListItem(Widget): - DEFAULT_CSS = "ListItem {height: auto;}" - +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; + } + ListItem.--highlight { + background: $accent; + } + ListItem > Widget { + height: auto; + } + """ highlighted = reactive(False) - def watch_highlighted(self, value: bool) -> None: - self.set_class(value, "--highlight") + def on_click(self, event: events.Click) -> None: + self.emit_no_wait(self.ChildSelected(self)) + + class ChildSelected(Message): + pass diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 06074b061..e06574cea 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -1,57 +1,108 @@ from __future__ import annotations +from textual import events 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 Widget from textual.widgets._list_item import ListItem -class ListView(Widget, can_focus=True, can_focus_children=True): +class ListView(Vertical, can_focus=True, can_focus_children=False): DEFAULT_CSS = """ ListView { - layout: vertical; - overflow: auto; + scrollbar-size-vertical: 1; } """ - index = reactive(0) - BINDINGS = [ Binding("down", "down", "Down"), Binding("up", "up", "Up"), + Binding("enter", "select", "Select"), ] + 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: super().__init__(*children, name=name, id=id, classes=classes) + self.index = initial_index - def validate_index(self, index: int) -> int: - last_index = len(self.children) - 1 + @property + def highlighted_child(self) -> ListItem | None: + if 0 <= self.index < len(self.children): + return self.children[self.index] + return None + + def validate_index(self, index: int) -> int | None: + if not self.children: + return None + return self._clamp_index(index) + + def _clamp_index(self, index: int) -> int: + last_index = max(len(self.children) - 1, 0) return clamp(index, 0, last_index) - def watch_index(self, index: int) -> None: - child = self.children[index] - child.highlighted = True + def _is_valid_index(self, index: int | None) -> bool: + if index is None: + return False + return 0 <= index < len(self.children) - def action_down(self) -> None: - self.index += 1 + def watch_index(self, old_index: int, new_index: int) -> None: + if self._is_valid_index(old_index): + old_child = self.children[old_index] + old_child.highlighted = False + old_child.set_class(False, "--highlight") - def action_up(self) -> None: + if self._is_valid_index(new_index): + new_child = self.children[new_index] + new_child.highlighted = True + new_child.set_class(True, "--highlight") + else: + new_child = None + + self._scroll_highlighted_region() + self.emit_no_wait(self.Highlighted(self, new_child)) + + def action_select(self) -> None: + selected_child = self.highlighted_child + self.emit_no_wait(self.Selected(self, selected_child)) + + def on_list_item_child_selected(self, event: ListItem.ChildSelected) -> None: + self.focus() + self.index = self.children.index(event.sender) + self.emit_no_wait(self.Selected(self, event.sender)) + + def key_up(self, event: events.Key) -> None: + event.stop() + event.prevent_default() self.index -= 1 - class Highlighted(Message): - def __init__(self, sender: ListView, item: ListItem) -> None: + def key_down(self, event: events.Key) -> None: + event.stop() + event.prevent_default() + self.index += 1 + + def _scroll_highlighted_region(self) -> None: + 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): + def __init__(self, sender: ListView, item: ListItem | None) -> None: super().__init__(sender) self.item = item - class Selected(Message): + class Selected(Message, bubble=True): def __init__(self, sender: ListView, item: ListItem) -> None: super().__init__(sender) self.item = item