diff --git a/CHANGELOG.md b/CHANGELOG.md
index dae30d7d3..c6ac7ca1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added TEXTUAL_LOG env var which should be a path that Textual will write verbose logs to (textual devtools is generally preferred) https://github.com/Textualize/textual/pull/2148
- Added textual.logging.TextualHandler logging handler
- Added Query.set_classes, DOMNode.set_classes, and `classes` setter for Widget https://github.com/Textualize/textual/issues/1081
+- Added `OptionList` https://github.com/Textualize/textual/pull/2154
## [0.16.0] - 2023-03-22
diff --git a/docs/api/option_list.md b/docs/api/option_list.md
new file mode 100644
index 000000000..6c7bc05b5
--- /dev/null
+++ b/docs/api/option_list.md
@@ -0,0 +1,3 @@
+::: textual.widgets.OptionList
+::: textual.widgets._option_list.Option
+::: textual.widgets._option_list.Separator
diff --git a/docs/examples/widgets/option_list.css b/docs/examples/widgets/option_list.css
new file mode 100644
index 000000000..cc1b7365f
--- /dev/null
+++ b/docs/examples/widgets/option_list.css
@@ -0,0 +1,10 @@
+Screen {
+ align: center middle;
+}
+
+OptionList {
+ background: $panel;
+ border: round $primary;
+ width: 70%;
+ height: 70%;
+}
diff --git a/docs/examples/widgets/option_list_options.py b/docs/examples/widgets/option_list_options.py
new file mode 100644
index 000000000..de9157c1c
--- /dev/null
+++ b/docs/examples/widgets/option_list_options.py
@@ -0,0 +1,36 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Footer, Header, OptionList
+from textual.widgets.option_list import Option, Separator
+
+
+class OptionListApp(App[None]):
+ CSS_PATH = "option_list.css"
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield OptionList(
+ Option("Aerilon", id="aer"),
+ Option("Aquaria", id="aqu"),
+ Separator(),
+ Option("Canceron", id="can"),
+ Option("Caprica", id="cap", disabled=True),
+ Separator(),
+ Option("Gemenon", id="gem"),
+ Separator(),
+ Option("Leonis", id="leo"),
+ Option("Libran", id="lib"),
+ Separator(),
+ Option("Picon", id="pic"),
+ Separator(),
+ Option("Sagittaron", id="sag"),
+ Option("Scorpia", id="sco"),
+ Separator(),
+ Option("Tauron", id="tau"),
+ Separator(),
+ Option("Virgon", id="vir"),
+ )
+ yield Footer()
+
+
+if __name__ == "__main__":
+ OptionListApp().run()
diff --git a/docs/examples/widgets/option_list_strings.py b/docs/examples/widgets/option_list_strings.py
new file mode 100644
index 000000000..d170efa57
--- /dev/null
+++ b/docs/examples/widgets/option_list_strings.py
@@ -0,0 +1,28 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Footer, Header, OptionList
+
+
+class OptionListApp(App[None]):
+ CSS_PATH = "option_list.css"
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield OptionList(
+ "Aerilon",
+ "Aquaria",
+ "Canceron",
+ "Caprica",
+ "Gemenon",
+ "Leonis",
+ "Libran",
+ "Picon",
+ "Sagittaron",
+ "Scorpia",
+ "Tauron",
+ "Virgon",
+ )
+ yield Footer()
+
+
+if __name__ == "__main__":
+ OptionListApp().run()
diff --git a/docs/examples/widgets/option_list_tables.py b/docs/examples/widgets/option_list_tables.py
new file mode 100644
index 000000000..aaf51a0a2
--- /dev/null
+++ b/docs/examples/widgets/option_list_tables.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from rich.table import Table
+
+from textual.app import App, ComposeResult
+from textual.widgets import Footer, Header, OptionList
+from textual.widgets.option_list import Option, Separator
+
+COLONIES: tuple[tuple[str, str, str, str], ...] = (
+ ("Aerilon", "Demeter", "1.2 Billion", "Gaoth"),
+ ("Aquaria", "Hermes", "75,000", "None"),
+ ("Canceron", "Hephaestus", "6.7 Billion", "Hades"),
+ ("Caprica", "Apollo", "4.9 Billion", "Caprica City"),
+ ("Gemenon", "Hera", "2.8 Billion", "Oranu"),
+ ("Leonis", "Artemis", "2.6 Billion", "Luminere"),
+ ("Libran", "Athena", "2.1 Billion", "None"),
+ ("Picon", "Poseidon", "1.4 Billion", "Queenstown"),
+ ("Sagittaron", "Zeus", "1.7 Billion", "Tawa"),
+ ("Scorpia", "Dionysus", "450 Million", "Celeste"),
+ ("Tauron", "Ares", "2.5 Billion", "Hypatia"),
+ ("Virgon", "Hestia", "4.3 Billion", "Boskirk"),
+)
+
+
+class OptionListApp(App[None]):
+ CSS_PATH = "option_list.css"
+
+ @staticmethod
+ def colony(name: str, god: str, population: str, capital: str) -> Table:
+ table = Table(title=f"Data for {name}", expand=True)
+ table.add_column("Patron God")
+ table.add_column("Population")
+ table.add_column("Capital City")
+ table.add_row(god, population, capital)
+ return table
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield OptionList(*[self.colony(*row) for row in COLONIES])
+ yield Footer()
+
+
+if __name__ == "__main__":
+ OptionListApp().run()
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index abcd8a749..7a9d40e29 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -138,6 +138,15 @@ Display a markdown document.
```{.textual path="docs/examples/widgets/markdown.py"}
```
+## OptionList
+
+Display a vertical list of options (options may be Rich renderables).
+
+[OptionList reference](./widgets/option_list.md){ .md-button .md-button--primary }
+
+
+```{.textual path="docs/examples/widgets/option_list_options.py"}
+```
## Placeholder
diff --git a/docs/widgets/option_list.md b/docs/widgets/option_list.md
new file mode 100644
index 000000000..ab6cfca45
--- /dev/null
+++ b/docs/widgets/option_list.md
@@ -0,0 +1,127 @@
+# OptionList
+
+!!! tip "Added in version 0.17.0"
+
+A widget for showing a vertical list of Rich renderable options.
+
+- [x] Focusable
+- [ ] Container
+
+## Examples
+
+### Options as simple strings
+
+An `OptionList` can be constructed with a simple collection of string
+options:
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/option_list_strings.py"}
+ ```
+
+=== "option_list_strings.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list_strings.py"
+ ~~~
+
+=== "option_list.css"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list.css"
+ ~~~
+
+### Options as `Option` instances
+
+For finer control over the options, the `Option` class can be used; this
+allows for setting IDs, setting initial disabled state, etc. The `Separator`
+class can be used to add separator lines between options.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/option_list_options.py"}
+ ```
+
+=== "option_list_options.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list_options.py"
+ ~~~
+
+=== "option_list.css"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list.css"
+ ~~~
+
+### Options as Rich renderables
+
+Because the prompts for the options can be [Rich
+renderables](https://rich.readthedocs.io/en/latest/protocol.html), this
+means they can be any height you wish. As an example, here is an option list
+comprised of [Rich
+tables](https://rich.readthedocs.io/en/latest/tables.html):
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/option_list_tables.py"}
+ ```
+
+=== "option_list_tables.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list_tables.py"
+ ~~~
+
+=== "option_list.css"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/option_list.css"
+ ~~~
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+|---------------|-----------------|---------|---------------------------------------------------------------------------|
+| `highlighted` | `int` \| `None` | `None` | The index of the highlighted option. `None` means nothing is highlighted. |
+
+## Messages
+
+### ::: textual.widgets.OptionList.OptionHighlighted
+
+### ::: textual.widgets.OptionList.OptionSelected
+
+Both of the messages above inherit from this common base, which makes
+available the following properties relating to the `OptionList` and the
+related `Option`:
+
+### Common message properties
+
+Both of the above messages provide the following properties:
+
+#### ::: textual.widgets.OptionList.OptionMessage.option
+#### ::: textual.widgets.OptionList.OptionMessage.option_id
+#### ::: textual.widgets.OptionList.OptionMessage.option_index
+#### ::: textual.widgets.OptionList.OptionMessage.option_list
+
+## Bindings
+
+The option list widget defines the following bindings:
+
+::: textual.widgets.OptionList.BINDINGS
+ options:
+ show_root_heading: false
+ show_root_toc_entry: false
+
+## Component Classes
+
+The option list provides the following component classes:
+
+::: textual.widgets.OptionList.COMPONENT_CLASSES
+ options:
+ show_root_heading: false
+ show_root_toc_entry: false
+
+## See Also
+
+* [OptionList][textual.widgets.OptionList] code reference
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index e785346eb..60b69e228 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -137,6 +137,7 @@ nav:
- "widgets/loading_indicator.md"
- "widgets/markdown_viewer.md"
- "widgets/markdown.md"
+ - "widgets/option_list.md"
- "widgets/placeholder.md"
- "widgets/radiobutton.md"
- "widgets/radioset.md"
@@ -174,6 +175,7 @@ nav:
- "api/markdown.md"
- "api/message_pump.md"
- "api/message.md"
+ - "api/option_list.md"
- "api/pilot.md"
- "api/placeholder.md"
- "api/query.md"
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 6599f7191..baa7b5b62 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -597,7 +597,7 @@ class MessagePump(metaclass=MessagePumpMeta):
def check_idle(self) -> None:
"""Prompt the message pump to call idle if the queue is empty."""
- if self._message_queue.empty():
+ if self._running and self._message_queue.empty():
self.post_message(messages.Prompt())
async def _post_message(self, message: Message) -> bool:
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index db5717284..003a92642 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -23,6 +23,7 @@ if typing.TYPE_CHECKING:
from ._list_view import ListView
from ._loading_indicator import LoadingIndicator
from ._markdown import Markdown, MarkdownViewer
+ from ._option_list import OptionList
from ._placeholder import Placeholder
from ._pretty import Pretty
from ._radio_button import RadioButton
@@ -51,6 +52,7 @@ __all__ = [
"LoadingIndicator",
"Markdown",
"MarkdownViewer",
+ "OptionList",
"Placeholder",
"Pretty",
"RadioButton",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 66e78fe94..3bde06619 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -13,6 +13,7 @@ from ._list_view import ListView as ListView
from ._loading_indicator import LoadingIndicator as LoadingIndicator
from ._markdown import Markdown as Markdown
from ._markdown import MarkdownViewer as MarkdownViewer
+from ._option_list import OptionList as OptionList
from ._placeholder import Placeholder as Placeholder
from ._pretty import Pretty as Pretty
from ._radio_button import RadioButton as RadioButton
diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py
new file mode 100644
index 000000000..21f357ef8
--- /dev/null
+++ b/src/textual/widgets/_option_list.py
@@ -0,0 +1,944 @@
+"""Provides the core of a classic vertical bounce-bar option list.
+
+Useful as a lightweight list view (not to be confused with ListView, which
+is much richer but uses widgets for the items) and as the base for various
+forms of bounce-bar menu.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar, NamedTuple
+
+from rich.console import RenderableType
+from rich.repr import Result
+from rich.rule import Rule
+from rich.style import Style
+from typing_extensions import Literal, Self, TypeAlias
+
+from ..binding import Binding, BindingType
+from ..events import Click, MouseMove
+from ..geometry import Region, Size
+from ..message import Message
+from ..reactive import reactive
+from ..scroll_view import ScrollView
+from ..strip import Strip
+
+
+class DuplicateID(Exception):
+ """Exception raised if a duplicate ID is used."""
+
+
+class OptionDoesNotExist(Exception):
+ """Exception raised when a request has been made for an option that doesn't exist."""
+
+
+class Option:
+ """Class that holds the details of an individual option."""
+
+ def __init__(
+ self, prompt: RenderableType, id: str | None = None, disabled: bool = False
+ ) -> None:
+ """Initialise the option.
+
+ Args:
+ prompt: The prompt for the option.
+ id: The optional ID for the option.
+ disabled: The initial enabled/disabled state. Enabled by default.
+ """
+ self.__prompt = prompt
+ self.__id = id
+ self.disabled = disabled
+
+ @property
+ def prompt(self) -> RenderableType:
+ """The prompt for the option."""
+ return self.__prompt
+
+ @property
+ def id(self) -> str | None:
+ """The optional ID for the option."""
+ return self.__id
+
+ def __rich_repr__(self) -> Result:
+ yield "prompt", self.prompt
+ yield "id", self.id, None
+ yield "disabled", self.disabled, False
+
+
+class Separator:
+ """Class used to add a separator to an [OptionList][textual.widgets.OptionList]."""
+
+
+class Line(NamedTuple):
+ """Class that holds a list of segments for the line of a option."""
+
+ segments: Strip
+ """The strip of segments that make up the line."""
+
+ option_index: int | None = None
+ """The index of the [Option][textual.widgets.option_list.Option] that this line is related to.
+
+ If the line isn't related to an option this will be `None`.
+ """
+
+
+class OptionLineSpan(NamedTuple):
+ """Class that holds the line span information for an option.
+
+ An [Option][textual.widgets.option_list.Option] can have a prompt that
+ spans multiple lines. Also, there's no requirement that every option in
+ an option list has the same span information. So this structure is used
+ to track the line that an option starts on, and how many lines it
+ contains.
+ """
+
+ first: int
+ """The line position for the start of the option.."""
+ line_count: int
+ """The count of lines that make up the option."""
+
+ def __contains__(self, line: object) -> bool:
+ # For this named tuple `in` will have a very specific meaning; but
+ # to keep mypy and friends happy we need to accept an object as the
+ # parameter. So, let's keep the type checkers happy but only accept
+ # an int.
+ assert isinstance(line, int)
+ return line >= self.first and line < (self.first + self.line_count)
+
+
+OptionListContent: TypeAlias = "Option | Separator"
+"""The type of an item of content in the option list.
+
+This type represents all of the types that will be found in the list of
+content of the option list after it has been processed for addition.
+"""
+
+NewOptionListContent: TypeAlias = "OptionListContent | None | RenderableType"
+"""The type of a new item of option list content to be added to an option list.
+
+This type represents all of the types that will be accepted when adding new
+content to the option list. This is a superset of `OptionListContent`.
+"""
+
+
+class OptionList(ScrollView, can_focus=True):
+ """A vertical option list with bounce-bar highlighting."""
+
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("down", "cursor_down", "Down", show=False),
+ Binding("end", "last", "Last", show=False),
+ Binding("enter", "select", "Select", show=False),
+ Binding("home", "first", "First", show=False),
+ Binding("page_down", "page_down", "Page Down", show=False),
+ Binding("page_up", "page_up", "Page Up", show=False),
+ Binding("up", "cursor_up", "Up", show=False),
+ ]
+ """
+ | Key(s) | Description |
+ | :- | :- |
+ | down | Move the highlight down. |
+ | end | Move the highlight to the last option. |
+ | enter | Select the current option. |
+ | home | Move the highlight to the first option. |
+ | page_down | Move the highlight down a page of options. |
+ | page_up | Move the highlight up a page of options. |
+ | up | Move the highlight up. |
+ """
+
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
+ "option-list--option-disabled",
+ "option-list--option-highlighted",
+ "option-list--option-highlighted-disabled",
+ "option-list--option-hover",
+ "option-list--option-hover-disabled",
+ "option-list--option-hover-highlighted",
+ "option-list--option-hover-highlighted-disabled",
+ "option-list--separator",
+ }
+ """
+ | Class | Description |
+ | :- | :- |
+ | `option-list--option-disabled` | Target disabled options. |
+ | `option-list--option-highlighted` | Target the highlighted option. |
+ | `option-list--option-highlighted-disabled` | Target a disabled option that is also highlighted. |
+ | `option-list--option-hover` | Target an option that has the mouse over it. |
+ | `option-list--option-hover-disabled` | Target a disabled option that has the mouse over it. |
+ | `option-list--option-hover-highlighted` | Target a highlighted option that has the mouse over it. |
+ | `option-list--option-hover-highlighted-disabled` | Target a disabled highlighted option that has the mouse over it. |
+ | `option-list--separator` | Target the separators. |
+ """
+
+ DEFAULT_CSS = """
+ OptionList {
+ background: $panel-lighten-1;
+ color: $text;
+ overflow-x: hidden;
+ }
+
+ OptionList > .option-list--separator {
+ color: $text-muted;
+ }
+
+ OptionList > .option-list--option-highlighted {
+ background: $accent 50%;
+ color: $text;
+ text-style: bold;
+ }
+
+ OptionList:focus > .option-list--option-highlighted {
+ background: $accent;
+ }
+
+ OptionList > .option-list--option-disabled {
+ color: $text-disabled;
+ }
+
+ OptionList > .option-list--option-highlighted-disabled {
+ color: $text-disabled;
+ background: $accent 30%;
+ }
+
+ OptionList:focus > .option-list--option-highlighted-disabled {
+ background: $accent 40%;
+ }
+
+ OptionList > .option-list--option-hover {
+ background: $boost;
+ }
+
+ OptionList > .option-list--option-hover-disabled {
+ color: $text-disabled;
+ background: $boost;
+ }
+
+ OptionList > .option-list--option-hover-highlighted {
+ background: $accent 60%;
+ color: $text;
+ text-style: bold;
+ }
+
+ OptionList:focus > .option-list--option-hover-highlighted {
+ background: $accent;
+ color: $text;
+ text-style: bold;
+ }
+
+ OptionList > .option-list--option-hover-highlighted-disabled {
+ color: $text-disabled;
+ background: $accent 60%;
+ }
+ """
+ """The default styling for an `OptionList`."""
+
+ highlighted: reactive[int | None] = reactive["int | None"](None)
+ """The index of the currently-highlighted option, or `None` if no option is highlighted."""
+
+ class OptionMessage(Message):
+ """Base class for all option messages."""
+
+ def __init__(self, option_list: OptionList, index: int) -> None:
+ """Initialise the option message.
+
+ Args:
+ option_list: The option list that owns the option.
+ index: The index of the option that the message relates to.
+ """
+ super().__init__()
+ self.option_list: OptionList = option_list
+ """The option list that sent the message."""
+ self.option: Option = option_list.get_option_at_index(index)
+ """The highlighted option."""
+ self.option_id: str | None = self.option.id
+ """The ID of the option that the message relates to."""
+ self.option_index: int = index
+ """The index of the option that the message relates to."""
+
+ def __rich_repr__(self) -> Result:
+ yield "option_list", self.option_list
+ yield "option", self.option
+ yield "option_id", self.option_id
+ yield "option_index", self.option_index
+
+ class OptionHighlighted(OptionMessage):
+ """Message sent when an option is highlighted.
+
+ Can be handled using `on_option_list_option_highlighted` in a subclass of
+ `OptionList` or in a parent node in the DOM.
+ """
+
+ class OptionSelected(OptionMessage):
+ """Message sent when an option is selected.
+
+ Can be handled using `on_option_list_option_selected` in a subclass of
+ `OptionList` or in a parent node in the DOM.
+ """
+
+ def __init__(
+ self,
+ *content: NewOptionListContent,
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ):
+ """Initialise the option list.
+
+ Args:
+ *content: The content for the option list.
+ name: The name of the option list.
+ id: The ID of the option list in the DOM.
+ classes: The CSS classes of the option list.
+ disabled: Whether the option list is disabled or not.
+ """
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+
+ # Internal refresh trackers. For things driven from on_idle.
+ self._needs_refresh_content_tracking = False
+ self._needs_to_scroll_to_highlight = False
+
+ self._contents: list[OptionListContent] = [
+ self._make_content(item) for item in content
+ ]
+ """A list of the content of the option list.
+
+ This is *every* item that makes up the content of the option list;
+ this includes both the options *and* the separators (and any other
+ decoration we could end up adding -- although I don't anticipate
+ anything else at the moment; but padding around separators could be
+ a thing, perhaps).
+ """
+
+ self._options: list[Option] = [
+ content for content in self._contents if isinstance(content, Option)
+ ]
+ """A list of the options within the option list.
+
+ This is a list of references to just the options alone, ignoring the
+ separators and potentially any other line-oriented option list
+ content that isn't an option.
+ """
+
+ self._option_ids: dict[str, int] = {}
+ """A dictionary of option IDs and the option indexes they relate to."""
+
+ self._lines: list[Line] = []
+ """A list of all of the individual lines that make up the option list.
+
+ Note that the size of this list will be at least the same as the number
+ of options, and actually greater if any prompt of any option is
+ multiple lines.
+ """
+
+ self._spans: list[OptionLineSpan] = []
+ """A list of the locations and sizes of all options in the option list.
+
+ This will be the same size as the number of prompts; each entry in
+ the list contains the line offset of the start of the prompt, and
+ the count of the lines in the prompt.
+ """
+
+ # Initial calculation of the content tracking.
+ self._request_content_tracking_refresh()
+
+ self._mouse_hovering_over: int | None = None
+ """Used to track what the mouse is hovering over."""
+
+ # Finally, cause the highlighted property to settle down based on
+ # the state of the option list in regard to its available options.
+ # Be sure to have a look at validate_highlighted.
+ self.highlighted = None
+
+ def _request_content_tracking_refresh(
+ self, rescroll_to_highlight: bool = False
+ ) -> None:
+ """Request that the content tracking information gets refreshed.
+
+ Args:
+ rescroll_to_highlight: Should the widget ensure the highlight is visible?
+
+ Calling this method sets a flag to say the refresh should happen,
+ and books the refresh call in for the next idle moment.
+ """
+ self._needs_refresh_content_tracking = True
+ self._needs_to_scroll_to_highlight = rescroll_to_highlight
+ self.check_idle()
+
+ def on_idle(self) -> None:
+ """Perform content tracking data refresh when idle."""
+ self._refresh_content_tracking()
+ if self._needs_to_scroll_to_highlight:
+ self._needs_to_scroll_to_highlight = False
+ self.scroll_to_highlight()
+
+ def watch_show_vertical_scrollbar(self) -> None:
+ """Handle the vertical scrollbar visibility status changing.
+
+ `show_vertical_scrollbar` is watched because it has an impact on the
+ available width in which to render the renderables that make up the
+ options in the list. If a vertical scrollbar appears or disappears
+ we need to recalculate all the lines that make up the list.
+ """
+ self._request_content_tracking_refresh()
+
+ def on_resize(self) -> None:
+ """Refresh the layout of the renderables in the list when resized."""
+ self._request_content_tracking_refresh(rescroll_to_highlight=True)
+
+ def on_mouse_move(self, event: MouseMove) -> None:
+ """React to the mouse moving.
+
+ Args:
+ event: The mouse movement event.
+ """
+ self._mouse_hovering_over = event.style.meta.get("option")
+
+ def on_leave(self) -> None:
+ """React to the mouse leaving the widget."""
+ self._mouse_hovering_over = None
+
+ def on_click(self, event: Click) -> None:
+ """React to the mouse being clicked on an item.
+
+ Args:
+ event: The click event.
+ """
+ clicked_option = event.style.meta.get("option")
+ if clicked_option is not None:
+ self.highlighted = clicked_option
+ self.action_select()
+
+ def _make_content(self, content: NewOptionListContent) -> OptionListContent:
+ """Convert a single item of content for the list into a content type.
+
+ Args:
+ content: The content to turn into a full option list type.
+
+ Returns:
+ The content, usable in the option list.
+ """
+ if isinstance(content, (Option, Separator)):
+ return content
+ if content is None:
+ return Separator()
+ return Option(content)
+
+ def _clear_content_tracking(self) -> None:
+ """Clear down the content tracking information."""
+ self._lines.clear()
+ self._spans.clear()
+ # TODO: Having the option ID tracking be tied up with the main
+ # content tracking isn't necessary. Can possibly improve this a wee
+ # bit.
+ self._option_ids.clear()
+
+ def _refresh_content_tracking(self, force: bool = False) -> None:
+ """Refresh the various forms of option list content tracking.
+
+ Args:
+ force: Optionally force the refresh.
+
+ Raises:
+ DuplicateID: If there is an attempt to use a duplicate ID.
+
+ Without a `force` the refresh will only take place if it has been
+ requested via `_refresh_content_tracking`.
+ """
+
+ # If we don't need to refresh, don't bother.
+ if not self._needs_refresh_content_tracking and not force:
+ return
+
+ # If we don't know our own width yet, we can't sensibly work out the
+ # heights of the prompts of the options yet, so let's shortcut that
+ # work. We'll be back here once we know our height.
+ if not self.size.width:
+ return
+
+ self._clear_content_tracking()
+ self._needs_refresh_content_tracking = False
+
+ # Set up for doing less property access work inside the loop.
+ lines_from = self.app.console.render_lines
+ options = self.app.console.options.update_width(
+ self.scrollable_content_region.width
+ )
+ add_span = self._spans.append
+ option_ids = self._option_ids
+ add_lines = self._lines.extend
+
+ # Create a rule that can be used as a separator.
+ separator = Strip(lines_from(Rule(style=""))[0])
+
+ # Work through each item that makes up the content of the list,
+ # break out the individual lines that will be used to draw it, and
+ # also set up the tracking of the actual options.
+ line = 0
+ option = 0
+ for content in self._contents:
+ if isinstance(content, Option):
+ # The content is an option, so render out the prompt and
+ # work out the lines needed to show it.
+ new_lines = [
+ Line(
+ Strip(prompt_line).apply_style(Style(meta={"option": option})),
+ option,
+ )
+ for prompt_line in lines_from(content.prompt, options)
+ ]
+ # Record the span information for the option.
+ add_span(OptionLineSpan(line, len(new_lines)))
+ if content.id is not None:
+ # The option has an ID set, create a mapping from that
+ # ID to the option so we can use it later.
+ if content.id in option_ids:
+ raise DuplicateID(
+ f"The option list already has an option with id '{content.id}'"
+ )
+ option_ids[content.id] = option
+ option += 1
+ else:
+ # The content isn't an option, so it must be a separator (if
+ # there were to be other non-option content for an option
+ # list it's in this if/else where we'd process it).
+ new_lines = [Line(separator)]
+ add_lines(new_lines)
+ line += len(new_lines)
+
+ # Now that we know how many lines make up the whole content of the
+ # list, set the virtual size.
+ self.virtual_size = Size(self.scrollable_content_region.width, len(self._lines))
+
+ def add_option(self, item: NewOptionListContent = None) -> Self:
+ """Add a new option to the end of the option list.
+
+ Args:
+ item: The new item to add.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ DuplicateID: If there is an attempt to use a duplicate ID.
+ """
+ # Turn any incoming value into valid content for the list.
+ content = self._make_content(item)
+ self._contents.append(content)
+ # If the content is a genuine option, add it to the list of options.
+ if isinstance(content, Option):
+ self._options.append(content)
+ self._refresh_content_tracking(force=True)
+ self.refresh()
+ return self
+
+ def _remove_option(self, index: int) -> None:
+ """Remove an option from the option list.
+
+ Args:
+ index: The index of the item to remove.
+
+ Raises:
+ IndexError: If there is no option of the given index.
+ """
+ option = self._options[index]
+ del self._options[index]
+ del self._contents[self._contents.index(option)]
+ self._refresh_content_tracking(force=True)
+ # Force a re-validation of the highlight.
+ self.highlighted = self.highlighted
+ self.refresh()
+
+ def remove_option(self, option_id: str) -> Self:
+ """Remove the option with the given ID.
+
+ Args:
+ option_id: The ID of the option to remove.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If no option has the given ID.
+ """
+ try:
+ self._remove_option(self._option_ids[option_id])
+ except KeyError:
+ raise OptionDoesNotExist(
+ f"There is no option with an ID of '{option_id}'"
+ ) from None
+ return self
+
+ def remove_option_at_index(self, index: int) -> Self:
+ """Remove the option at the given index.
+
+ Args:
+ index: The index of the option to remove.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If there is no option with the given index.
+ """
+ try:
+ self._remove_option(index)
+ except IndexError:
+ raise OptionDoesNotExist(
+ f"There is no option with an index of {index}"
+ ) from None
+ return self
+
+ def clear_options(self) -> Self:
+ """Clear the content of the option list.
+
+ Returns:
+ The `OptionList` instance.
+ """
+ self._contents.clear()
+ self._options.clear()
+ self._refresh_content_tracking(force=True)
+ self.highlighted = None
+ self._mouse_hovering_over = None
+ self.virtual_size = Size(self.scrollable_content_region.width, 0)
+ self.refresh()
+ return self
+
+ def _set_option_disabled(self, index: int, disabled: bool) -> Self:
+ """Set the disabled state of an option in the list.
+
+ Args:
+ index: The index of the option to set the disabled state of.
+ disabled: The disabled state to set.
+
+ Returns:
+ The `OptionList` instance.
+ """
+ self._options[index].disabled = disabled
+ # TODO: Refresh only if the affected option is visible.
+ self.refresh()
+ return self
+
+ def enable_option_at_index(self, index: int) -> Self:
+ """Enable the option at the given index.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If there is no option with the given index.
+ """
+ try:
+ return self._set_option_disabled(index, False)
+ except IndexError:
+ raise OptionDoesNotExist(
+ f"There is no option with an index of {index}"
+ ) from None
+
+ def disable_option_at_index(self, index: int) -> Self:
+ """Disable the option at the given index.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If there is no option with the given index.
+ """
+ try:
+ return self._set_option_disabled(index, True)
+ except IndexError:
+ raise OptionDoesNotExist(
+ f"There is no option with an index of {index}"
+ ) from None
+
+ def enable_option(self, option_id: str) -> Self:
+ """Enable the option with the given ID.
+
+ Args:
+ option_id: The ID of the option to enable.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If no option has the given ID.
+ """
+ try:
+ return self.enable_option_at_index(self._option_ids[option_id])
+ except KeyError:
+ raise OptionDoesNotExist(
+ f"There is no option with an ID of '{option_id}'"
+ ) from None
+
+ def disable_option(self, option_id: str) -> Self:
+ """Disable the option with the given ID.
+
+ Args:
+ option_id: The ID of the option to disable.
+
+ Returns:
+ The `OptionList` instance.
+
+ Raises:
+ OptionDoesNotExist: If no option has the given ID.
+ """
+ try:
+ return self.disable_option_at_index(self._option_ids[option_id])
+ except KeyError:
+ raise OptionDoesNotExist(
+ f"There is no option with an ID of '{option_id}'"
+ ) from None
+
+ @property
+ def option_count(self) -> int:
+ """The count of options."""
+ return len(self._options)
+
+ def get_option_at_index(self, index: int) -> Option:
+ """Get the option at the given index.
+
+ Args:
+ index: The index of the option to get.
+
+ Returns:
+ The option at that index.
+
+ Raises:
+ OptionDoesNotExist: If there is no option with the index.
+ """
+ try:
+ return self._options[index]
+ except IndexError:
+ raise OptionDoesNotExist(
+ f"There is no option with an index of {index}"
+ ) from None
+
+ def get_option(self, option_id: str) -> Option:
+ """Get the option with the given ID.
+
+ Args:
+ index: The ID of the option to get.
+
+ Returns:
+ The option at with the ID.
+
+ Raises:
+ OptionDoesNotExist: If no option has the given ID.
+ """
+ try:
+ return self.get_option_at_index(self._option_ids[option_id])
+ except KeyError:
+ raise OptionDoesNotExist(
+ f"There is no option with an ID of '{option_id}'"
+ ) from None
+
+ def render_line(self, y: int) -> Strip:
+ """Render a single line in the option list.
+
+ Args:
+ y: The Y offset of the line to render.
+
+ Returns:
+ A `Strip` instance for the caller to render.
+ """
+
+ scroll_x, scroll_y = self.scroll_offset
+
+ # First off, work out which line we're working on, based off the
+ # current scroll offset plus the line we're being asked to render.
+ line_number = scroll_y + y
+ try:
+ line = self._lines[line_number]
+ except IndexError:
+ # An IndexError means we're drawing in an option list where
+ # there's more list than there are options.
+ return Strip([])
+
+ # Now that we know which line we're on, pull out the option index so
+ # we have a "local" copy to refer to rather than needing to do a
+ # property access multiple times.
+ option_index = line.option_index
+
+ # Knowing which line we're going to be drawing, we can now go pull
+ # the relevant segments for the line of that particular prompt.
+ strip = line.segments
+
+ # If the line we're looking at isn't associated with an option, it
+ # will be a separator, so let's exit early with that.
+ if option_index is None:
+ return strip.apply_style(
+ self.get_component_rich_style("option-list--separator")
+ )
+
+ # At this point we know we're drawing actual content. To allow for
+ # horizontal scrolling, let's crop the strip at the right locations.
+ strip = strip.crop(scroll_x, scroll_x + self.scrollable_content_region.width)
+
+ highlighted = self.highlighted
+ mouse_over = self._mouse_hovering_over
+ spans = self._spans
+
+ # Handle drawing a disabled option.
+ if self._options[option_index].disabled:
+ # Disabled but the highlight?
+ if option_index == highlighted:
+ return strip.apply_style(
+ self.get_component_rich_style(
+ "option-list--option-hover-highlighted-disabled"
+ if option_index == mouse_over
+ else "option-list--option-highlighted-disabled"
+ )
+ )
+ # Disabled but mouse hover?
+ if option_index == mouse_over:
+ return strip.apply_style(
+ self.get_component_rich_style("option-list--option-hover-disabled")
+ )
+ # Just a normal disabled option.
+ return strip.apply_style(
+ self.get_component_rich_style("option-list--option-disabled")
+ )
+
+ # Handle drawing a highlighted option.
+ if highlighted is not None and line_number in spans[highlighted]:
+ # Highlighted with the mouse over it?
+ if option_index == mouse_over:
+ return strip.apply_style(
+ self.get_component_rich_style(
+ "option-list--option-hover-highlighted"
+ )
+ )
+ # Just a normal highlight.
+ return strip.apply_style(
+ self.get_component_rich_style("option-list--option-highlighted")
+ )
+
+ # Perhaps the line is within an otherwise-uninteresting option that
+ # has the mouse hovering over it?
+ if mouse_over is not None and line_number in spans[mouse_over]:
+ return strip.apply_style(
+ self.get_component_rich_style("option-list--option-hover")
+ )
+
+ # It's a normal option line.
+ return strip.apply_style(self.rich_style)
+
+ def scroll_to_highlight(self) -> None:
+ """Ensure that the highlighted option is in view."""
+ highlighted = self.highlighted
+ if highlighted is None:
+ return
+ try:
+ span = self._spans[highlighted]
+ except IndexError:
+ # Index error means we're being asked to scroll to a highlight
+ # before all the tracking information has been worked out.
+ # That's fine; let's just NoP that.
+ return
+ self.scroll_to_region(
+ Region(
+ 0, span.first, self.scrollable_content_region.width, span.line_count
+ ),
+ force=True,
+ animate=False,
+ )
+
+ def validate_highlighted(self, highlighted: int | None) -> int | None:
+ """Validate the `highlighted` property value on access."""
+ if not self._options:
+ return None
+ if highlighted is None or highlighted < 0:
+ return 0
+ return min(highlighted, len(self._options) - 1)
+
+ def watch_highlighted(self, highlighted: int | None) -> None:
+ """React to the highlighted option having changed."""
+ if highlighted is not None:
+ self.scroll_to_highlight()
+ if not self._options[highlighted].disabled:
+ self.post_message(self.OptionHighlighted(self, highlighted))
+
+ def action_cursor_up(self) -> None:
+ """Move the highlight up by one option."""
+ if self.highlighted is not None:
+ if self.highlighted > 0:
+ self.highlighted -= 1
+ else:
+ self.highlighted = len(self._options) - 1
+ elif self._options:
+ self.action_first()
+
+ def action_cursor_down(self) -> None:
+ """Move the highlight down by one option."""
+ if self.highlighted is not None:
+ if self.highlighted < len(self._options) - 1:
+ self.highlighted += 1
+ else:
+ self.highlighted = 0
+ elif self._options:
+ self.action_first()
+
+ def action_first(self) -> None:
+ """Move the highlight to the first option."""
+ if self._options:
+ self.highlighted = 0
+
+ def action_last(self) -> None:
+ """Move the highlight to the last option."""
+ if self._options:
+ self.highlighted = len(self._options) - 1
+
+ def _page(self, direction: Literal[-1, 1]) -> None:
+ """Move the highlight by one page.
+
+ Args:
+ direction: The direction to head, -1 for up and 1 for down.
+ """
+
+ # If we find ourselves in a position where we don't know where we're
+ # going, we need a fallback location. Where we go will depend on the
+ # direction.
+ fallback = self.action_first if direction == -1 else self.action_last
+
+ highlighted = self.highlighted
+ if highlighted is None:
+ # There is no highlight yet so let's go to the default position.
+ fallback()
+ else:
+ # We want to page roughly by lines, but we're dealing with
+ # options that can be a varying number of lines in height. So
+ # let's start with the target line alone.
+ target_line = max(
+ 0,
+ self._spans[highlighted].first
+ + (direction * self.scrollable_content_region.height),
+ )
+ try:
+ # Now that we've got a target line, let's figure out the
+ # index of the target option.
+ target_option = self._lines[target_line].option_index
+ except IndexError:
+ # An index error suggests we've gone out of bounds, let's
+ # settle on whatever the call things is a good place to wrap
+ # to.
+ fallback()
+ else:
+ # Looks like we've figured out the next option to jump to.
+ self.highlighted = target_option
+
+ def action_page_up(self):
+ """Move the highlight up one page."""
+ self._page(-1)
+
+ def action_page_down(self):
+ """Move the highlight down one page."""
+ self._page(1)
+
+ def action_select(self) -> None:
+ """Select the currently-highlighted option.
+
+ If no option is selected, then nothing happens. If an option is
+ selected, a [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected]
+ message will be posted.
+ """
+ highlighted = self.highlighted
+ if highlighted is not None and not self._options[highlighted].disabled:
+ self.post_message(self.OptionSelected(self, highlighted))
diff --git a/src/textual/widgets/option_list.py b/src/textual/widgets/option_list.py
new file mode 100644
index 000000000..a014a0980
--- /dev/null
+++ b/src/textual/widgets/option_list.py
@@ -0,0 +1,3 @@
+from ._option_list import DuplicateID, Option, OptionDoesNotExist, Separator
+
+__all__ = ["DuplicateID", "Option", "OptionDoesNotExist", "Separator"]
diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py
new file mode 100644
index 000000000..ed3239122
--- /dev/null
+++ b/tests/option_list/test_option_list_create.py
@@ -0,0 +1,115 @@
+"""Core option list unit tests, aimed at testing basic list creation."""
+
+from __future__ import annotations
+
+import pytest
+
+from textual.app import App, ComposeResult
+from textual.widgets import OptionList
+from textual.widgets.option_list import (
+ DuplicateID,
+ Option,
+ OptionDoesNotExist,
+ Separator,
+)
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def compose(self) -> ComposeResult:
+ yield OptionList(
+ "0",
+ Option("1"),
+ Separator(),
+ Option("2", disabled=True),
+ None,
+ Option("3", id="3"),
+ Option("4", id="4", disabled=True),
+ )
+
+
+async def test_all_parameters_become_options() -> None:
+ """All input parameters to a list should become options."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 5
+ for n in range(5):
+ assert isinstance(option_list.get_option_at_index(n), Option)
+
+
+async def test_id_capture() -> None:
+ """All options given an ID should retain the ID."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ with_id = 0
+ without_id = 0
+ for n in range(5):
+ if option_list.get_option_at_index(n).id is None:
+ without_id += 1
+ else:
+ with_id += 1
+ assert with_id == 2
+ assert without_id == 3
+
+
+async def test_get_option_by_id() -> None:
+ """It should be possible to get an option by ID."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.get_option("3").prompt == "3"
+ assert option_list.get_option("4").prompt == "4"
+
+
+async def test_get_option_with_bad_id() -> None:
+ """Asking for an option with a bad ID should give an error."""
+ async with OptionListApp().run_test() as pilot:
+ with pytest.raises(OptionDoesNotExist):
+ _ = pilot.app.query_one(OptionList).get_option("this does not exist")
+
+
+async def test_get_option_by_index() -> None:
+ """It should be possible to get an option by index."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for n in range(5):
+ assert option_list.get_option_at_index(n).prompt == str(n)
+ assert option_list.get_option_at_index(-1).prompt == "4"
+
+
+async def test_get_option_at_bad_index() -> None:
+ """Asking for an option at a bad index should give an error."""
+ async with OptionListApp().run_test() as pilot:
+ with pytest.raises(OptionDoesNotExist):
+ _ = pilot.app.query_one(OptionList).get_option_at_index(42)
+ with pytest.raises(OptionDoesNotExist):
+ _ = pilot.app.query_one(OptionList).get_option_at_index(-42)
+
+
+async def test_clear_option_list() -> None:
+ """It should be possible to clear the option list of all content."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 5
+ option_list.clear_options()
+ assert option_list.option_count == 0
+
+
+async def test_add_later() -> None:
+ """It should be possible to add more items to a list."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 5
+ option_list.add_option("more")
+ assert option_list.option_count == 6
+ option_list.add_option()
+ assert option_list.option_count == 6
+ option_list.add_option(Option("even more"))
+ assert option_list.option_count == 7
+
+
+async def test_create_with_duplicate_id() -> None:
+ """Adding an option with a duplicate ID should be an error."""
+ async with OptionListApp().run_test() as pilot:
+ with pytest.raises(DuplicateID):
+ pilot.app.query_one(OptionList).add_option(Option("dupe", id="3"))
diff --git a/tests/option_list/test_option_list_disabled.py b/tests/option_list/test_option_list_disabled.py
new file mode 100644
index 000000000..ce58bab5d
--- /dev/null
+++ b/tests/option_list/test_option_list_disabled.py
@@ -0,0 +1,80 @@
+"""Unit tests for testing an option list's disabled facility."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def __init__(self, disabled: bool) -> None:
+ super().__init__()
+ self.initial_disabled = disabled
+
+ def compose(self) -> ComposeResult:
+ """Compose the child widgets."""
+ yield OptionList(
+ *[
+ Option(str(n), id=str(n), disabled=self.initial_disabled)
+ for n in range(100)
+ ]
+ )
+
+
+async def test_default_enabled() -> None:
+ """Options created enabled should remain enabled."""
+ async with OptionListApp(False).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for option in range(option_list.option_count):
+ assert option_list.get_option_at_index(option).disabled is False
+
+
+async def test_default_disabled() -> None:
+ """Options created disabled should remain disabled."""
+ async with OptionListApp(True).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for option in range(option_list.option_count):
+ assert option_list.get_option_at_index(option).disabled is True
+
+
+async def test_enabled_to_disabled_via_index() -> None:
+ """It should be possible to change enabled to disabled via index."""
+ async with OptionListApp(False).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for n in range(option_list.option_count):
+ assert option_list.get_option_at_index(n).disabled is False
+ option_list.disable_option_at_index(n)
+ assert option_list.get_option_at_index(n).disabled is True
+
+
+async def test_disabled_to_enabled_via_index() -> None:
+ """It should be possible to change disabled to enabled via index."""
+ async with OptionListApp(True).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for n in range(option_list.option_count):
+ assert option_list.get_option_at_index(n).disabled is True
+ option_list.enable_option_at_index(n)
+ assert option_list.get_option_at_index(n).disabled is False
+
+
+async def test_enabled_to_disabled_via_id() -> None:
+ """It should be possible to change enabled to disabled via id."""
+ async with OptionListApp(False).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for n in range(option_list.option_count):
+ assert option_list.get_option(str(n)).disabled is False
+ option_list.disable_option(str(n))
+ assert option_list.get_option(str(n)).disabled is True
+
+
+async def test_disabled_to_enabled_via_id() -> None:
+ """It should be possible to change disabled to enabled via id."""
+ async with OptionListApp(True).run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for n in range(option_list.option_count):
+ assert option_list.get_option(str(n)).disabled is True
+ option_list.enable_option(str(n))
+ assert option_list.get_option(str(n)).disabled is False
diff --git a/tests/option_list/test_option_list_mouse_hover.py b/tests/option_list/test_option_list_mouse_hover.py
new file mode 100644
index 000000000..a76f782f8
--- /dev/null
+++ b/tests/option_list/test_option_list_mouse_hover.py
@@ -0,0 +1,51 @@
+"""Unit tests aimed at checking the OptionList mouse hover handing."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.geometry import Offset
+from textual.widgets import Label, OptionList
+from textual.widgets.option_list import Option
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def compose(self) -> ComposeResult:
+ yield Label("Something else to hover over")
+ yield OptionList(*[Option(str(n), id=str(n)) for n in range(10)])
+
+
+async def test_no_hover() -> None:
+ """When the mouse isn't over the OptionList _mouse_hovering_over should be None."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.hover(Label)
+ assert pilot.app.query_one(OptionList)._mouse_hovering_over is None
+
+
+async def test_hover_highlight() -> None:
+ """The mouse hover value should react to the mouse hover over a highlighted option."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.hover(OptionList)
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list._mouse_hovering_over == 0
+ assert option_list._mouse_hovering_over == option_list.highlighted
+
+
+async def test_hover_no_highlight() -> None:
+ """The mouse hover value should react to the mouse hover over a non-highlighted option."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.hover(OptionList, Offset(1, 1))
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list._mouse_hovering_over == 1
+ assert option_list._mouse_hovering_over != option_list.highlighted
+
+
+async def test_hover_then_leave() -> None:
+ """After a mouse has been over an OptionList and left _mouse_hovering_over should be None again."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.hover(OptionList)
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list._mouse_hovering_over == 0
+ await pilot.hover(Label)
+ assert option_list._mouse_hovering_over is None
diff --git a/tests/option_list/test_option_list_movement.py b/tests/option_list/test_option_list_movement.py
new file mode 100644
index 000000000..65e425bee
--- /dev/null
+++ b/tests/option_list/test_option_list_movement.py
@@ -0,0 +1,159 @@
+"""Test movement within an option list."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.widgets import OptionList
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def compose(self) -> ComposeResult:
+ yield OptionList("1", "2", "3", None, "4", "5", "6")
+
+
+async def test_initial_highlight() -> None:
+ """The highlight should start on the first item."""
+ async with OptionListApp().run_test() as pilot:
+ assert pilot.app.query_one(OptionList).highlighted == 0
+
+
+async def test_cleared_highlight_is_none() -> None:
+ """The highlight should be `None` if the list is cleared."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ option_list.clear_options()
+ assert option_list.highlighted is None
+
+
+async def test_cleared_movement_does_nothing() -> None:
+ """The highlight should remain `None` if the list is cleared."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ option_list.clear_options()
+ assert option_list.highlighted is None
+ await pilot.press("tab", "down", "up", "page_down", "page_up", "home", "end")
+ assert option_list.highlighted is None
+
+
+async def test_move_down() -> None:
+ """The highlight should move down when asked to."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.press("tab", "down")
+ assert pilot.app.query_one(OptionList).highlighted == 1
+
+
+async def test_move_down_from_end() -> None:
+ """The highlight should wrap around when moving down from the end."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ option_list.highlighted = 5
+ await pilot.press("tab", "down")
+ assert option_list.highlighted == 0
+
+
+async def test_move_up() -> None:
+ """The highlight should move up when asked to."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ option_list.highlighted = 1
+ await pilot.press("tab", "up")
+ assert option_list.highlighted == 0
+
+
+async def test_move_up_from_nowhere() -> None:
+ """The highlight should settle on the last item when moving up from `None`."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.press("tab", "up")
+ assert pilot.app.query_one(OptionList).highlighted == 5
+
+
+async def test_move_end() -> None:
+ """The end key should go to the end of the list."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.press("tab", "end")
+ assert pilot.app.query_one(OptionList).highlighted == 5
+
+
+async def test_move_home() -> None:
+ """The home key should go to the start of the list."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.highlighted == 0
+ option_list.highlighted = 5
+ assert option_list.highlighted == 5
+ await pilot.press("tab", "home")
+ assert option_list.highlighted == 0
+
+
+async def test_page_down_from_start_short_list() -> None:
+ """Doing a page down from the start of a short list should move to the end."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.press("tab", "page_down")
+ assert pilot.app.query_one(OptionList).highlighted == 5
+
+
+async def test_page_up_from_end_short_list() -> None:
+ """Doing a page up from the end of a short list should move to the start."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.highlighted == 0
+ option_list.highlighted = 5
+ assert option_list.highlighted == 5
+ await pilot.press("tab", "page_up")
+ assert option_list.highlighted == 0
+
+
+async def test_page_down_from_end_short_list() -> None:
+ """Doing a page down from the end of a short list should go nowhere."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.highlighted == 0
+ option_list.highlighted = 5
+ assert option_list.highlighted == 5
+ await pilot.press("tab", "page_down")
+ assert option_list.highlighted == 5
+
+
+async def test_page_up_from_start_short_list() -> None:
+ """Doing a page up from the start of a short list go nowhere."""
+ async with OptionListApp().run_test() as pilot:
+ await pilot.press("tab", "page_up")
+ assert pilot.app.query_one(OptionList).highlighted == 0
+
+
+class EmptyOptionListApp(App[None]):
+ """Test option list application with no optons."""
+
+ def compose(self) -> ComposeResult:
+ yield OptionList()
+
+
+async def test_empty_list_movement() -> None:
+ """Attempting to move around an empty list should be a non-operation."""
+ async with EmptyOptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ await pilot.press("tab")
+ for movement in ("up", "down", "home", "end", "page_up", "page_down"):
+ await pilot.press(movement)
+ assert option_list.highlighted is None
+
+
+async def test_no_highlight_movement() -> None:
+ """Attempting to move around in a list with no highlight should select the most appropriate item."""
+ for movement, landing in (
+ ("up", 0),
+ ("down", 0),
+ ("home", 0),
+ ("end", 99),
+ ("page_up", 0),
+ ("page_down", 99),
+ ):
+ async with EmptyOptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ for _ in range(100):
+ option_list.add_option("test")
+ await pilot.press("tab")
+ await pilot.press(movement)
+ assert option_list.highlighted == landing
diff --git a/tests/option_list/test_option_list_option_subclass.py b/tests/option_list/test_option_list_option_subclass.py
new file mode 100644
index 000000000..9e59c55dd
--- /dev/null
+++ b/tests/option_list/test_option_list_option_subclass.py
@@ -0,0 +1,38 @@
+"""Unit tests aimed at ensuring the option list option class can be subclassed."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+
+class OptionWithExtras(Option):
+ """An example subclass of a option."""
+
+ def __init__(self, test: int) -> None:
+ super().__init__(str(test), str(test), False)
+ self.test = test
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def compose(self) -> ComposeResult:
+ yield OptionList(*[OptionWithExtras(n) for n in range(100)])
+
+
+async def test_option_list_with_subclassed_options() -> None:
+ """It should be possible to build an option list with subclassed options."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 100
+ for n in range(option_list.option_count):
+ for option in (
+ option_list.get_option(str(n)),
+ option_list.get_option_at_index(n),
+ ):
+ assert isinstance(option, OptionWithExtras)
+ assert option.prompt == str(n)
+ assert option.id == str(n)
+ assert option.test == n
diff --git a/tests/option_list/test_option_messages.py b/tests/option_list/test_option_messages.py
new file mode 100644
index 000000000..67f190550
--- /dev/null
+++ b/tests/option_list/test_option_messages.py
@@ -0,0 +1,120 @@
+"""Unit tests aimed at testing the option list messages."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.geometry import Offset
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.messages: list[tuple[str, str, int]] = []
+
+ def compose(self) -> ComposeResult:
+ yield OptionList(*[Option(str(n), id=str(n)) for n in range(10)])
+
+ def _record(self, event: OptionList.OptionMessage) -> None:
+ assert isinstance(event.option_id, str)
+ self.messages.append(
+ (event.__class__.__name__, event.option_id, event.option_index)
+ )
+
+ def on_option_list_option_highlighted(
+ self, event: OptionList.OptionHighlighted
+ ) -> None:
+ self._record(event)
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ self._record(event)
+
+
+async def test_messages_on_startup() -> None:
+ """There should be a highlighted message when a non-empty option list first starts up."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.pause()
+ assert pilot.app.messages == [("OptionHighlighted", "0", 0)]
+
+
+async def test_same_highlight_message() -> None:
+ """Highlighting a highlight should result in no message."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.pause()
+ pilot.app.query_one(OptionList).highlighted = 0
+ await pilot.pause()
+ assert pilot.app.messages == [("OptionHighlighted", "0", 0)]
+
+
+async def test_highlight_disabled_option_no_message() -> None:
+ """Highlighting a disabled option should result in no messages."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.pause()
+ pilot.app.query_one(OptionList).disable_option("1")
+ pilot.app.query_one(OptionList).highlighted = 1
+ await pilot.pause()
+ assert pilot.app.messages[1:] == []
+
+
+async def test_new_highlight() -> None:
+ """Setting the highlight to a new option should result in a message."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.pause()
+ pilot.app.query_one(OptionList).highlighted = 2
+ await pilot.pause()
+ assert pilot.app.messages[1:] == [("OptionHighlighted", "2", 2)]
+
+
+async def test_move_highlight_with_keyboard() -> None:
+ """Changing option via the keyboard should result in a message."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.press("tab", "down")
+ assert pilot.app.messages[1:] == [("OptionHighlighted", "1", 1)]
+
+
+async def test_select_message_with_keyboard() -> None:
+ """Hitting enter on an option should result in a message."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.press("tab", "down", "enter")
+ assert pilot.app.messages[1:] == [
+ ("OptionHighlighted", "1", 1),
+ ("OptionSelected", "1", 1),
+ ]
+
+
+async def test_select_disabled_option_with_keyboard() -> None:
+ """Hitting enter on an option should result in a message."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ pilot.app.query_one(OptionList).disable_option("1")
+ await pilot.press("tab", "down", "enter")
+ assert pilot.app.messages[1:] == []
+
+
+async def test_click_option_with_mouse() -> None:
+ """Clicking on an option via the mouse should result in highlight and select messages."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ await pilot.click(OptionList, Offset(1, 1))
+ assert pilot.app.messages[1:] == [
+ ("OptionHighlighted", "1", 1),
+ ("OptionSelected", "1", 1),
+ ]
+
+
+async def test_click_disabled_option_with_mouse() -> None:
+ """Clicking on a disabled option via the mouse should result no messages."""
+ async with OptionListApp().run_test() as pilot:
+ assert isinstance(pilot.app, OptionListApp)
+ pilot.app.query_one(OptionList).disable_option("1")
+ await pilot.click(OptionList, Offset(1, 1))
+ assert pilot.app.messages[1:] == []
diff --git a/tests/option_list/test_option_removal.py b/tests/option_list/test_option_removal.py
new file mode 100644
index 000000000..45d2748d8
--- /dev/null
+++ b/tests/option_list/test_option_removal.py
@@ -0,0 +1,85 @@
+"""Test removing options from an option list."""
+
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+
+
+class OptionListApp(App[None]):
+ """Test option list application."""
+
+ def compose(self) -> ComposeResult:
+ yield OptionList(
+ Option("0", id="0"),
+ Option("1", id="1"),
+ )
+
+
+async def test_remove_first_option_via_index() -> None:
+ """It should be possible to remove the first option of an option list, via index."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option_at_index(0)
+ assert option_list.option_count == 1
+ assert option_list.highlighted == 0
+
+
+async def test_remove_first_option_via_id() -> None:
+ """It should be possible to remove the first option of an option list, via ID."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option("0")
+ assert option_list.option_count == 1
+ assert option_list.highlighted == 0
+
+
+async def test_remove_last_option_via_index() -> None:
+ """It should be possible to remove the last option of an option list, via index."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option_at_index(1)
+ assert option_list.option_count == 1
+ assert option_list.highlighted == 0
+
+
+async def test_remove_last_option_via_id() -> None:
+ """It should be possible to remove the last option of an option list, via ID."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option("1")
+ assert option_list.option_count == 1
+ assert option_list.highlighted == 0
+
+
+async def test_remove_all_options_via_index() -> None:
+ """It should be possible to remove all options via index."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option_at_index(0)
+ option_list.remove_option_at_index(0)
+ assert option_list.option_count == 0
+ assert option_list.highlighted is None
+
+
+async def test_remove_all_options_via_id() -> None:
+ """It should be possible to remove all options via ID."""
+ async with OptionListApp().run_test() as pilot:
+ option_list = pilot.app.query_one(OptionList)
+ assert option_list.option_count == 2
+ assert option_list.highlighted == 0
+ option_list.remove_option("0")
+ option_list.remove_option("1")
+ assert option_list.option_count == 0
+ assert option_list.highlighted is None
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 42c7fd18a..589be45f8 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -183,202 +183,199 @@
font-weight: 700;
}
- .terminal-3056812568-matrix {
+ .terminal-1817785991-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3056812568-title {
+ .terminal-1817785991-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3056812568-r1 { fill: #c5c8c6 }
- .terminal-3056812568-r2 { fill: #e3e3e3 }
- .terminal-3056812568-r3 { fill: #004578 }
- .terminal-3056812568-r4 { fill: #e1e1e1 }
- .terminal-3056812568-r5 { fill: #632ca6 }
- .terminal-3056812568-r6 { fill: #dde6ed;font-weight: bold }
- .terminal-3056812568-r7 { fill: #14191f }
- .terminal-3056812568-r8 { fill: #23568b }
+ .terminal-1817785991-r1 { fill: #c5c8c6 }
+ .terminal-1817785991-r2 { fill: #e3e3e3 }
+ .terminal-1817785991-r3 { fill: #004578 }
+ .terminal-1817785991-r4 { fill: #e1e1e1 }
+ .terminal-1817785991-r5 { fill: #632ca6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- MyApp
+ MyApp
-
-
-
- ⭘MyApp
- ╭──────────────────╮╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
- │ok││test│
- │╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍││╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍│
- │││╭─ 0 ──────────────────────────────────────╮╭─ 1 ──────────────────────────────────────╮╭─ 2 ─────│
- │││││││││
- ││││ Foo Bar Baz ││ Foo Bar Baz ││ Foo │
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY▁▁││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY▁▁││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- ││││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH 0123456789 IJKLMNOPQRSTUVWXY││ ABCDEFGH│
- │││╰──────────────────────────────────────────╯╰──────────────────────────────────────────╯╰─────────│
- │││▋│
- ╰──────────────────╯╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
+
+
+
+ ⭘MyApp
+ ╭──────────────────╮╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
+ │ok││test│
+ │╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍││╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍│
+ │││╭╮╭╮╭╮╭╮│
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ ││││││││││││
+ │││╰╯╰╯╰╯╰╯│
+ ╰──────────────────╯╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -5945,141 +5942,139 @@
font-weight: 700;
}
- .terminal-3573285936-matrix {
+ .terminal-2701996076-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3573285936-title {
+ .terminal-2701996076-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3573285936-r1 { fill: #c5c8c6 }
- .terminal-3573285936-r2 { fill: #e8e0e7 }
- .terminal-3573285936-r3 { fill: #ddedf9 }
- .terminal-3573285936-r4 { fill: #eae3e5 }
- .terminal-3573285936-r5 { fill: #ede6e6 }
- .terminal-3573285936-r6 { fill: #efe9e4 }
- .terminal-3573285936-r7 { fill: #efeedf }
- .terminal-3573285936-r8 { fill: #e9eee5 }
- .terminal-3573285936-r9 { fill: #e4eee8 }
- .terminal-3573285936-r10 { fill: #e2edeb }
- .terminal-3573285936-r11 { fill: #dfebed }
+ .terminal-2701996076-r1 { fill: #c5c8c6 }
+ .terminal-2701996076-r2 { fill: #e8e0e7 }
+ .terminal-2701996076-r3 { fill: #ddedf9 }
+ .terminal-2701996076-r4 { fill: #eae3e5 }
+ .terminal-2701996076-r5 { fill: #ede6e6 }
+ .terminal-2701996076-r6 { fill: #efe9e4 }
+ .terminal-2701996076-r7 { fill: #e4eee8 }
+ .terminal-2701996076-r8 { fill: #e2edeb }
+ .terminal-2701996076-r9 { fill: #dfebed }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- HeightComparisonApp
+ HeightComparisonApp
-
-
-
- #cells·
- ·
- ·
- #percent·
- •
- ·
- #w·
- ·
- ·
- •
- #h·
- ·
- ·
- ·
- #vw•
- ·
- ·
- ·
- #vh·
- •
- #auto·
- #fr1·
- #fr2·
- ·
+
+
+
+ #cells·
+ ·
+ ·
+ #percent·
+ •
+ ·
+ #w·
+ ·
+ ·
+ •
+ #h·
+ ·
+ #auto·
+ ·
+ #fr1•
+ ·
+ ·
+ ·
+ ·
+ #fr2•
+ ·
+ ·
+ ·
+ ·
@@ -11969,141 +11964,139 @@
font-weight: 700;
}
- .terminal-1938916138-matrix {
+ .terminal-3285483541-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1938916138-title {
+ .terminal-3285483541-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1938916138-r1 { fill: #c5c8c6 }
- .terminal-1938916138-r2 { fill: #e8e0e7 }
- .terminal-1938916138-r3 { fill: #eae3e5 }
- .terminal-1938916138-r4 { fill: #ede6e6 }
- .terminal-1938916138-r5 { fill: #efe9e4 }
- .terminal-1938916138-r6 { fill: #efeedf }
- .terminal-1938916138-r7 { fill: #e9eee5 }
- .terminal-1938916138-r8 { fill: #e4eee8 }
- .terminal-1938916138-r9 { fill: #e2edeb }
- .terminal-1938916138-r10 { fill: #dfebed }
- .terminal-1938916138-r11 { fill: #ddedf9 }
+ .terminal-3285483541-r1 { fill: #c5c8c6 }
+ .terminal-3285483541-r2 { fill: #e8e0e7 }
+ .terminal-3285483541-r3 { fill: #eae3e5 }
+ .terminal-3285483541-r4 { fill: #ede6e6 }
+ .terminal-3285483541-r5 { fill: #efe9e4 }
+ .terminal-3285483541-r6 { fill: #e4eee8 }
+ .terminal-3285483541-r7 { fill: #e2edeb }
+ .terminal-3285483541-r8 { fill: #dfebed }
+ .terminal-3285483541-r9 { fill: #ddedf9 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- HeightComparisonApp
+ HeightComparisonApp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- #cells#percent#w#h#vw#vh#auto#fr1#fr3
-
-
-
-
-
-
-
-
-
-
-
- ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #cells#percent#w#h#auto#fr1#fr3
+
+
+
+
+
+
+
+
+
+
+
+ ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•
@@ -12133,137 +12126,131 @@
font-weight: 700;
}
- .terminal-1071832686-matrix {
+ .terminal-1523559455-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1071832686-title {
+ .terminal-1523559455-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1071832686-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-1071832686-r2 { fill: #1e1201;font-weight: bold }
- .terminal-1071832686-r3 { fill: #dde6ed }
- .terminal-1071832686-r4 { fill: #c5c8c6 }
- .terminal-1071832686-r5 { fill: #dfe4e7 }
- .terminal-1071832686-r6 { fill: #1e1405 }
- .terminal-1071832686-r7 { fill: #e1e1e1 }
- .terminal-1071832686-r8 { fill: #211505 }
+ .terminal-1523559455-r1 { fill: #e1e1e1 }
+ .terminal-1523559455-r2 { fill: #c5c8c6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- lane swimmer country time
- 4 Joseph Schooling Singapore 50.39
- 2 Michael Phelps United States 51.14
- 5 Chad le Clos South Africa 51.14
- 6 László Cseh Hungary 51.14
- 3 Li Zhuhao China 51.26
- 8 Mehdy Metella France 51.58
- 7 Tom Shields United States 51.73
- 1 Aleksandr Sadovnikov Russia 51.84
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12294,135 +12281,131 @@
font-weight: 700;
}
- .terminal-1710966859-matrix {
+ .terminal-1523559455-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-1710966859-title {
+ .terminal-1523559455-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-1710966859-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-1710966859-r2 { fill: #dde6ed }
- .terminal-1710966859-r3 { fill: #c5c8c6 }
- .terminal-1710966859-r4 { fill: #1e1405 }
- .terminal-1710966859-r5 { fill: #dfe4e7 }
- .terminal-1710966859-r6 { fill: #e1e1e1 }
+ .terminal-1523559455-r1 { fill: #e1e1e1 }
+ .terminal-1523559455-r2 { fill: #c5c8c6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- lane swimmer country time
- 0 5 Chad le Clos South Africa 51.14
- 1 4 Joseph Schooling Singapore 50.39
- 2 2 Michael Phelps United States 51.14
- 3 6 László Cseh Hungary 51.14
- 4 3 Li Zhuhao China 51.26
- 5 8 Mehdy Metella France 51.58
- 6 7 Tom Shields United States 51.73
- 7 10 Darren Burns Scotland 51.84
- 8 1 Aleksandr Sadovnikov Russia 51.84
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12453,134 +12436,131 @@
font-weight: 700;
}
- .terminal-2311386745-matrix {
+ .terminal-1523559455-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2311386745-title {
+ .terminal-1523559455-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2311386745-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-2311386745-r2 { fill: #dde6ed }
- .terminal-2311386745-r3 { fill: #c5c8c6 }
- .terminal-2311386745-r4 { fill: #e1e1e1 }
- .terminal-2311386745-r5 { fill: #211505 }
+ .terminal-1523559455-r1 { fill: #e1e1e1 }
+ .terminal-1523559455-r2 { fill: #c5c8c6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- lane swimmer country time
- 4 Joseph Schooling Singapore 50.39
- 2 Michael Phelps United States 51.14
- 5 Chad le Clos South Africa 51.14
- 6 László Cseh Hungary 51.14
- 3 Li Zhuhao China 51.26
- 8 Mehdy Metella France 51.58
- 7 Tom Shields United States 51.73
- 1 Aleksandr Sadovnikov Russia 51.84
- 10 Darren Burns Scotland 51.84
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12611,136 +12591,131 @@
font-weight: 700;
}
- .terminal-3008422431-matrix {
+ .terminal-1523559455-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3008422431-title {
+ .terminal-1523559455-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3008422431-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-3008422431-r2 { fill: #dde6ed }
- .terminal-3008422431-r3 { fill: #c5c8c6 }
- .terminal-3008422431-r4 { fill: #dfe4e7 }
- .terminal-3008422431-r5 { fill: #e1e1e1 }
- .terminal-3008422431-r6 { fill: #1e1405 }
- .terminal-3008422431-r7 { fill: #211505 }
+ .terminal-1523559455-r1 { fill: #e1e1e1 }
+ .terminal-1523559455-r2 { fill: #c5c8c6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- lane swimmer country time
- 4 Joseph Schooling Singapore 50.39
- 2 Michael Phelps United States 51.14
- 5 Chad le Clos South Africa 51.14
- 6 László Cseh Hungary 51.14
- 3 Li Zhuhao China 51.26
- 8 Mehdy Metella France 51.58
- 7 Tom Shields United States 51.73
- 1 Aleksandr Sadovnikov Russia 51.84
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -12771,134 +12746,131 @@
font-weight: 700;
}
- .terminal-2683041401-matrix {
+ .terminal-1523559455-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2683041401-title {
+ .terminal-1523559455-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2683041401-r1 { fill: #dde6ed;font-weight: bold }
- .terminal-2683041401-r2 { fill: #dde6ed }
- .terminal-2683041401-r3 { fill: #c5c8c6 }
- .terminal-2683041401-r4 { fill: #e1e1e1 }
- .terminal-2683041401-r5 { fill: #211505 }
+ .terminal-1523559455-r1 { fill: #e1e1e1 }
+ .terminal-1523559455-r2 { fill: #c5c8c6 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- TableApp
+ TableApp
-
-
-
- lane swimmer country time
- 4 Joseph Schooling Singapore 50.39
- 2 Michael Phelps United States 51.14
- 5 Chad le Clos South Africa 51.14
- 6 László Cseh Hungary 51.14
- 3 Li Zhuhao China 51.26
- 8 Mehdy Metella France 51.58
- 7 Tom Shields United States 51.73
- 1 Aleksandr Sadovnikov Russia 51.84
- 10 Darren Burns Scotland 51.84
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -17263,6 +17235,493 @@
'''
# ---
+# name: test_option_list
+ '''
+
+
+ '''
+# ---
+# name: test_option_list.1
+ '''
+
+
+ '''
+# ---
+# name: test_option_list.2
+ '''
+
+
+ '''
+# ---
# name: test_order_independence
'''